Compare commits
108
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6756d2fca5 | ||
|
|
5998c0fc46 | ||
|
|
f5b15a0394 | ||
|
|
38e5fd84bb | ||
|
|
e0113e3413 | ||
|
|
637b8061ed | ||
|
|
29095e087f | ||
|
|
e3e6d51f6f | ||
|
|
df1575fe27 | ||
|
|
a39993075e | ||
|
|
9ed278b440 | ||
|
|
16d4c72920 | ||
|
|
1e32410fca | ||
|
|
8880601ef6 | ||
|
|
dcf8ace6e1 | ||
|
|
1a6fb85259 | ||
|
|
5ca60d7066 | ||
|
|
e7b801d584 | ||
|
|
638c58f0e4 | ||
|
|
92627da459 | ||
|
|
15ad5c1151 | ||
|
|
92e2f4f069 | ||
|
|
a08c193a90 | ||
|
|
8fbebed140 | ||
|
|
9c96c36fc6 | ||
|
|
ffa38d5f28 | ||
|
|
e465797b5f | ||
|
|
b4a311c84f | ||
|
|
739068ccb9 | ||
|
|
8cd704ed08 | ||
|
|
eb5a89623b | ||
|
|
f193661e68 | ||
|
|
53623ebb4a | ||
|
|
db6c806d58 | ||
|
|
6652970925 | ||
|
|
dc8f5a75e1 | ||
|
|
fecef1135c | ||
|
|
200697216d | ||
|
|
434e5bad4e | ||
|
|
4ff5ed11bb | ||
|
|
60252437ac | ||
|
|
75849beb90 | ||
|
|
8f21e16277 | ||
|
|
12a95d1e89 | ||
|
|
66688031cd | ||
|
|
7abac87140 | ||
|
|
1b4fdc83d8 | ||
|
|
ce80c36be1 | ||
|
|
07fb02790c | ||
|
|
b0f353af90 | ||
|
|
514b15b1ca | ||
|
|
5f8b813eb4 | ||
|
|
6668dcfb56 | ||
|
|
1eb1dfe317 | ||
|
|
cf89cec20e | ||
|
|
24e536b9b9 | ||
|
|
e6c893219c | ||
|
|
01c398f2f7 | ||
|
|
f2e4ffe860 | ||
|
|
358dcb94b2 | ||
|
|
2b40eebf5b | ||
|
|
793ca1b410 | ||
|
|
5e38fc2f76 | ||
|
|
c23d4dad8b | ||
|
|
e2ce8420a7 | ||
|
|
488c0aa210 | ||
|
|
48f1ed7fb8 | ||
|
|
389712bba3 | ||
|
|
584004a25c | ||
|
|
6a8528fc98 | ||
|
|
8ef7c0a53b | ||
|
|
dc6e1e32fb | ||
|
|
2681233449 | ||
|
|
7a7673119e | ||
|
|
8deafeae8e | ||
|
|
59a31d6a1d | ||
|
|
8bedf432e2 | ||
|
|
42cd361c58 | ||
|
|
87c3d73fbc | ||
|
|
564b8aaa8d | ||
|
|
7cc6343ba1 | ||
|
|
c9048143cc | ||
|
|
203a67ee86 | ||
|
|
3c7c121c7d | ||
|
|
0e5e28b4da | ||
|
|
22e4082825 | ||
|
|
0c3ea75cac | ||
|
|
a451386e0a | ||
|
|
8bd65d535e | ||
|
|
c5585df135 | ||
|
|
385819caf0 | ||
|
|
3f7bd94a69 | ||
|
|
6fd03f37f7 | ||
|
|
65989b3a5a | ||
|
|
5f6726f173 | ||
|
|
fda3ec158c | ||
|
|
c872246b8c | ||
|
|
e9a239e419 | ||
|
|
bc256ad369 | ||
|
|
7185aa4ba5 | ||
|
|
de5b093d62 | ||
|
|
2c69cbe775 | ||
|
|
285d14e996 | ||
|
|
3c73eb5a27 | ||
|
|
95d88709f9 | ||
|
|
56f5a00417 | ||
|
|
e9bdf60f82 | ||
|
|
d12fb96dc2 |
No files matched your search
@@ -0,0 +1,42 @@
|
||||
# Память проекта
|
||||
|
||||
Основная работа в репозитории — Android-приложение SHX VPN (`src/frontends/android`), форк клиента strongSwan.
|
||||
Описание приложения, кода, локализации и релиза: `src/frontends/android/docs/README.md`.
|
||||
Дизайн: `src/frontends/android/docs/design/README.md` (макет «Финал 2026»).
|
||||
|
||||
## Android-приложение: дизайн и UI
|
||||
|
||||
- **Эталон — макет «Финал 2026»** (артборды `F_*`, тёмная тема `D_*`): живой холст https://claude.ai/artifact/UCC88C9hkU3hDU2YwMck1a,
|
||||
копия исходников в `docs/design/final-2026/`. Экраны делаются строго по макету; новый экран сначала появляется на холсте.
|
||||
- UI собирается только из компонентов макета: `ui/compose/design/ShxKit.kt`, `ShxIcons.kt`, цвета и шрифты `ui/compose/theme/ShxDesign.kt`
|
||||
(`LocalShxColors`, `ShxType`). Сторонние цвета, размеры и стандартные M3-диалоги не использовать.
|
||||
- Изменили экран в коде — обновите `F_*` и парный `D_*` на холсте и копию в `docs/design/final-2026/`.
|
||||
|
||||
## Android-приложение: строки и переводы
|
||||
|
||||
- Языки: en (база), ru, de, pl, uk, zh-rCN, zh-rTW. Новая строка добавляется сразу во все семь `values*/strings.xml`.
|
||||
- Плейсхолдеры и `\n` как в английской строке; кавычки и plurals по языку — см. раздел «Локализация» в `docs/README.md`.
|
||||
- После правок строк смотреть de и uk на эмуляторе (`adb shell cmd locale set-app-locales one.shx.strongswan.ext --locales de-DE`):
|
||||
длинные слова не должны переноситься в навигации и заголовках. Сброс — `--locales ""`.
|
||||
|
||||
## Android-приложение: сборка и выкладка
|
||||
|
||||
- **Бакет S3 (Selectel, `shx-app`) ПРИВАТНЫЙ.** Публичного чтения нет и включать его не нужно:
|
||||
`GET https://s3.ru-7.storage.selcloud.ru/shx-app/latest.json` всегда отдаёт `AccessDenied` — это норма, а не ошибка выкладки.
|
||||
Проверять результат публикации только авторизованно: `aws s3 cp s3://shx-app/latest.json -` (endpoint и ключи из `.env`).
|
||||
- Сборка и публикация — скриптом `build-local.sh` (Git Bash) или `build-local.ps1` в `src/frontends/android`:
|
||||
он читает `.env`, собирает `assembleRelease`, подписывает keystore'ом из `.env`, генерирует `latest.json`
|
||||
(sha256/size/versionCode) и заливает `shx-vpn.apk` + `latest.json` в корень бакета с `Cache-Control: no-cache`.
|
||||
- **Порядок релиза:** `--skip-upload` → smoke-тест APK на эмуляторе (AVD `shx36`, все вкладки, `adb logcat -b crash`) →
|
||||
выгрузка именно проверенного `build/publish/shx-vpn.apk` и `latest.json` через `aws s3 cp` → сверка `latest.json` в бакете.
|
||||
Выгрузка в S3 — только с подтверждения владельца.
|
||||
- **`.env` нельзя парсить через `source`/`eval`**: значения `NOTES_RU` и `AWS_CA_BUNDLE` содержат пробелы и
|
||||
обратные слэши. Разбирать только построчно `KEY=...` (как в `build-local.sh`).
|
||||
- aws cli установлен через pip: `%APPDATA%\Python\Python313\Scripts\aws` (скрипт сам добавляет путь в PATH).
|
||||
- `versionCode` растёт вручную в `app/build.gradle`; перед сборкой сверять с `versionCode` в `latest.json` бакета.
|
||||
- `NOTES_RU` в `.env` — заметки релиза (попадают в `latest.json` → диалог обновления в приложении); обновлять под релиз.
|
||||
`.env` не в git: в worktree лежит своя копия, после релиза перенести `NOTES_RU` в `.env` основного checkout.
|
||||
- **Сборка из worktree:** скопировать игнорируемые входы из основного checkout (`.env` с абсолютным `KEYSTORE_FILE`,
|
||||
`local.properties`, `Android.common.mk`, `app/src/main/jni/openssl/`, сгенерированные исходники libstrongswan — список в `docs/README.md`)
|
||||
и собирать с `ORG_GRADLE_PROJECT_shortNdkCommands=true` (иначе линковка упирается в лимит длины команды Windows).
|
||||
- Автообновление приложения: `logic/updates/UpdateManager.kt` качает манифест/APK, сверяет SHA-256 и подпись.
|
||||
@@ -0,0 +1,40 @@
|
||||
<!--
|
||||
Параметры сборки и публикации обновлений.
|
||||
Скопируйте этот файл в .env и заполните значения:
|
||||
cp .env.example .env
|
||||
.env не попадает в git (добавлен в .gitignore).
|
||||
Строки в формате KEY=value; комментарии - строки, начинающиеся с #.
|
||||
-->
|
||||
|
||||
# --- S3 (Selectel Object Storage) ---
|
||||
S3_ENDPOINT=https://s3.ru-7.storage.selcloud.ru
|
||||
S3_REGION=ru-7
|
||||
S3_BUCKET=shx-app
|
||||
# Пусто = файлы кладутся в корень бакета (latest.json и shx-vpn.apk)
|
||||
S3_PREFIX=
|
||||
S3_ACCESS_KEY=
|
||||
S3_SECRET_KEY=
|
||||
|
||||
# Если AWS CLI ругается на SSL (self-signed certificate in certificate chain),
|
||||
# укажите CA-бандл, содержащий корень сертификата эндпоинта
|
||||
# (для Selectel - GlobalSign Root CA - R6; например бандл из Git for Windows):
|
||||
# AWS_CA_BUNDLE=C:\Program Files\Git\mingw64\etc\ssl\certs\ca-bundle.crt
|
||||
|
||||
# Публичная статичная ссылка на бакет (без завершающего слеша).
|
||||
# Требуется публичный доступ на чтение бакета в панели Selectel.
|
||||
# Зашивается в APK как адрес проверки обновлений.
|
||||
S3_PUBLIC_BASE_URL=https://s3.ru-7.storage.selcloud.ru/shx-app
|
||||
|
||||
# --- Подпись APK (опционально) ---
|
||||
# Если KEYSTORE_FILE не задан, release APK подписывается debug-ключом
|
||||
# (~/.android/debug.keystore) - это нужно, чтобы обновление встало поверх
|
||||
# сборок, установленных из Android Studio.
|
||||
# Путь - относительно каталога android-проекта или абсолютный.
|
||||
# KEYSTORE_FILE=release.keystore
|
||||
# KEYSTORE_PASSWORD=
|
||||
# KEY_ALIAS=
|
||||
# KEY_PASSWORD=
|
||||
|
||||
# --- Текст "что нового" в latest.json (опционально, одна строка без кавычек) ---
|
||||
# NOTES_EN=
|
||||
# NOTES_RU=
|
||||
@@ -7,9 +7,17 @@ app/src/main/obj
|
||||
app/src/main/jni/openssl/
|
||||
*.iml
|
||||
local.properties
|
||||
.env
|
||||
*.apk
|
||||
*.aab
|
||||
*.apks
|
||||
*.idsig
|
||||
*.jks
|
||||
*.keystore
|
||||
|
||||
# local release notes for the update manifest (kept out of the repo)
|
||||
NOTES_*.txt
|
||||
keystore/
|
||||
*.jks
|
||||
*.keystore
|
||||
.kotlin/
|
||||
@@ -0,0 +1,191 @@
|
||||
apply plugin: 'com.android.application'
|
||||
apply plugin: 'org.jetbrains.kotlin.plugin.compose'
|
||||
|
||||
/* Optional .env next to this project: S3/update settings and signing (see .env.example).
|
||||
Environment variables take precedence over .env values. */
|
||||
def dotenv = [:]
|
||||
def envFile = rootProject.file('.env')
|
||||
if (envFile.exists()) {
|
||||
envFile.eachLine { line ->
|
||||
def trimmed = line.trim()
|
||||
if (trimmed && !trimmed.startsWith('#') && trimmed.contains('=')) {
|
||||
def idx = trimmed.indexOf('=')
|
||||
dotenv[trimmed.substring(0, idx).trim()] = trimmed.substring(idx + 1).trim()
|
||||
}
|
||||
}
|
||||
}
|
||||
def envOr = { String key, String defaultValue ->
|
||||
(System.getenv(key) ?: dotenv[key] ?: defaultValue)
|
||||
}
|
||||
|
||||
def updateBaseUrl = envOr('S3_PUBLIC_BASE_URL', '').replace('\\', '\\\\').replace('"', '\\"')
|
||||
|
||||
def keystoreFile = envOr('KEYSTORE_FILE', '')
|
||||
def keystorePassword = envOr('KEYSTORE_PASSWORD', '')
|
||||
/* note: locals must not be named keyAlias/keyPassword - those names would
|
||||
shadow the signingConfig methods inside the signingConfigs block */
|
||||
def keystoreKeyAlias = envOr('KEY_ALIAS', '')
|
||||
def keystoreKeyPassword = envOr('KEY_PASSWORD', '')
|
||||
|
||||
android {
|
||||
namespace = 'org.strongswan.android'
|
||||
|
||||
/* minSdk >= 23 flips AGP's default to uncompressed .so (extractNativeLibs=false):
|
||||
* the APK grew 12 -> 23 MB. Updates are downloaded over cellular, so keep
|
||||
* the libraries compressed as before. */
|
||||
packaging {
|
||||
jniLibs {
|
||||
useLegacyPackaging = true
|
||||
}
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
applicationId "one.shx.strongswan.ext"
|
||||
compileSdk = 37
|
||||
minSdk = 24
|
||||
targetSdk = 36
|
||||
|
||||
versionCode 166
|
||||
versionName "2.18.1"
|
||||
|
||||
/* Base URL of the update storage, trailing slash included */
|
||||
buildConfigField "String", "UPDATE_BASE_URL", "\"${updateBaseUrl}\""
|
||||
|
||||
/* S3 credentials used by the app for authenticated update downloads
|
||||
(same keys as the publish script). They are obfuscated with XOR +
|
||||
Base64 at build time to hinder trivial string extraction.
|
||||
Note: obfuscation is not real protection - an APK can always be
|
||||
reverse engineered, so rotate these keys if they leak. */
|
||||
def updateS3Access = envOr('S3_ACCESS_KEY', '')
|
||||
def updateS3Secret = envOr('S3_SECRET_KEY', '')
|
||||
def updateXorKey = new byte[16]
|
||||
System.arraycopy(java.security.MessageDigest.getInstance('SHA-256')
|
||||
.digest('shx-update-credentials'.getBytes('UTF-8')), 0, updateXorKey, 0, 16)
|
||||
def xorB64 = { String value ->
|
||||
if (!value) { return '' }
|
||||
def bytes = value.getBytes('UTF-8')
|
||||
for (int i = 0; i < bytes.length; i++) {
|
||||
bytes[i] = (byte)(bytes[i] ^ updateXorKey[i % updateXorKey.length])
|
||||
}
|
||||
java.util.Base64.getEncoder().encodeToString(bytes)
|
||||
}
|
||||
buildConfigField "String", "UPDATE_S3_ACCESS_KEY", "\"${xorB64(updateS3Access)}\""
|
||||
buildConfigField "String", "UPDATE_S3_SECRET_KEY", "\"${xorB64(updateS3Secret)}\""
|
||||
buildConfigField "String", "UPDATE_S3_KEY_XOR", "\"${java.util.Base64.getEncoder().encodeToString(updateXorKey)}\""
|
||||
buildConfigField "String", "UPDATE_S3_REGION", "\"${envOr('S3_REGION', 'us-east-1')}\""
|
||||
|
||||
externalNativeBuild {
|
||||
ndkBuild {
|
||||
arguments '-j' + Runtime.runtime.availableProcessors()
|
||||
/* deep checkout paths (e.g. git worktrees) overflow the Windows command line; opt in with -PshortNdkCommands */
|
||||
if (project.hasProperty('shortNdkCommands')) {
|
||||
arguments 'APP_SHORT_COMMANDS=true', 'LOCAL_SHORT_COMMANDS=true'
|
||||
}
|
||||
abiFilters 'armeabi-v7a', 'arm64-v8a', 'x86_64'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ndkVersion = "27.3.13750724"
|
||||
|
||||
externalNativeBuild {
|
||||
ndkBuild {
|
||||
path 'src/main/jni/Android.mk'
|
||||
}
|
||||
}
|
||||
|
||||
tasks.withType(JavaCompile).configureEach {
|
||||
compileTask ->
|
||||
options.compilerArgs << "-Xlint:unchecked" << "-Xlint:deprecation"
|
||||
}
|
||||
|
||||
signingConfigs {
|
||||
release {
|
||||
if (keystoreFile) {
|
||||
storeFile rootProject.file(keystoreFile)
|
||||
storePassword keystorePassword
|
||||
keyAlias keystoreKeyAlias
|
||||
keyPassword keystoreKeyPassword
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
/* R8 shrinking + resource shrinking: strips dead code/resources and
|
||||
obfuscates. The JNI surface is kept via proguard-rules.txt.
|
||||
Smoke-test a release build on a device before publishing. */
|
||||
minifyEnabled true
|
||||
shrinkResources true
|
||||
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.txt'
|
||||
if (keystoreFile) {
|
||||
signingConfig signingConfigs.release
|
||||
} else {
|
||||
/* fall back to the debug key so that release APKs update debug-signed
|
||||
installations in place (no uninstall, data is preserved) */
|
||||
def debugKeystore = new File(System.getProperty('user.home'), '.android/debug.keystore')
|
||||
if (debugKeystore.exists()) {
|
||||
signingConfig signingConfigs.debug
|
||||
println "WARNING: KEYSTORE_FILE not set (see .env.example) - signing the release APK with the debug key (${debugKeystore})"
|
||||
} else {
|
||||
println "WARNING: no signing configuration - the release APK will be UNSIGNED (set KEYSTORE_FILE in .env or run a debug build once)"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
compileOptions {
|
||||
targetCompatibility JavaVersion.VERSION_11
|
||||
sourceCompatibility JavaVersion.VERSION_11
|
||||
}
|
||||
buildFeatures {
|
||||
compose true
|
||||
buildConfig true
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation 'androidx.appcompat:appcompat:1.7.1'
|
||||
implementation 'androidx.core:core:1.17.0'
|
||||
implementation 'androidx.core:core-splashscreen:1.0.1'
|
||||
implementation 'androidx.lifecycle:lifecycle-process:2.9.4'
|
||||
implementation 'androidx.lifecycle:lifecycle-runtime-compose:2.9.4'
|
||||
implementation 'androidx.lifecycle:lifecycle-viewmodel-compose:2.9.4'
|
||||
implementation 'androidx.preference:preference:1.2.1'
|
||||
implementation 'androidx.localbroadcastmanager:localbroadcastmanager:1.1.0'
|
||||
implementation 'androidx.work:work-runtime-ktx:2.10.1'
|
||||
implementation 'com.google.android.material:material:1.13.0'
|
||||
|
||||
/* BOM 2026.09.00 = compose 1.12. The Expressive API (MaterialExpressiveTheme,
|
||||
MotionScheme.expressive, LoadingIndicator, MaterialShapes) lives only in
|
||||
material3 1.5.0-alpha/beta, so material3 is pinned explicitly (needs AGP 9.1,
|
||||
compileSdk 37, minSdk 24). Unpin once 1.5.0 is stable. */
|
||||
def composeBom = platform('androidx.compose:compose-bom:2026.09.00')
|
||||
implementation composeBom
|
||||
implementation 'androidx.compose.ui:ui'
|
||||
implementation 'androidx.compose.material3:material3:1.5.0-beta01'
|
||||
/* adaptive-navigation-suite (NavigationSuiteScaffold) requires minSdk 23+ (material3 1.5 requires 24) */
|
||||
implementation 'androidx.compose.material3:material3-adaptive-navigation-suite:1.5.0-beta01'
|
||||
/* list-detail layout for Profiles on expanded widths */
|
||||
implementation 'androidx.compose.material3.adaptive:adaptive-layout:1.3.0'
|
||||
implementation 'androidx.compose.material3.adaptive:adaptive-navigation:1.3.0'
|
||||
implementation 'androidx.compose.material:material-icons-extended'
|
||||
/* home screen widget (status + connect button) */
|
||||
implementation 'androidx.glance:glance-appwidget:1.2.0'
|
||||
implementation 'androidx.glance:glance-material3:1.2.0'
|
||||
implementation 'androidx.activity:activity-compose:1.11.0'
|
||||
/* QR: scanning profiles (camera) and showing a profile as a QR code */
|
||||
implementation 'com.journeyapps:zxing-android-embedded:4.3.0'
|
||||
|
||||
debugImplementation 'androidx.compose.ui:ui-tooling'
|
||||
implementation 'androidx.compose.ui:ui-tooling-preview'
|
||||
|
||||
testImplementation 'junit:junit:4.13.2'
|
||||
testImplementation 'org.assertj:assertj-core:3.27.6'
|
||||
testImplementation 'org.mockito:mockito-core:5.20.0'
|
||||
|
||||
androidTestImplementation 'androidx.test.ext:junit:1.3.0'
|
||||
androidTestImplementation 'androidx.test:runner:1.7.0'
|
||||
androidTestImplementation 'org.assertj:assertj-core:3.27.6'
|
||||
androidTestImplementation composeBom
|
||||
androidTestImplementation 'androidx.compose.ui:ui-test-junit4'
|
||||
}
|
||||
@@ -58,3 +58,9 @@
|
||||
# --- squash warnings for optional dependencies referenced only from XML ---
|
||||
|
||||
-dontwarn org.bouncycastle.jsse.util.**
|
||||
|
||||
# --- Room databases are instantiated reflectively (WorkManager's WorkDatabase_Impl) ---
|
||||
|
||||
-keep class * extends androidx.room.RoomDatabase {
|
||||
<init>();
|
||||
}
|
||||
+204
@@ -0,0 +1,204 @@
|
||||
/*
|
||||
* Copyright (C) 2026 SHX VPN
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or modify it
|
||||
* under the terms of the GNU General Public License as published by the
|
||||
* Free Software Foundation; either version 2 of the License, or (at your
|
||||
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful, but
|
||||
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
||||
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
|
||||
* for more details.
|
||||
*/
|
||||
|
||||
package org.strongswan.android.logic
|
||||
|
||||
import android.os.Bundle
|
||||
import android.util.Base64
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import org.assertj.core.api.Assertions.assertThat
|
||||
import org.json.JSONObject
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.strongswan.android.data.ManagedVpnProfile
|
||||
import org.strongswan.android.data.VpnProfile
|
||||
import org.strongswan.android.data.VpnProfile.SelectedAppsHandling
|
||||
import org.strongswan.android.data.VpnProfileDataSource
|
||||
import org.strongswan.android.data.VpnType
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.security.KeyStore
|
||||
import java.util.UUID
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class VpnProfileExporterTest
|
||||
{
|
||||
private fun sampleProfile(): VpnProfile
|
||||
{
|
||||
val profile = VpnProfile()
|
||||
profile.setUUID(UUID.fromString("123e4567-e89b-12d3-a456-426614174000"))
|
||||
profile.setName("Shared Profile")
|
||||
profile.setVpnType(VpnType.IKEV2_EAP)
|
||||
profile.setGateway("vpn.example.com")
|
||||
profile.setPort(4500)
|
||||
profile.setRemoteId(" CN=vpn.example.com")
|
||||
profile.setLocalId("alice@example.com")
|
||||
profile.setUsername("alice")
|
||||
profile.setPassword("secret")
|
||||
profile.setIkeProposal("aes256-sha2_384-prfsha384-modp2048")
|
||||
profile.setEspProposal("aes256gcm16-prfsha384-modp2048")
|
||||
profile.setDnsServers("10.0.0.1 10.0.0.2")
|
||||
profile.setMTU(1400)
|
||||
profile.setNATKeepAlive(20)
|
||||
profile.setIncludedSubnets("10.1.0.0/16")
|
||||
profile.setExcludedSubnets("10.1.2.0/24")
|
||||
profile.setSplitTunneling(VpnProfile.SPLIT_TUNNELING_BLOCK_IPV4)
|
||||
profile.setProxyHost("proxy.example.com")
|
||||
profile.setProxyPort(8080)
|
||||
profile.setProxyExclusions("example.org example.net")
|
||||
profile.setSelectedApps("com.example.app1 com.example.app2")
|
||||
profile.setSelectedAppsHandling(SelectedAppsHandling.SELECTED_APPS_ONLY)
|
||||
profile.setFlags(VpnProfile.FLAGS_SUPPRESS_CERT_REQS or VpnProfile.FLAGS_DISABLE_CRL or
|
||||
VpnProfile.FLAGS_STRICT_REVOCATION or VpnProfile.FLAGS_IPv6_TRANSPORT)
|
||||
return profile
|
||||
}
|
||||
|
||||
@Test
|
||||
fun exportRoundTripMatchesImporterFormat()
|
||||
{
|
||||
val json = JSONObject(VpnProfileExporter.serializeProfile(sampleProfile(), true).toString())
|
||||
|
||||
assertThat(json.getString("uuid")).isEqualTo("123e4567-e89b-12d3-a456-426614174000")
|
||||
assertThat(json.getString("name")).isEqualTo("Shared Profile")
|
||||
assertThat(json.getString("type")).isEqualTo("ikev2-eap")
|
||||
|
||||
val remote = json.getJSONObject("remote")
|
||||
assertThat(remote.getString("addr")).isEqualTo("vpn.example.com")
|
||||
assertThat(remote.getInt("port")).isEqualTo(4500)
|
||||
assertThat(remote.getString("id")).isEqualTo(" CN=vpn.example.com")
|
||||
assertThat(remote.getBoolean("certreq")).isFalse()
|
||||
assertThat(remote.getJSONObject("revocation").getBoolean("crl")).isFalse()
|
||||
assertThat(remote.getJSONObject("revocation").getBoolean("strict")).isTrue()
|
||||
|
||||
val local = json.getJSONObject("local")
|
||||
assertThat(local.getString("id")).isEqualTo("alice@example.com")
|
||||
assertThat(local.getString("eap_id")).isEqualTo("alice")
|
||||
assertThat(local.getString("shared_secret")).isEqualTo("secret")
|
||||
|
||||
assertThat(json.getString("ike-proposal")).isEqualTo("aes256-sha2_384-prfsha384-modp2048")
|
||||
assertThat(json.getString("esp-proposal")).isEqualTo("aes256gcm16-prfsha384-modp2048")
|
||||
assertThat(json.getJSONArray("dns-servers").length()).isEqualTo(2)
|
||||
assertThat(json.getInt("mtu")).isEqualTo(1400)
|
||||
assertThat(json.getInt("nat-keepalive")).isEqualTo(20)
|
||||
assertThat(json.getBoolean("ipv6-transport")).isTrue()
|
||||
|
||||
val proxy = json.getJSONObject("proxy")
|
||||
assertThat(proxy.getString("host")).isEqualTo("proxy.example.com")
|
||||
assertThat(proxy.getInt("port")).isEqualTo(8080)
|
||||
assertThat(proxy.getJSONArray("exclusions").length()).isEqualTo(2)
|
||||
|
||||
val split = json.getJSONObject("split-tunneling")
|
||||
assertThat(split.getJSONArray("subnets").getString(0)).isEqualTo("10.1.0.0/16")
|
||||
assertThat(split.getJSONArray("excluded").getString(0)).isEqualTo("10.1.2.0/24")
|
||||
assertThat(split.getBoolean("block-ipv4")).isTrue()
|
||||
assertThat(split.has("block-ipv6")).isFalse()
|
||||
|
||||
assertThat(json.getJSONArray("apps").length()).isEqualTo(2)
|
||||
assertThat(json.has("excluded-apps")).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun passwordOmittedWhenNotIncluded()
|
||||
{
|
||||
val json = JSONObject(VpnProfileExporter.serializeProfile(sampleProfile(), false).toString())
|
||||
|
||||
assertThat(json.getJSONObject("local").has("shared_secret")).isFalse()
|
||||
assertThat(json.getJSONObject("local").getString("eap_id")).isEqualTo("alice")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun defaultsAreOmitted()
|
||||
{
|
||||
val profile = VpnProfile()
|
||||
profile.setName("Minimal")
|
||||
profile.setVpnType(VpnType.IKEV2_EAP)
|
||||
profile.setGateway("vpn.example.com")
|
||||
|
||||
val json = JSONObject(VpnProfileExporter.serializeProfile(profile, false).toString())
|
||||
|
||||
assertThat(json.has("local")).isFalse()
|
||||
assertThat(json.has("ike-proposal")).isFalse()
|
||||
assertThat(json.has("dns-servers")).isFalse()
|
||||
assertThat(json.has("mtu")).isFalse()
|
||||
assertThat(json.has("ipv6-transport")).isFalse()
|
||||
assertThat(json.getJSONObject("remote").has("certreq")).isFalse()
|
||||
assertThat(json.getJSONObject("remote").has("revocation")).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun managedUserCertificateIsRepackagedWithoutPassword()
|
||||
{
|
||||
val local = Bundle()
|
||||
local.putString(VpnProfileDataSource.KEY_USER_CERTIFICATE, TEST_P12)
|
||||
local.putString(VpnProfileDataSource.KEY_USER_CERTIFICATE_PASSWORD, "testpassword")
|
||||
val bundle = Bundle()
|
||||
bundle.putString(VpnProfileDataSource.KEY_NAME, "Managed")
|
||||
bundle.putString(VpnProfileDataSource.KEY_VPN_TYPE, "ikev2-cert")
|
||||
bundle.putBundle("local", local)
|
||||
val profile = ManagedVpnProfile(bundle, UUID.randomUUID())
|
||||
|
||||
val exported = VpnProfileExporter.userCertificateData(profile)
|
||||
assertThat(exported).isNotNull()
|
||||
|
||||
/* the recipient must be able to open the container without knowing a password */
|
||||
val keyStore = KeyStore.getInstance("PKCS12")
|
||||
keyStore.load(ByteArrayInputStream(Base64.decode(exported, Base64.DEFAULT)), charArrayOf())
|
||||
val alias = keyStore.aliases().nextElement()
|
||||
assertThat(keyStore.getKey(alias, charArrayOf())).isNotNull()
|
||||
assertThat(keyStore.getCertificateChain(alias)).isNotEmpty()
|
||||
val cert = keyStore.getCertificate(alias) as java.security.cert.X509Certificate
|
||||
assertThat(cert.getSubjectX500Principal().name).contains("Test User")
|
||||
}
|
||||
|
||||
companion object
|
||||
{
|
||||
/** PKCS#12 container with a private key for CN=Test User, password: testpassword */
|
||||
private val TEST_P12 = """ MIIKOAIBAzCCCe4GCSqGSIb3DQEHAaCCCd8EggnbMIIJ1zCCBCoGCSqGSIb3DQEHBqCCBBswggQXAgEAMIIEEAYJKoZIhvcN
|
||||
AQcBMF8GCSqGSIb3DQEFDTBSMDEGCSqGSIb3DQEFDDAkBBDba1sYbH9bqoGmf+8dPAqZAgIIADAMBggqhkiG9w0CCQUAMB0G
|
||||
CWCGSAFlAwQBKgQQ6xNPeUT6JH4tibm14NRyZ4CCA6AyC5vk/wmALhgKx3M2jRlOdSXaGp6iLaIt1a6mDAlpULXik9oxmWmh
|
||||
4m5Q5UTq9nfJBTJkmSv0MKhMOpP8WKijEH92UWqrI+P6VRpDDMM110tySeE3Qv4al/5+RJSVQFyJktSK+2bA4aFlMI+WxZBQ
|
||||
Ki0l+mPMSEhDkUTVez/UBcx8rYPCFq9Di42VT2DbVfebcvmAfWJwjlIWMixGiWrIpS7U/m5v0RSUQyDSNYnp1b49eDePGLUF
|
||||
90yEeOxiVjPEaRRPzeaEbBy569ig2BltZDBbCR0Ju+FUSWsyDD7OV/k9ZcmkRT4e2Hp7r9De2UiYS3sD+dGVPTTVVBEMewiv
|
||||
ujsccRUx83PRlbhQFMQh6WscsBzhPb6K5qgeYQ0xUZLHMfM6657xws2UyhPFnrGfeokZg3r3SihB5XueZV95fVFtDHC0D8gD
|
||||
tbdnc404XNx+xhB7uf33UZ5KUde7TC068VSXovUuxzgGFa/ZmbICtpumsr+na/+/I09zLttjnZXnr2islywjCwB9pbuzTG2y
|
||||
N5ZhgsELiNph5C/8sjIxqLNQkzvD1M9K9u5YiaxaUU/ZHxIs9F6QJYa+JqGiafuBcUZuc9kg7oNm94vD69i6Jhg10LKc7My5
|
||||
B5lU9SPJ/kFjKrmk4dhDYCiTRCI/zOuanmPERdFlLR+SMJe/ACRUFj+O0WT4Sd5eE1byHxzu7Rxtuoc5GAPdQHPzKoDOj5Bc
|
||||
3h8yH5tTyZEUjy6Q2Svqrn/hxxduYx6zB0IATKiMXp2ADsuM1K35OWLK0WyyGC5C/ONcp84MM6vYMNecvcC8kPyuBsmegzbC
|
||||
HiScBvVlqsWZUzFIdQ+aIw1c2IY0LHuWksd7QYIJAWDLK3eMk3oNeFO/ao0JLE4N9gUQKLTyMa+sxytc0C9OfJb7PB/kssYW
|
||||
FC9KmmitkKU+JlM+i0uD9hXdHPThmyO8bPXT2EKM6zB0AWmcAPmPUMedzWREOmuldQNa1OQrIsZjyTOMDIy+l0UPQiFc6Q6e
|
||||
SdhHlXmzr/BSwC3u/oDfzZRye6crC9qFlLg4SLFSisxF6NC+YzlfVnpXEftc3lTAJpf2UQrgxFPaawgbGK0xGGuesBzmtLFs
|
||||
J57YD7h9vvqdD4VRCQrWn5Fy3/2rQVCHYZseLGyygWl0RR8C1TcWq/aWlycLIzNQmiGmYSrihA2u9v/2aVr1SyKCJicxBH3l
|
||||
Xfz4adrs6aeAGC0I6G73zJ+skxud1Y6AMIIFpQYJKoZIhvcNAQcBoIIFlgSCBZIwggWOMIIFigYLKoZIhvcNAQwKAQKgggU5
|
||||
MIIFNTBfBgkqhkiG9w0BBQ0wUjAxBgkqhkiG9w0BBQwwJAQQOc129Uu2Ph/kfFdWKDKL+gICCAAwDAYIKoZIhvcNAgkFADAd
|
||||
BglghkgBZQMEASoEEOpkCTykee2dSnZ0xkWndckEggTQD5YCqwB/3T86lOpmpb+gsKBMp6s48JkxiPhg4FO15aIHfqyg8Get
|
||||
KkxZCfeNzNszTbT5BApyRvbNedy9EPWluWvdnyYTDjKZvYDw5OvM21k1yX+pScXj58kBx2AMk3/XwFIeFOTx2WXt6mUy4GR7
|
||||
3pA+Zxe9pWO2SB9Zzn3VrCgj6B8o70fEHRJbt8kYR5uX7S5GVFhwgQbcFc1bmn1o/cDGqKS8U4D+LDR3++m4iQUHblz4r6Vm
|
||||
7hkknc2qIW116jtO3MYXQ9s+O8uiOlKbRnmQSuFjH4p0KzKl5i2uolj1rB5xHJwrJE4ajv6ubovEU/sUUjlnHScEF2S9qaJ3
|
||||
+sMlYgQMcvduaGDFx79leD0DdPxONZa8WmxnY7pwYdO7xFHloPfL/bSXnsSTX3cxHVyFj2r1/+MNhYM/BQG82/UfM5/s/1q+
|
||||
SmZHujO+vpDg1H9lKvIiFv992+oYGuiFpgjrTi3KGe2cMlY3r/LmFJnJkTjfTaTBRQ0UOOcC3+9/neN4dZ6Dhl51wM3ayRtS
|
||||
puhl5eNG3c/05VbjLaz98I6j8WlpEVHYxMyYXqeWchHPaGMlPeKWEZTZYqly1dodLthvZyCW6oItEFVSCJZtDl0bqYPm/CWv
|
||||
A2/1H8jwawzUN8KPiDIQScpK3qVR5c1pvol7uQ8P4yk6sIDqRoS27b2pS2vQwJNWIc93daCzk7MpJ/BJNTENhn2hxvlgksSZ
|
||||
RI/pWJVKe/vtPqDiW1Exe5N3lJ/Pk9fTQ3cCzVUP2S25DCNhdtHmlKGR37y+j/JeAAWXNs0GH0oq48FkWIEWj7gwynxsBlME
|
||||
xS3O2I1ZsDkzcaJh66tZ0Vyle87VpgZ6wA0NKqbwHdGQlAyVZ944O1jktQH9Do5qvfkkbcOM/yQE31fCTLIkT9hGXXVBG7Vy
|
||||
071cp5efGkU1bBfAHBmhNP7E3VDogHUbciwRSpw4O8GXtPtP3RMbEUpoqHaX4KRvyK0XKN6ZNKl8lVaDzU8g6TL313H1Z3yJ
|
||||
LPa0aedC2E4MJiQTVwbinSXhUdsmhVrFP0MfbtssdSLKkk0hNkzqsUExcaoxh51uR2pw5tb1w6x279C7dAV/+GibTeY2yngE
|
||||
+ikKRIa1YH3KvSZtFskLmXf2kphk1IRZHzQZauTgIwJnY7XFJWdus3HKdqr/XaqrchLfmMXYj/ZG7bS5ssoO7g9JPMD/5ElB
|
||||
Ur4Gz/1qE88NWBvqt6+guMAB5YvrsaOxxSLYZj6dyeO1bQfxMecuo3txI0lwJWpbkTHOu0rLOtde7OzgPo9qfRPur+rY5JqC
|
||||
tYOyCzfFFcwILWC92eiGO+N2XUe6k75qVSWpROSwkT/lZFmz6AmeBena8obtOnV6yTWapEPXkGMKP9Cm6rXEVgvZeUh886Bd
|
||||
5Am/mfAe0cT8hxoutrDXD649xRB4JNq1/jff8jik/kjgpEzk62rz6ix42JxB9gYljfdtekvi5/JfF7ODvij4xO6OzycrH0I/
|
||||
742vk4swnVwKRQfhCQuaklNrTmpqWsefprLy54EArTjSNEHspZJD/BGNJe8q+ntECKMBqPzTgptuxb9JSWsTnJzYFP6kxYLf
|
||||
NxQpWPVQz51SkmVzpjRgp/RE3FLmBtponAST69xIQuXz+xinc1asWYgxPjAXBgkqhkiG9w0BCRQxCh4IAHQAZQBzAHQwIwYJ
|
||||
KoZIhvcNAQkVMRYEFNSUf6nnO3vpRNk70pJS3R58gg9NMEEwMTANBglghkgBZQMEAgEFAAQgL2BlHv8N4AAEaEOTRpzFamcY
|
||||
l/5PNjlWjlgV252DMCoECGgcyjRqux6dAgIIAA=="""
|
||||
}
|
||||
}
|
||||
+87
@@ -0,0 +1,87 @@
|
||||
/*
|
||||
* Copyright (C) 2026 SHX VPN
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or modify it
|
||||
* under the terms of the GNU General Public License as published by the
|
||||
* Free Software Foundation; either version 2 of the License, or (at your
|
||||
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful, but
|
||||
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
||||
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
|
||||
* for more details.
|
||||
*/
|
||||
|
||||
package org.strongswan.android.ui.compose
|
||||
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.junit4.createComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.strongswan.android.R
|
||||
import org.strongswan.android.ui.compose.theme.ShxTheme
|
||||
|
||||
class ConnectivityCheckScreenTest
|
||||
{
|
||||
@get:Rule
|
||||
val composeRule = createComposeRule()
|
||||
|
||||
private fun string(id: Int): String =
|
||||
androidx.test.platform.app.InstrumentationRegistry.getInstrumentation().targetContext.getString(id)
|
||||
|
||||
private fun setContent()
|
||||
{
|
||||
composeRule.setContent {
|
||||
ShxTheme {
|
||||
ConnectivityCheckScreen()
|
||||
}
|
||||
}
|
||||
composeRule.waitForIdle()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun showsSectionHeaders()
|
||||
{
|
||||
setContent()
|
||||
composeRule.onNodeWithText(
|
||||
string(R.string.health_check_targets_title)
|
||||
).assertIsDisplayed()
|
||||
composeRule.onNodeWithText(
|
||||
string(R.string.health_check_server_down_title)
|
||||
).assertIsDisplayed()
|
||||
composeRule.onNodeWithText(
|
||||
string(R.string.health_check_tunnel_dead_title)
|
||||
).assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun showsMasterToggle()
|
||||
{
|
||||
setContent()
|
||||
composeRule.onNodeWithText(
|
||||
string(R.string.health_check_title)
|
||||
).assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun showsAggressiveModeToggle()
|
||||
{
|
||||
setContent()
|
||||
composeRule.onNodeWithText(
|
||||
string(R.string.health_check_aggressive_title)
|
||||
).assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun showsTargetToggles()
|
||||
{
|
||||
setContent()
|
||||
composeRule.onNodeWithText(
|
||||
string(R.string.probe_target_google)
|
||||
).assertIsDisplayed()
|
||||
composeRule.onNodeWithText(
|
||||
string(R.string.probe_target_cloudflare)
|
||||
).assertIsDisplayed()
|
||||
}
|
||||
}
|
||||
+91
@@ -0,0 +1,91 @@
|
||||
/*
|
||||
* Copyright (C) 2026 SHX VPN
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or modify it
|
||||
* under the terms of the GNU General Public License as published by the
|
||||
* Free Software Foundation; either version 2 of the License, or (at your
|
||||
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful, but
|
||||
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
||||
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
|
||||
* for more details.
|
||||
*/
|
||||
|
||||
package org.strongswan.android.ui.compose
|
||||
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.junit4.createComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.strongswan.android.R
|
||||
import org.strongswan.android.logic.routing.TemplateMatch
|
||||
import org.strongswan.android.ui.compose.routing.AppReason
|
||||
import org.strongswan.android.ui.compose.routing.AppsFilter
|
||||
import org.strongswan.android.ui.compose.routing.DirectAppRow
|
||||
import org.strongswan.android.ui.compose.routing.DirectAppsContent
|
||||
import org.strongswan.android.ui.compose.routing.DirectAppsGroup
|
||||
import org.strongswan.android.ui.compose.routing.DirectAppsUiState
|
||||
import org.strongswan.android.ui.compose.routing.QuickChipState
|
||||
import org.strongswan.android.ui.compose.theme.ShxTheme
|
||||
|
||||
/** Smoke test of the stateless "Apps direct" screen: filter chips, quick-add chips and an app row. */
|
||||
class DirectAppsScreenTest
|
||||
{
|
||||
@get:Rule
|
||||
val composeRule = createComposeRule()
|
||||
|
||||
private fun string(id: Int, vararg args: Any): String =
|
||||
InstrumentationRegistry.getInstrumentation().targetContext.getString(id, *args)
|
||||
|
||||
private val state = DirectAppsUiState(
|
||||
loading = false,
|
||||
groups = listOf(
|
||||
DirectAppsGroup(
|
||||
category = "banks",
|
||||
rows = listOf(
|
||||
DirectAppRow("com.example.bank", "Example Bank", true, AppReason.Template(TemplateMatch(category = "banks"))),
|
||||
),
|
||||
),
|
||||
DirectAppsGroup(
|
||||
category = null,
|
||||
rows = listOf(DirectAppRow("com.example.chat", "Example Chat", false, AppReason.Tunnel)),
|
||||
),
|
||||
),
|
||||
directCount = 1,
|
||||
tunnelCount = 1,
|
||||
filter = AppsFilter.ALL,
|
||||
quickChips = listOf(QuickChipState("banks", true), QuickChipState("shop", false)),
|
||||
)
|
||||
|
||||
@Test
|
||||
fun showsFiltersQuickChipsAndRows()
|
||||
{
|
||||
var toggled: Pair<String, Boolean>? = null
|
||||
composeRule.setContent {
|
||||
ShxTheme {
|
||||
DirectAppsContent(
|
||||
state = state,
|
||||
onBack = {},
|
||||
onQuery = {},
|
||||
onFilter = {},
|
||||
onShowSystem = {},
|
||||
onToggleApp = { pkg, direct -> toggled = pkg to direct },
|
||||
onToggleCategory = { _, _ -> },
|
||||
onAllRf = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
composeRule.waitForIdle()
|
||||
composeRule.onNodeWithText(string(R.string.apps_filter_direct, 1)).assertIsDisplayed()
|
||||
composeRule.onNodeWithText(string(R.string.apps_filter_vpn, 1)).assertIsDisplayed()
|
||||
composeRule.onNodeWithText(string(R.string.routing_apps_add_all_rf)).assertIsDisplayed()
|
||||
composeRule.onNodeWithText("Example Bank").assertIsDisplayed()
|
||||
composeRule.onNodeWithText("Example Chat").performClick()
|
||||
assertEquals("com.example.chat" to true, toggled)
|
||||
}
|
||||
}
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
/*
|
||||
* Copyright (C) 2026 SHX VPN
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or modify it
|
||||
* under the terms of the GNU General Public License as published by the
|
||||
* Free Software Foundation; either version 2 of the License, or (at your
|
||||
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful, but
|
||||
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
||||
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
|
||||
* for more details.
|
||||
*/
|
||||
|
||||
package org.strongswan.android.ui.compose
|
||||
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.junit4.createComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.strongswan.android.R
|
||||
import org.strongswan.android.ui.compose.theme.ShxTheme
|
||||
|
||||
/** Smoke tests for the redesigned Profiles, Routing and Settings screens. */
|
||||
class ScreensSmokeTest
|
||||
{
|
||||
@get:Rule
|
||||
val composeRule = createComposeRule()
|
||||
|
||||
private fun string(id: Int): String =
|
||||
InstrumentationRegistry.getInstrumentation().targetContext.getString(id)
|
||||
|
||||
@Test
|
||||
fun profilesShowsEmptyState()
|
||||
{
|
||||
composeRule.setContent {
|
||||
ShxTheme {
|
||||
ProfilesScreen(
|
||||
state = HomeUiState(),
|
||||
onConnect = {},
|
||||
onDisconnect = {},
|
||||
onEdit = {},
|
||||
onDelete = {},
|
||||
onRestore = {},
|
||||
onShare = {},
|
||||
onAdd = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
composeRule.onNodeWithText(string(R.string.no_profiles)).assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun routingShowsHeroAndAdvancedBlock()
|
||||
{
|
||||
composeRule.setContent {
|
||||
ShxTheme {
|
||||
RoutingScreen()
|
||||
}
|
||||
}
|
||||
composeRule.onNodeWithText(string(R.string.routing_title)).assertIsDisplayed()
|
||||
/* advanced block is collapsed until tapped */
|
||||
composeRule.onNodeWithText(string(R.string.routing_advanced).uppercase()).assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun settingsOpensSecurityPageAndGoesBack()
|
||||
{
|
||||
composeRule.setContent {
|
||||
ShxTheme {
|
||||
SettingsScreen(profiles = emptyList())
|
||||
}
|
||||
}
|
||||
composeRule.onNodeWithText(string(R.string.settings_section_security)).performClick()
|
||||
composeRule.waitForIdle()
|
||||
composeRule.onNodeWithText(string(R.string.safe_ipv6_title)).assertIsDisplayed()
|
||||
}
|
||||
}
|
||||
@@ -30,7 +30,21 @@
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
|
||||
<!-- «Recent apps»: per-app traffic totals from the system network statistics;
|
||||
granted by the user in Settings > Usage access, never requested silently -->
|
||||
<uses-permission android:name="android.permission.PACKAGE_USAGE_STATS"
|
||||
tools:ignore="ProtectedPermissions" />
|
||||
<uses-permission android:name="android.permission.POST_PROMOTED_NOTIFICATIONS"/>
|
||||
<!-- required to install downloaded updates of the app itself -->
|
||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||
<!-- lets the app install its own updates in the background without the
|
||||
system confirmation dialog (Android 12+, same package/signature only) -->
|
||||
<uses-permission android:name="android.permission.UPDATE_PACKAGES_WITHOUT_USER_ACTION" />
|
||||
<!-- required to claim update ownership (setRequestUpdateOwnership) so our
|
||||
own updates keep being applied silently on Android 14+ -->
|
||||
<uses-permission android:name="android.permission.ENFORCE_UPDATE_OWNERSHIP" />
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"
|
||||
android:maxSdkVersion="32" />
|
||||
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
|
||||
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
|
||||
<!-- Required to reliably schedule NAT keepalives/rekeyings via the AlarmManager.
|
||||
@@ -53,6 +67,7 @@
|
||||
android:allowBackup="false" >
|
||||
<activity
|
||||
android:name=".ui.MainActivity"
|
||||
android:theme="@style/ShxSplashTheme"
|
||||
android:label="@string/main_activity_name"
|
||||
android:launchMode="singleTop"
|
||||
android:exported="true">
|
||||
@@ -64,6 +79,18 @@
|
||||
<action android:name="android.service.quicksettings.action.QS_TILE_PREFERENCES" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<activity
|
||||
android:name=".ui.ScenariosActivity"
|
||||
android:exported="false" />
|
||||
<activity
|
||||
android:name=".ui.ServerActivity"
|
||||
android:exported="false" />
|
||||
<activity
|
||||
android:name=".ui.StatsActivity"
|
||||
android:exported="false" />
|
||||
<activity
|
||||
android:name=".ui.BatteryActivity"
|
||||
android:exported="false" />
|
||||
<activity
|
||||
android:name=".ui.VpnProfileControlActivity"
|
||||
android:theme="@style/TransparentActivity"
|
||||
@@ -79,6 +106,17 @@
|
||||
<action android:name="one.shx.strongswan.ext.action.DISCONNECT" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="one.shx.strongswan.ext.action.TOGGLE_VPN" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
</intent-filter>
|
||||
<!-- deep links for automation tools: shxvpn://connect[?profile=<uuid>], shxvpn://disconnect, shxvpn://toggle -->
|
||||
<intent-filter tools:ignore="AppLinkUrlError">
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="shxvpn" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<activity
|
||||
android:name=".ui.VpnProfileDetailActivity" >
|
||||
@@ -87,10 +125,6 @@
|
||||
android:name=".ui.TrustedCertificatesActivity"
|
||||
android:label="@string/trusted_certs_title" >
|
||||
</activity>
|
||||
<activity
|
||||
android:name=".ui.SelectedApplicationsActivity"
|
||||
android:label="@string/profile_select_apps" >
|
||||
</activity>
|
||||
<activity
|
||||
android:name=".ui.LogActivity"
|
||||
android:label="@string/log_title" >
|
||||
@@ -100,13 +134,28 @@
|
||||
android:label="@string/pref_title">
|
||||
</activity>
|
||||
<activity
|
||||
android:name=".ui.TrustedNetworksActivity"
|
||||
android:label="@string/auto_connect_title">
|
||||
android:name=".ui.ConnectivityCheckActivity"
|
||||
android:label="@string/health_check_title">
|
||||
</activity>
|
||||
<activity
|
||||
android:name=".ui.RouteCheckActivity"
|
||||
android:label="@string/route_check_title">
|
||||
</activity>
|
||||
<activity
|
||||
android:name=".ui.InboxActivity"
|
||||
android:label="@string/events_title">
|
||||
</activity>
|
||||
<activity
|
||||
android:name=".ui.DiagnosticsActivity"
|
||||
android:label="@string/diag_title">
|
||||
</activity>
|
||||
<activity
|
||||
android:name=".ui.OnboardingActivity"
|
||||
android:label="@string/app_name">
|
||||
</activity>
|
||||
<activity
|
||||
android:name=".ui.RemediationInstructionsActivity"
|
||||
android:label="@string/remediation_instructions_title"
|
||||
android:theme="@style/ApplicationTheme.ActionBar" >
|
||||
android:label="@string/remediation_instructions_title" >
|
||||
</activity>
|
||||
<activity
|
||||
android:name=".ui.VpnProfileSelectActivity"
|
||||
@@ -121,7 +170,7 @@
|
||||
<activity
|
||||
android:name=".ui.VpnProfileImportActivity"
|
||||
android:label="@string/profile_import"
|
||||
android:theme="@style/ApplicationTheme.ActionBar"
|
||||
android:theme="@style/TransparentActivity"
|
||||
android:taskAffinity=""
|
||||
android:excludeFromRecents="true"
|
||||
android:exported="true">
|
||||
@@ -215,8 +264,26 @@
|
||||
<intent-filter>
|
||||
<action android:name="android.service.quicksettings.action.QS_TILE" />
|
||||
</intent-filter>
|
||||
<meta-data
|
||||
android:name="android.service.quicksettings.TILE_CATEGORY"
|
||||
android:value="android.service.quicksettings.CATEGORY_CONNECTIVITY" />
|
||||
<meta-data
|
||||
android:name="android.service.quicksettings.TOGGLEABLE_TILE"
|
||||
android:value="true" />
|
||||
</service>
|
||||
|
||||
<receiver
|
||||
android:name=".ui.widget.ShxWidgetReceiver"
|
||||
android:label="@string/widget_label"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
|
||||
</intent-filter>
|
||||
<meta-data
|
||||
android:name="android.appwidget.provider"
|
||||
android:resource="@xml/shx_widget_info" />
|
||||
</receiver>
|
||||
|
||||
<provider
|
||||
android:name=".data.LogContentProvider"
|
||||
android:authorities="one.shx.strongswan.ext.content.log"
|
||||
@@ -225,6 +292,53 @@
|
||||
not work (probably too many indirections with ACTION_SEND) so we secure
|
||||
this provider with a custom ticketing system -->
|
||||
</provider>
|
||||
<provider
|
||||
android:name=".data.ProfileExportProvider"
|
||||
android:authorities="one.shx.strongswan.ext.content.export"
|
||||
android:exported="true" >
|
||||
<!-- same ticketing approach as above for shared VPN profile exports -->
|
||||
</provider>
|
||||
<!-- serves downloaded updates to the system package installer -->
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.fileprovider"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true">
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/file_paths" />
|
||||
</provider>
|
||||
<!-- receives the result of the self-update installation -->
|
||||
<receiver
|
||||
android:name=".logic.updates.UpdateInstallStatusReceiver"
|
||||
android:exported="false" />
|
||||
<!-- restores an expected VPN connection after the hosting process died;
|
||||
only uses a non-wakeup alarm so it never wakes the device -->
|
||||
<receiver
|
||||
android:name=".logic.WatchdogReceiver"
|
||||
android:exported="false" />
|
||||
<!-- «Yes» / «No» of the suggestion notifications -->
|
||||
<receiver
|
||||
android:name=".logic.events.EventAnswerReceiver"
|
||||
android:exported="false" />
|
||||
<!-- edges of the time windows of the user scenarios -->
|
||||
<receiver
|
||||
android:name=".logic.scenarios.ScenarioReceiver"
|
||||
android:exported="false" />
|
||||
<!-- ends a "pause VPN for N minutes" and reconnects the paused profile -->
|
||||
<receiver
|
||||
android:name=".logic.PauseReceiver"
|
||||
android:exported="false" />
|
||||
<!-- runs after the app has updated itself (the system kills the
|
||||
process during the install): cleans up and lets auto-connect
|
||||
restore the VPN -->
|
||||
<receiver
|
||||
android:name=".logic.updates.UpdateReplacedReceiver"
|
||||
android:exported="false">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
+190
@@ -0,0 +1,190 @@
|
||||
/*
|
||||
* Copyright (C) 2012 Tobias Brunner
|
||||
*
|
||||
* Copyright (C) secunet Security Networks AG
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or modify it
|
||||
* under the terms of the GNU General Public License as published by the
|
||||
* Free Software Foundation; either version 2 of the License, or (at your
|
||||
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful, but
|
||||
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
||||
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
|
||||
* for more details.
|
||||
*/
|
||||
|
||||
package org.strongswan.android.data;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileNotFoundException;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.security.SecureRandom;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
import org.strongswan.android.logic.CharonVpnService;
|
||||
|
||||
import android.content.ContentProvider;
|
||||
import android.content.ContentValues;
|
||||
import android.database.Cursor;
|
||||
import android.database.MatrixCursor;
|
||||
import android.net.Uri;
|
||||
import android.os.ParcelFileDescriptor;
|
||||
import android.os.SystemClock;
|
||||
import android.provider.OpenableColumns;
|
||||
|
||||
public class LogContentProvider extends ContentProvider
|
||||
{
|
||||
private static final String AUTHORITY = "one.shx.strongswan.ext.content.log";
|
||||
/* an Uri is valid for 30 minutes */
|
||||
private static final long URI_VALIDITY = 30 * 60 * 1000;
|
||||
private static ConcurrentHashMap<Uri, Long> mUris = new ConcurrentHashMap<Uri, Long>();
|
||||
/* file served by each Uri (defaults to the charon log for legacy Uris) */
|
||||
private static ConcurrentHashMap<Uri, File> mFiles = new ConcurrentHashMap<Uri, File>();
|
||||
private File mLogFile;
|
||||
|
||||
public LogContentProvider()
|
||||
{
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onCreate()
|
||||
{
|
||||
mLogFile = new File(getContext().getFilesDir(), CharonVpnService.LOG_FILE);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* The log file can only be accessed by Uris created with this method
|
||||
* @return null if failed to create the Uri
|
||||
*/
|
||||
public static Uri createContentUri()
|
||||
{
|
||||
return createContentUri(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* The log file can only be accessed by Uris created with this method
|
||||
* @param file file to serve, the charon log if null
|
||||
* @return null if failed to create the Uri
|
||||
*/
|
||||
public static Uri createContentUri(File file)
|
||||
{
|
||||
SecureRandom random;
|
||||
try
|
||||
{
|
||||
random = SecureRandom.getInstance("SHA1PRNG");
|
||||
}
|
||||
catch (NoSuchAlgorithmException e)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
Uri uri = Uri.parse("content://" + AUTHORITY + "/" + random.nextLong());
|
||||
mUris.put(uri, SystemClock.uptimeMillis());
|
||||
if (file != null)
|
||||
{ /* ConcurrentHashMap does not accept null values; fileFor() falls
|
||||
* back to the charon log for legacy Uris */
|
||||
mFiles.put(uri, file);
|
||||
}
|
||||
return uri;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getType(Uri uri)
|
||||
{
|
||||
/* MIME type for our log file */
|
||||
return "text/plain";
|
||||
}
|
||||
|
||||
@Override
|
||||
public Cursor query(Uri uri, String[] projection, String selection,
|
||||
String[] selectionArgs, String sortOrder)
|
||||
{
|
||||
/* this is called by apps to find out the name and size of the file.
|
||||
* since we only provide a single file per Uri this is simple to implement */
|
||||
if (projection == null)
|
||||
{
|
||||
projection = new String[]{ OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE };
|
||||
}
|
||||
File file = fileFor(uri);
|
||||
if (file == null)
|
||||
{ /* don't check the validity as this information is not really private */
|
||||
return null;
|
||||
}
|
||||
List<String> cols = new ArrayList<>();
|
||||
List<Object> vals = new ArrayList<>();
|
||||
for (String col : projection)
|
||||
{
|
||||
if (OpenableColumns.DISPLAY_NAME.equals(col))
|
||||
{
|
||||
cols.add(OpenableColumns.DISPLAY_NAME);
|
||||
vals.add(file.getName());
|
||||
}
|
||||
else if (OpenableColumns.SIZE.equals(col))
|
||||
{
|
||||
cols.add(OpenableColumns.SIZE);
|
||||
vals.add(file.length());
|
||||
}
|
||||
}
|
||||
|
||||
MatrixCursor cursor = new MatrixCursor(cols.toArray(new String[0]), 1);
|
||||
cursor.addRow(vals.toArray());
|
||||
return cursor;
|
||||
}
|
||||
|
||||
@Override
|
||||
public ParcelFileDescriptor openFile(Uri uri, String mode) throws FileNotFoundException
|
||||
{
|
||||
File file = fileFor(uri);
|
||||
if (file != null)
|
||||
{
|
||||
return ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_CREATE | ParcelFileDescriptor.MODE_READ_ONLY);
|
||||
}
|
||||
return super.openFile(uri, mode);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the file served by the given Uri, or null if the Uri is invalid or expired.
|
||||
*/
|
||||
private File fileFor(Uri uri)
|
||||
{
|
||||
Long timestamp = mUris.get(uri);
|
||||
if (timestamp == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
long elapsed = SystemClock.uptimeMillis() - timestamp;
|
||||
if (elapsed > 0 && elapsed < URI_VALIDITY)
|
||||
{ /* we fail if clock wrapped, should happen rarely though */
|
||||
File file = mFiles.get(uri);
|
||||
return file != null ? file : mLogFile;
|
||||
}
|
||||
mUris.remove(uri);
|
||||
mFiles.remove(uri);
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Uri insert(Uri uri, ContentValues values)
|
||||
{
|
||||
/* not supported */
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int delete(Uri uri, String selection, String[] selectionArgs)
|
||||
{
|
||||
/* not supported */
|
||||
return 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int update(Uri uri, ContentValues values, String selection,
|
||||
String[] selectionArgs)
|
||||
{
|
||||
/* not supported */
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -47,7 +47,7 @@ public class ManagedVpnProfile extends VpnProfile
|
||||
private ManagedTrustedCertificate trustedCertificate;
|
||||
private ManagedUserCertificate userCertificate;
|
||||
|
||||
ManagedVpnProfile(final Bundle bundle, final UUID uuid)
|
||||
public ManagedVpnProfile(final Bundle bundle, final UUID uuid)
|
||||
{
|
||||
int flags = 0;
|
||||
int splitFlags = 0;
|
||||
|
||||
+194
@@ -0,0 +1,194 @@
|
||||
/*
|
||||
* Copyright (C) 2026 SHX VPN
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or modify it
|
||||
* under the terms of the GNU General Public License as published by the
|
||||
* Free Software Foundation; either version 2 of the License, or (at your
|
||||
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful, but
|
||||
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
||||
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
|
||||
* for more details.
|
||||
*/
|
||||
|
||||
package org.strongswan.android.data;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileNotFoundException;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.security.SecureRandom;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
import android.content.ContentProvider;
|
||||
import android.content.ContentValues;
|
||||
import android.content.Context;
|
||||
import android.database.Cursor;
|
||||
import android.database.MatrixCursor;
|
||||
import android.net.Uri;
|
||||
import android.os.ParcelFileDescriptor;
|
||||
import android.os.SystemClock;
|
||||
import android.provider.OpenableColumns;
|
||||
|
||||
/**
|
||||
* Serves exported VPN profile files to other apps. Instead of grantUriPermissions
|
||||
* (which does not work reliably with ACTION_SEND) this provider is exported but only
|
||||
* serves files through randomly generated ticket Uris created with createContentUri().
|
||||
*/
|
||||
public class ProfileExportProvider extends ContentProvider
|
||||
{
|
||||
public static final String MIME_TYPE = "application/vnd.strongswan.profile";
|
||||
|
||||
private static final String AUTHORITY = "one.shx.strongswan.ext.content.export";
|
||||
/* an Uri is valid for 30 minutes */
|
||||
private static final long URI_VALIDITY = 30 * 60 * 1000;
|
||||
|
||||
private static class Ticket
|
||||
{
|
||||
final File mFile;
|
||||
final String mDisplayName;
|
||||
final long mCreatedAt;
|
||||
|
||||
Ticket(File file, String displayName, long createdAt)
|
||||
{
|
||||
mFile = file;
|
||||
mDisplayName = displayName;
|
||||
mCreatedAt = createdAt;
|
||||
}
|
||||
}
|
||||
|
||||
private static final ConcurrentHashMap<Uri, Ticket> mTickets = new ConcurrentHashMap<Uri, Ticket>();
|
||||
|
||||
public ProfileExportProvider()
|
||||
{
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onCreate()
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* The given file can only be accessed by Uris created with this method
|
||||
* @return null if failed to create the Uri
|
||||
*/
|
||||
public static Uri createContentUri(Context context, File file, String displayName)
|
||||
{
|
||||
SecureRandom random;
|
||||
try
|
||||
{
|
||||
random = SecureRandom.getInstance("SHA1PRNG");
|
||||
}
|
||||
catch (NoSuchAlgorithmException e)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
Uri uri = Uri.parse("content://" + AUTHORITY + "/" + random.nextLong());
|
||||
Ticket ticket = new Ticket(exportFile(context, file.getName()), displayName, SystemClock.uptimeMillis());
|
||||
mTickets.put(uri, ticket);
|
||||
return uri;
|
||||
}
|
||||
|
||||
/**
|
||||
* Directory exported profile files are stored in (also used by the exporter).
|
||||
*/
|
||||
public static File exportDirectory(Context context)
|
||||
{
|
||||
File dir = new File(context.getCacheDir(), "export");
|
||||
if (!dir.exists())
|
||||
{
|
||||
dir.mkdirs();
|
||||
}
|
||||
return dir;
|
||||
}
|
||||
|
||||
/**
|
||||
* The file the exported profile is stored in (resolves against the export directory).
|
||||
*/
|
||||
public static File exportFile(Context context, String fileName)
|
||||
{
|
||||
return new File(exportDirectory(context), fileName);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getType(Uri uri)
|
||||
{
|
||||
return MIME_TYPE;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Cursor query(Uri uri, String[] projection, String selection,
|
||||
String[] selectionArgs, String sortOrder)
|
||||
{
|
||||
/* this is called by apps to find out the name and size of the file */
|
||||
if (projection == null)
|
||||
{
|
||||
projection = new String[]{ OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE };
|
||||
}
|
||||
Ticket ticket = mTickets.get(uri);
|
||||
if (ticket == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
List<String> cols = new ArrayList<>();
|
||||
List<Object> vals = new ArrayList<>();
|
||||
for (String col : projection)
|
||||
{
|
||||
if (OpenableColumns.DISPLAY_NAME.equals(col))
|
||||
{
|
||||
cols.add(OpenableColumns.DISPLAY_NAME);
|
||||
vals.add(ticket.mDisplayName);
|
||||
}
|
||||
else if (OpenableColumns.SIZE.equals(col))
|
||||
{
|
||||
cols.add(OpenableColumns.SIZE);
|
||||
vals.add(ticket.mFile.length());
|
||||
}
|
||||
}
|
||||
|
||||
MatrixCursor cursor = new MatrixCursor(cols.toArray(new String[0]), 1);
|
||||
cursor.addRow(vals.toArray());
|
||||
return cursor;
|
||||
}
|
||||
|
||||
@Override
|
||||
public ParcelFileDescriptor openFile(Uri uri, String mode) throws FileNotFoundException
|
||||
{
|
||||
Ticket ticket = mTickets.get(uri);
|
||||
if (ticket != null)
|
||||
{
|
||||
long elapsed = SystemClock.uptimeMillis() - ticket.mCreatedAt;
|
||||
if (elapsed >= 0 && elapsed < URI_VALIDITY && ticket.mFile.exists())
|
||||
{
|
||||
return ParcelFileDescriptor.open(ticket.mFile, ParcelFileDescriptor.MODE_READ_ONLY);
|
||||
}
|
||||
mTickets.remove(uri);
|
||||
}
|
||||
return super.openFile(uri, mode);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Uri insert(Uri uri, ContentValues values)
|
||||
{
|
||||
/* not supported */
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int delete(Uri uri, String selection, String[] selectionArgs)
|
||||
{
|
||||
/* not supported */
|
||||
return 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int update(Uri uri, ContentValues values, String selection,
|
||||
String[] selectionArgs)
|
||||
{
|
||||
/* not supported */
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
+2786
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,118 @@
|
||||
/*
|
||||
* Copyright (C) 2026 SHX VPN
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or modify it
|
||||
* under the terms of the GNU General Public License as published by the
|
||||
* Free Software Foundation; either version 2 of the License, or (at your
|
||||
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful, but
|
||||
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
||||
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
|
||||
* for more details.
|
||||
*/
|
||||
|
||||
package org.strongswan.android.logic
|
||||
|
||||
import android.content.Context
|
||||
import java.io.File
|
||||
import java.io.FileWriter
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* Lightweight file-based lifecycle log (diag.log) for app-level events that
|
||||
* must remain diagnosable after the hosting process dies: logcat is lost in
|
||||
* that case and charon.log only covers the daemon. Records process starts,
|
||||
* watchdog decisions, retry scheduling, network changes, auto-connect and
|
||||
* foreground-service start failures.
|
||||
*
|
||||
* The writer is kept open across writes but flushed on every line, so
|
||||
* events survive a process kill at any point while avoiding a per-line
|
||||
* open/stat/close cycle. The file rotates at [MAX_SIZE] (one previous
|
||||
* generation is kept), tracked by a written-byte counter instead of a stat
|
||||
* per line. Never throws - logging must not be able to break the caller.
|
||||
*/
|
||||
object DiagLog
|
||||
{
|
||||
private const val FILE_NAME = "diag.log"
|
||||
private const val OLD_FILE_NAME = "diag.log.1"
|
||||
private const val MAX_SIZE = 256L * 1024
|
||||
|
||||
@Volatile
|
||||
private var dir: File? = null
|
||||
|
||||
private var writer: FileWriter? = null
|
||||
private var written = 0L
|
||||
|
||||
private val format = SimpleDateFormat("MM-dd HH:mm:ss.SSS", Locale.US)
|
||||
|
||||
/** Must be called once at process start (from the Application class). */
|
||||
@JvmStatic
|
||||
fun init(context: Context)
|
||||
{
|
||||
dir = context.getFilesDir()
|
||||
}
|
||||
|
||||
/** Write one line; never throws. */
|
||||
@JvmStatic
|
||||
fun i(tag: String, message: String)
|
||||
{
|
||||
val base = dir ?: return
|
||||
try
|
||||
{
|
||||
synchronized(this)
|
||||
{
|
||||
/* SimpleDateFormat is not thread-safe: format under the lock */
|
||||
val line = "${format.format(Date())} $tag: $message\n"
|
||||
if (written > MAX_SIZE)
|
||||
{
|
||||
rotate(base)
|
||||
}
|
||||
var w = writer
|
||||
if (w == null)
|
||||
{
|
||||
val file = File(base, FILE_NAME)
|
||||
written = file.length()
|
||||
w = FileWriter(file, true)
|
||||
writer = w
|
||||
}
|
||||
w.write(line)
|
||||
w.flush()
|
||||
written += line.toByteArray(Charsets.UTF_8).size
|
||||
}
|
||||
}
|
||||
catch (_: Exception)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
/** Close the writer and move diag.log to diag.log.1; reopened lazily. */
|
||||
private fun rotate(base: File)
|
||||
{
|
||||
writer?.let {
|
||||
try
|
||||
{
|
||||
it.close()
|
||||
}
|
||||
catch (_: Exception)
|
||||
{
|
||||
}
|
||||
}
|
||||
writer = null
|
||||
try
|
||||
{
|
||||
File(base, OLD_FILE_NAME).delete()
|
||||
File(base, FILE_NAME).renameTo(File(base, OLD_FILE_NAME))
|
||||
}
|
||||
catch (_: Exception)
|
||||
{
|
||||
}
|
||||
written = 0
|
||||
}
|
||||
|
||||
/** The log file (may not exist yet). */
|
||||
@JvmStatic
|
||||
fun file(context: Context): File = File(context.getFilesDir(), FILE_NAME)
|
||||
}
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
/*
|
||||
* Copyright (C) 2026 SHX VPN
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or modify it
|
||||
* under the terms of the GNU General Public License as published by the
|
||||
* Free Software Foundation; either version 2 of the License, or (at your
|
||||
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful, but
|
||||
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
||||
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
|
||||
* for more details.
|
||||
*/
|
||||
|
||||
package org.strongswan.android.logic;
|
||||
|
||||
import android.app.AlarmManager;
|
||||
import android.app.PendingIntent;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.os.Build;
|
||||
import android.os.SystemClock;
|
||||
import android.util.Log;
|
||||
|
||||
/**
|
||||
* Retries a foreground service start that the system blocked while the app
|
||||
* was in the background (Android 12+). Alarms are on the allowlist for such
|
||||
* starts, so the start is delivered via a one-shot alarm instead.
|
||||
*
|
||||
* The alarm is NON-WAKEUP and exact: it never wakes the device and simply
|
||||
* fires when the device is awake anyway (deferred by Doze until then). It
|
||||
* replaces any previously scheduled retry, so blocked starts cannot pile up.
|
||||
*/
|
||||
final class FgsStartRetry
|
||||
{
|
||||
private static final String TAG = FgsStartRetry.class.getSimpleName();
|
||||
private static final int REQUEST_CODE = 42;
|
||||
private static final long RETRY_DELAY_MS = 5000;
|
||||
|
||||
private FgsStartRetry()
|
||||
{
|
||||
}
|
||||
|
||||
static void schedule(Context context, Intent intent)
|
||||
{
|
||||
AlarmManager am = (AlarmManager)context.getSystemService(Context.ALARM_SERVICE);
|
||||
if (am == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
PendingIntent pi = PendingIntent.getForegroundService(context, REQUEST_CODE, intent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE);
|
||||
long when = SystemClock.elapsedRealtime() + RETRY_DELAY_MS;
|
||||
try
|
||||
{
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && !am.canScheduleExactAlarms())
|
||||
{
|
||||
am.set(AlarmManager.ELAPSED_REALTIME, when, pi);
|
||||
}
|
||||
else
|
||||
{
|
||||
am.setExact(AlarmManager.ELAPSED_REALTIME, when, pi);
|
||||
}
|
||||
}
|
||||
catch (SecurityException e)
|
||||
{ /* the exact-alarm grant was revoked in between; inexact is fine */
|
||||
am.set(AlarmManager.ELAPSED_REALTIME, when, pi);
|
||||
}
|
||||
Log.i(TAG, "Foreground service start was blocked, retrying via a non-wakeup alarm");
|
||||
DiagLog.i(TAG, "FGS start blocked, retry alarm scheduled in " + RETRY_DELAY_MS + "ms");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,347 @@
|
||||
/*
|
||||
* Copyright (C) 2026 SHX VPN
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or modify it
|
||||
* under the terms of the GNU General Public License as published by the
|
||||
* Free Software Foundation; either version 2 of the License, or (at your
|
||||
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful, but
|
||||
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
||||
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
|
||||
* for more details.
|
||||
*/
|
||||
|
||||
package org.strongswan.android.logic
|
||||
|
||||
import android.app.AlarmManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.VpnService
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import androidx.preference.PreferenceManager
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import org.strongswan.android.data.VpnProfileDataSource
|
||||
import org.strongswan.android.logic.events.Events
|
||||
|
||||
/**
|
||||
* "Pause VPN for N minutes": the tunnel is disconnected like a manual
|
||||
* disconnect (the watchdog stands down) and an alarm brings the same profile
|
||||
* back when the pause ends. Auto-connect does not start the tunnel while a
|
||||
* pause is running, and any connect request cancels the pause.
|
||||
*/
|
||||
object PauseManager
|
||||
{
|
||||
private const val TAG = "PauseManager"
|
||||
private const val PREF_UNTIL = "pref_pause_until"
|
||||
private const val PREF_PROFILE = "pref_pause_profile"
|
||||
private const val REQUEST_CODE = 45
|
||||
/** «Pause 15 min» from the VPN notification */
|
||||
const val ACTION_PAUSE = "org.strongswan.android.action.PAUSE_15"
|
||||
/* safety net of "until the network changes" */
|
||||
private const val NETWORK_PAUSE_MAX_MS = 12L * 60 * 60 * 1000
|
||||
|
||||
/* wall-clock end of the running pause, 0 if none; read by the Home screen */
|
||||
private val _until = MutableStateFlow(0L)
|
||||
val until: StateFlow<Long> = _until
|
||||
@Volatile private var loaded = false
|
||||
|
||||
private fun load(context: Context)
|
||||
{
|
||||
if (!loaded)
|
||||
{
|
||||
val until = PreferenceManager.getDefaultSharedPreferences(context).getLong(PREF_UNTIL, 0L)
|
||||
_until.value = if (until > System.currentTimeMillis()) until else 0L
|
||||
loaded = true
|
||||
}
|
||||
}
|
||||
|
||||
/** The flow of the pause end, initialised from the stored state. */
|
||||
@JvmStatic
|
||||
fun until(context: Context): StateFlow<Long>
|
||||
{
|
||||
load(context.applicationContext)
|
||||
return until
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun isPaused(context: Context): Boolean
|
||||
{
|
||||
load(context.applicationContext)
|
||||
return _until.value > System.currentTimeMillis()
|
||||
}
|
||||
|
||||
/** Disconnects [service] and schedules the reconnect of [profileUuid] in [minutes]. */
|
||||
@JvmStatic
|
||||
fun pause(context: Context, service: VpnStateService, profileUuid: String, minutes: Int)
|
||||
{
|
||||
pauseUntil(context, service, profileUuid, System.currentTimeMillis() + minutes * 60_000L)
|
||||
}
|
||||
|
||||
/** Disconnects [service] until the wall-clock time [until]. */
|
||||
@JvmStatic
|
||||
fun pauseUntil(context: Context, service: VpnStateService, profileUuid: String, until: Long)
|
||||
{
|
||||
val ctx = context.applicationContext
|
||||
unwatchNetwork(ctx)
|
||||
service.disconnect()
|
||||
store(ctx, until, profileUuid)
|
||||
DiagLog.i(TAG, "VPN paused until $until")
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnects until the device moves to another network (e.g. leaves this
|
||||
* Wi-Fi). A [NETWORK_PAUSE_MAX_MS] alarm is the safety net in case the
|
||||
* process dies meanwhile and the change is never seen.
|
||||
*/
|
||||
@JvmStatic
|
||||
fun pauseUntilNetworkChange(context: Context, service: VpnStateService, profileUuid: String)
|
||||
{
|
||||
val ctx = context.applicationContext
|
||||
pauseUntil(ctx, service, profileUuid, System.currentTimeMillis() + NETWORK_PAUSE_MAX_MS)
|
||||
watchNetwork(ctx)
|
||||
}
|
||||
|
||||
/** Next 07:00 after [now], local time. */
|
||||
@JvmStatic
|
||||
fun nextMorning(now: Long): Long
|
||||
{
|
||||
val c = java.util.Calendar.getInstance()
|
||||
c.timeInMillis = now
|
||||
c.set(java.util.Calendar.HOUR_OF_DAY, 7)
|
||||
c.set(java.util.Calendar.MINUTE, 0)
|
||||
c.set(java.util.Calendar.SECOND, 0)
|
||||
c.set(java.util.Calendar.MILLISECOND, 0)
|
||||
if (c.timeInMillis <= now)
|
||||
{
|
||||
c.add(java.util.Calendar.DAY_OF_YEAR, 1)
|
||||
}
|
||||
return c.timeInMillis
|
||||
}
|
||||
|
||||
private var networkCallback: android.net.ConnectivityManager.NetworkCallback? = null
|
||||
|
||||
private fun watchNetwork(ctx: Context)
|
||||
{
|
||||
val cm = ctx.getSystemService(Context.CONNECTIVITY_SERVICE) as? android.net.ConnectivityManager ?: return
|
||||
var first: android.net.Network? = null
|
||||
val callback = object : android.net.ConnectivityManager.NetworkCallback()
|
||||
{
|
||||
override fun onAvailable(network: android.net.Network)
|
||||
{
|
||||
/* right after the disconnect the default network may still be the
|
||||
* tunnel itself: only real uplinks count */
|
||||
val caps = try
|
||||
{
|
||||
cm.getNetworkCapabilities(network)
|
||||
}
|
||||
catch (_: Exception)
|
||||
{
|
||||
null
|
||||
}
|
||||
if (caps == null || caps.hasTransport(android.net.NetworkCapabilities.TRANSPORT_VPN))
|
||||
{
|
||||
return
|
||||
}
|
||||
/* the first uplink reported is the current one; any other one is a change */
|
||||
if (first == null)
|
||||
{
|
||||
first = network
|
||||
}
|
||||
else if (network != first)
|
||||
{
|
||||
DiagLog.i(TAG, "network changed, ending the pause")
|
||||
android.os.Handler(android.os.Looper.getMainLooper()).post { resumeNow(ctx) }
|
||||
}
|
||||
}
|
||||
}
|
||||
try
|
||||
{
|
||||
cm.registerDefaultNetworkCallback(callback)
|
||||
networkCallback = callback
|
||||
}
|
||||
catch (_: RuntimeException)
|
||||
{ /* too many callbacks: the safety alarm still ends the pause */
|
||||
}
|
||||
}
|
||||
|
||||
private fun unwatchNetwork(ctx: Context)
|
||||
{
|
||||
val callback = networkCallback ?: return
|
||||
networkCallback = null
|
||||
try
|
||||
{
|
||||
(ctx.getSystemService(Context.CONNECTIVITY_SERVICE) as? android.net.ConnectivityManager)?.unregisterNetworkCallback(callback)
|
||||
}
|
||||
catch (_: IllegalArgumentException)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
/** Moves the end of the running pause by [minutes]. */
|
||||
@JvmStatic
|
||||
fun extend(context: Context, minutes: Int)
|
||||
{
|
||||
val ctx = context.applicationContext
|
||||
val prefs = PreferenceManager.getDefaultSharedPreferences(ctx)
|
||||
val profile = prefs.getString(PREF_PROFILE, null) ?: return
|
||||
if (!isPaused(ctx))
|
||||
{
|
||||
return
|
||||
}
|
||||
store(ctx, _until.value + minutes * 60_000L, profile)
|
||||
}
|
||||
|
||||
/** Ends the pause right away and reconnects the paused profile. */
|
||||
@JvmStatic
|
||||
fun resumeNow(context: Context)
|
||||
{
|
||||
val ctx = context.applicationContext
|
||||
val profile = PreferenceManager.getDefaultSharedPreferences(ctx).getString(PREF_PROFILE, null)
|
||||
cancel(ctx)
|
||||
if (profile != null)
|
||||
{
|
||||
reconnect(ctx, profile)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Drops the pause without connecting. Called for every connect request,
|
||||
* so a manual connect (app, tile, widget, shortcut) wins over the pause.
|
||||
*/
|
||||
@JvmStatic
|
||||
fun cancel(context: Context)
|
||||
{
|
||||
val ctx = context.applicationContext
|
||||
load(ctx)
|
||||
if (_until.value == 0L)
|
||||
{
|
||||
return
|
||||
}
|
||||
_until.value = 0L
|
||||
unwatchNetwork(ctx)
|
||||
PreferenceManager.getDefaultSharedPreferences(ctx).edit()
|
||||
.remove(PREF_UNTIL).remove(PREF_PROFILE).apply()
|
||||
alarmManager(ctx).cancel(alarmIntent(ctx))
|
||||
}
|
||||
|
||||
private fun store(ctx: Context, until: Long, profileUuid: String)
|
||||
{
|
||||
PreferenceManager.getDefaultSharedPreferences(ctx).edit()
|
||||
.putLong(PREF_UNTIL, until).putString(PREF_PROFILE, profileUuid).apply()
|
||||
_until.value = until
|
||||
loaded = true
|
||||
val am = alarmManager(ctx)
|
||||
val pi = alarmIntent(ctx)
|
||||
try
|
||||
{
|
||||
val exact = Build.VERSION.SDK_INT < Build.VERSION_CODES.S || am.canScheduleExactAlarms()
|
||||
if (exact)
|
||||
{
|
||||
am.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, until, pi)
|
||||
}
|
||||
else
|
||||
{
|
||||
am.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, until, pi)
|
||||
}
|
||||
}
|
||||
catch (_: SecurityException)
|
||||
{ /* the exact-alarm grant was revoked in between; a late resume is fine */
|
||||
am.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, until, pi)
|
||||
}
|
||||
}
|
||||
|
||||
private fun alarmManager(ctx: Context) = ctx.getSystemService(Context.ALARM_SERVICE) as AlarmManager
|
||||
|
||||
private fun alarmIntent(ctx: Context): PendingIntent =
|
||||
PendingIntent.getBroadcast(
|
||||
ctx, REQUEST_CODE, Intent(ctx, PauseReceiver::class.java),
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
|
||||
/** Called by [PauseReceiver] when the alarm fires. */
|
||||
internal fun onAlarm(context: Context)
|
||||
{
|
||||
val ctx = context.applicationContext
|
||||
val profile = PreferenceManager.getDefaultSharedPreferences(ctx).getString(PREF_PROFILE, null)
|
||||
cancel(ctx)
|
||||
if (profile == null)
|
||||
{
|
||||
return
|
||||
}
|
||||
DiagLog.i(TAG, "pause over, reconnecting")
|
||||
reconnect(ctx, profile)
|
||||
Events.pauseOver(ctx)
|
||||
}
|
||||
|
||||
private fun reconnect(ctx: Context, profileUuid: String)
|
||||
{
|
||||
val needsConsent = try
|
||||
{
|
||||
VpnService.prepare(ctx) != null
|
||||
}
|
||||
catch (_: Exception)
|
||||
{
|
||||
true
|
||||
}
|
||||
if (needsConsent)
|
||||
{ /* the VPN permission went to another app meanwhile; only the UI can ask again */
|
||||
DiagLog.i(TAG, "pause over, but the VPN permission is gone")
|
||||
return
|
||||
}
|
||||
val service = VpnStateService.getLiveInstance()
|
||||
if (service != null)
|
||||
{ /* a live process: the regular connect path (keeps retries and state) */
|
||||
if (service.state == VpnStateService.State.DISABLED)
|
||||
{
|
||||
val extras = Bundle()
|
||||
extras.putString(VpnProfileDataSource.KEY_UUID, profileUuid)
|
||||
/* a password typed for the paused session goes along with it */
|
||||
service.profile?.takeIf { it.getUUID().toString() == profileUuid }?.password
|
||||
?.let { extras.putString(VpnProfileDataSource.KEY_PASSWORD, it) }
|
||||
service.connect(extras, true)
|
||||
}
|
||||
return
|
||||
}
|
||||
/* the process is gone (the alarm started a fresh one): start the
|
||||
* service directly, like the watchdog does */
|
||||
val start = Intent(ctx, CharonVpnService::class.java)
|
||||
start.putExtra(VpnProfileDataSource.KEY_UUID, profileUuid)
|
||||
try
|
||||
{
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O)
|
||||
{
|
||||
ctx.startForegroundService(start)
|
||||
}
|
||||
else
|
||||
{
|
||||
ctx.startService(start)
|
||||
}
|
||||
}
|
||||
catch (_: IllegalStateException)
|
||||
{
|
||||
FgsStartRetry.schedule(ctx, start)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Fires when a pause ends and brings the tunnel back. */
|
||||
class PauseReceiver : BroadcastReceiver()
|
||||
{
|
||||
override fun onReceive(context: Context, intent: Intent?)
|
||||
{
|
||||
if (intent?.action == PauseManager.ACTION_PAUSE)
|
||||
{
|
||||
org.strongswan.android.logic.scenarios.ScenarioEngine.onManual(context)
|
||||
val service = VpnStateService.getLiveInstance() ?: return
|
||||
val profile = service.profile ?: return
|
||||
PauseManager.pause(context, service, profile.getUUID().toString(), 15)
|
||||
return
|
||||
}
|
||||
PauseManager.onAlarm(context)
|
||||
}
|
||||
}
|
||||
+47
@@ -29,6 +29,12 @@ import android.util.Log;
|
||||
import org.strongswan.android.data.DatabaseHelper;
|
||||
import org.strongswan.android.data.ManagedConfigurationService;
|
||||
import org.strongswan.android.logic.autoconnect.AutoConnectManager;
|
||||
import org.strongswan.android.logic.connectivity.HealthCheckManager;
|
||||
import org.strongswan.android.logic.connectivity.WhiteListManager;
|
||||
import org.strongswan.android.logic.routing.RoutingManager;
|
||||
import org.strongswan.android.logic.routing.RuIpListManager;
|
||||
import org.strongswan.android.logic.shortcuts.VpnShortcutManager;
|
||||
import org.strongswan.android.logic.updates.UpdateManager;
|
||||
import org.strongswan.android.security.LocalCertificateKeyStoreProvider;
|
||||
import org.strongswan.android.utils.Constants;
|
||||
|
||||
@@ -81,6 +87,24 @@ public class StrongSwanApplication extends Application
|
||||
StrongSwanApplication.mContext = getApplicationContext();
|
||||
StrongSwanApplication.mInstance = this;
|
||||
|
||||
/* file-based lifecycle log; must be initialized before anything logs to it */
|
||||
DiagLog.init(this);
|
||||
DiagLog.i(TAG, "process start (pid=" + android.os.Process.myPid() + ")");
|
||||
|
||||
/* record every uncaught Java exception in diag.log before the process
|
||||
* dies: logcat is lost on a process kill, and this is the only way to
|
||||
* diagnose crashes reported by users without adb */
|
||||
final Thread.UncaughtExceptionHandler previousHandler =
|
||||
Thread.getDefaultUncaughtExceptionHandler();
|
||||
Thread.setDefaultUncaughtExceptionHandler((thread, throwable) -> {
|
||||
DiagLog.i(TAG, "UNCAUGHT EXCEPTION on " + thread.getName() + ": " +
|
||||
Log.getStackTraceString(throwable));
|
||||
if (previousHandler != null)
|
||||
{
|
||||
previousHandler.uncaughtException(thread, throwable);
|
||||
}
|
||||
});
|
||||
|
||||
mDatabaseHelper = new DatabaseHelper(mContext);
|
||||
|
||||
mManagedConfigurationService = new ManagedConfigurationService(mContext);
|
||||
@@ -97,6 +121,29 @@ public class StrongSwanApplication extends Application
|
||||
|
||||
/* starts monitoring networks if the trusted networks feature is enabled */
|
||||
AutoConnectManager.getInstance(this).start();
|
||||
|
||||
/* starts tunnel health checks if the connectivity check feature is enabled */
|
||||
HealthCheckManager.getInstance(this).start();
|
||||
|
||||
/* starts ISP whitelist detection if the feature is enabled */
|
||||
WhiteListManager.getInstance(this).start();
|
||||
|
||||
/* applies global routing rule sets and restarts the tunnel when they change */
|
||||
RoutingManager.getInstance(this).start();
|
||||
|
||||
/* loads the RU geoip list, refreshes it when due and restarts the tunnel on changes */
|
||||
RuIpListManager.getInstance(this).start();
|
||||
|
||||
/* publishes shortcuts for automation tools (e.g. Samsung Modes and Routines) if the feature is enabled */
|
||||
VpnShortcutManager.getInstance(this).start();
|
||||
|
||||
/* checks the update storage for a newer version of the app (if enabled and due) */
|
||||
UpdateManager.getInstance(this).start();
|
||||
/* daily re-read of the profiles that came from a link */
|
||||
org.strongswan.android.logic.profiles.ProfileUpdater.schedule(this);
|
||||
/* user scenarios of F_Automation */
|
||||
org.strongswan.android.logic.scenarios.ScenarioEngine.start(this);
|
||||
org.strongswan.android.logic.stats.WeeklySummary.schedule(this);
|
||||
}
|
||||
|
||||
private void reloadManagedConfigurationAndNotifyListeners()
|
||||
|
||||
+423
@@ -0,0 +1,423 @@
|
||||
/*
|
||||
* Copyright (C) 2026 SHX VPN
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or modify it
|
||||
* under the terms of the GNU General Public License as published by the
|
||||
* Free Software Foundation; either version 2 of the License, or (at your
|
||||
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful, but
|
||||
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
||||
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
|
||||
* for more details.
|
||||
*/
|
||||
|
||||
package org.strongswan.android.logic;
|
||||
|
||||
import android.content.Context;
|
||||
import android.net.Uri;
|
||||
import android.text.TextUtils;
|
||||
import android.util.Base64;
|
||||
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
import org.strongswan.android.data.ManagedCertificate;
|
||||
import org.strongswan.android.data.ManagedUserCertificate;
|
||||
import org.strongswan.android.data.ManagedVpnProfile;
|
||||
import org.strongswan.android.data.ProfileExportProvider;
|
||||
import org.strongswan.android.data.VpnProfile;
|
||||
import org.strongswan.android.data.VpnProfile.SelectedAppsHandling;
|
||||
import org.strongswan.android.data.VpnType;
|
||||
import org.strongswan.android.data.VpnType.VpnTypeFeature;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileWriter;
|
||||
import java.io.IOException;
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.security.Key;
|
||||
import java.security.KeyStore;
|
||||
import java.security.cert.Certificate;
|
||||
import java.security.cert.X509Certificate;
|
||||
import java.util.Enumeration;
|
||||
|
||||
/**
|
||||
* Exports a VpnProfile as a VPN profile file in the format used by the original
|
||||
* strongSwan app (.sswan), with the certificates embedded, so it can be shared
|
||||
* and imported by any strongSwan-compatible app.
|
||||
*/
|
||||
public class VpnProfileExporter
|
||||
{
|
||||
/** File name extension used by the original strongSwan app. */
|
||||
public static final String FILE_EXTENSION = ".sswan";
|
||||
|
||||
private VpnProfileExporter()
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize the given profile to the cache directory and create a shareable
|
||||
* content Uri for it.
|
||||
*
|
||||
* @param includePassword whether a stored password is written to the file (as cleartext)
|
||||
* @return the content Uri, or null if the profile could not be exported
|
||||
*/
|
||||
public static Uri exportProfile(Context context, VpnProfile profile, boolean includePassword)
|
||||
{
|
||||
try
|
||||
{
|
||||
JSONObject obj = serializeProfile(profile, includePassword);
|
||||
File file = writeFile(context, profile.getUUID().toString(), obj.toString(2));
|
||||
return ProfileExportProvider.createContentUri(context, file, displayName(profile.getName()));
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
e.printStackTrace();
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
static JSONObject serializeProfile(VpnProfile profile, boolean includePassword) throws IOException
|
||||
{
|
||||
if (TextUtils.isEmpty(profile.getGateway()))
|
||||
{
|
||||
throw new IOException("profile has no gateway");
|
||||
}
|
||||
JSONObject obj = new JSONObject();
|
||||
try
|
||||
{
|
||||
obj.put("uuid", profile.getUUID().toString());
|
||||
obj.put("name", profile.getName());
|
||||
obj.put("type", profile.getVpnType().getIdentifier());
|
||||
|
||||
obj.put("remote", buildRemote(profile));
|
||||
JSONObject local = buildLocal(profile, includePassword);
|
||||
if (local.length() > 0)
|
||||
{
|
||||
obj.put("local", local);
|
||||
}
|
||||
|
||||
obj.putOpt("ike-proposal", profile.getIkeProposal());
|
||||
obj.putOpt("esp-proposal", profile.getEspProposal());
|
||||
JSONArray dns = toArray(profile.getDnsServers());
|
||||
if (dns.length() > 0)
|
||||
{
|
||||
obj.put("dns-servers", dns);
|
||||
}
|
||||
obj.putOpt("mtu", profile.getMTU());
|
||||
obj.putOpt("nat-keepalive", profile.getNATKeepAlive());
|
||||
if (hasFlag(profile, VpnProfile.FLAGS_IPv6_TRANSPORT))
|
||||
{
|
||||
obj.put("ipv6-transport", true);
|
||||
}
|
||||
|
||||
JSONObject proxy = buildProxy(profile);
|
||||
if (proxy.length() > 0)
|
||||
{
|
||||
obj.put("proxy", proxy);
|
||||
}
|
||||
JSONObject split = buildSplitTunneling(profile);
|
||||
if (split.length() > 0)
|
||||
{
|
||||
obj.put("split-tunneling", split);
|
||||
}
|
||||
|
||||
if (profile.getSelectedAppsHandling() == SelectedAppsHandling.SELECTED_APPS_ONLY)
|
||||
{
|
||||
JSONArray apps = toArray(profile.getSelectedApps());
|
||||
if (apps.length() > 0)
|
||||
{
|
||||
obj.put("apps", apps);
|
||||
}
|
||||
}
|
||||
else if (profile.getSelectedAppsHandling() == SelectedAppsHandling.SELECTED_APPS_EXCLUDE)
|
||||
{
|
||||
JSONArray apps = toArray(profile.getSelectedApps());
|
||||
if (apps.length() > 0)
|
||||
{
|
||||
obj.put("excluded-apps", apps);
|
||||
}
|
||||
}
|
||||
return obj;
|
||||
}
|
||||
catch (IOException e)
|
||||
{
|
||||
throw e;
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
IOException wrapped = new IOException("failed to serialize profile");
|
||||
wrapped.initCause(e);
|
||||
throw wrapped;
|
||||
}
|
||||
}
|
||||
|
||||
private static JSONObject buildRemote(VpnProfile profile) throws Exception
|
||||
{
|
||||
JSONObject remote = new JSONObject();
|
||||
remote.put("addr", profile.getGateway());
|
||||
remote.putOpt("port", profile.getPort());
|
||||
remote.putOpt("id", profile.getRemoteId());
|
||||
|
||||
String cert = trustedCertificateData(profile);
|
||||
if (!TextUtils.isEmpty(cert))
|
||||
{
|
||||
remote.put("cert", cert);
|
||||
}
|
||||
if (hasFlag(profile, VpnProfile.FLAGS_SUPPRESS_CERT_REQS))
|
||||
{
|
||||
remote.put("certreq", false);
|
||||
}
|
||||
|
||||
JSONObject revocation = new JSONObject();
|
||||
if (hasFlag(profile, VpnProfile.FLAGS_DISABLE_CRL))
|
||||
{
|
||||
revocation.put("crl", false);
|
||||
}
|
||||
if (hasFlag(profile, VpnProfile.FLAGS_DISABLE_OCSP))
|
||||
{
|
||||
revocation.put("ocsp", false);
|
||||
}
|
||||
if (hasFlag(profile, VpnProfile.FLAGS_STRICT_REVOCATION))
|
||||
{
|
||||
revocation.put("strict", true);
|
||||
}
|
||||
if (revocation.length() > 0)
|
||||
{
|
||||
remote.put("revocation", revocation);
|
||||
}
|
||||
return remote;
|
||||
}
|
||||
|
||||
private static JSONObject buildLocal(VpnProfile profile, boolean includePassword) throws Exception
|
||||
{
|
||||
JSONObject local = new JSONObject();
|
||||
local.putOpt("id", profile.getLocalId());
|
||||
|
||||
VpnType type = profile.getVpnType();
|
||||
if (type.has(VpnTypeFeature.USER_PASS))
|
||||
{
|
||||
local.putOpt("eap_id", profile.getUsername());
|
||||
if (includePassword)
|
||||
{
|
||||
local.putOpt("shared_secret", profile.getPassword());
|
||||
}
|
||||
}
|
||||
if (type.has(VpnTypeFeature.CERTIFICATE))
|
||||
{
|
||||
String p12 = userCertificateData(profile);
|
||||
if (!TextUtils.isEmpty(p12))
|
||||
{
|
||||
local.put("p12", p12);
|
||||
}
|
||||
if (hasFlag(profile, VpnProfile.FLAGS_RSA_PSS))
|
||||
{
|
||||
local.put("rsa-pss", true);
|
||||
}
|
||||
}
|
||||
return local;
|
||||
}
|
||||
|
||||
private static JSONObject buildProxy(VpnProfile profile) throws Exception
|
||||
{
|
||||
JSONObject proxy = new JSONObject();
|
||||
if (!TextUtils.isEmpty(profile.getProxyHost()))
|
||||
{
|
||||
proxy.put("host", profile.getProxyHost());
|
||||
proxy.putOpt("port", profile.getProxyPort());
|
||||
JSONArray exclusions = toArray(profile.getProxyExclusions());
|
||||
if (exclusions.length() > 0)
|
||||
{
|
||||
proxy.put("exclusions", exclusions);
|
||||
}
|
||||
}
|
||||
return proxy;
|
||||
}
|
||||
|
||||
private static JSONObject buildSplitTunneling(VpnProfile profile) throws Exception
|
||||
{
|
||||
JSONObject split = new JSONObject();
|
||||
JSONArray included = toArray(profile.getIncludedSubnets());
|
||||
if (included.length() > 0)
|
||||
{
|
||||
split.put("subnets", included);
|
||||
}
|
||||
JSONArray excluded = toArray(profile.getExcludedSubnets());
|
||||
if (excluded.length() > 0)
|
||||
{
|
||||
split.put("excluded", excluded);
|
||||
}
|
||||
Integer st = profile.getSplitTunneling();
|
||||
if (st != null)
|
||||
{
|
||||
if ((st & VpnProfile.SPLIT_TUNNELING_BLOCK_IPV4) != 0)
|
||||
{
|
||||
split.put("block-ipv4", true);
|
||||
}
|
||||
if ((st & VpnProfile.SPLIT_TUNNELING_BLOCK_IPV6) != 0)
|
||||
{
|
||||
split.put("block-ipv6", true);
|
||||
}
|
||||
}
|
||||
return split;
|
||||
}
|
||||
|
||||
/**
|
||||
* Data of the CA/server certificate as Base64-encoded DER, or null if unavailable.
|
||||
*/
|
||||
private static String trustedCertificateData(VpnProfile profile)
|
||||
{
|
||||
if (profile instanceof ManagedVpnProfile)
|
||||
{ /* the raw certificate is stored with the profile */
|
||||
ManagedCertificate cert = ((ManagedVpnProfile)profile).getTrustedCertificate();
|
||||
return cert != null ? cert.getData() : null;
|
||||
}
|
||||
String alias = profile.getCertificateAlias();
|
||||
if (TextUtils.isEmpty(alias))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
try
|
||||
{ /* resolves from the app-local certificate store and all system stores */
|
||||
X509Certificate certificate = TrustedCertificateManager.getInstance().getCACertificateFromAlias(alias);
|
||||
if (certificate == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
return Base64.encodeToString(certificate.getEncoded(), Base64.NO_WRAP);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
e.printStackTrace();
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Data of the user certificate as Base64-encoded PKCS#12, or null if not exportable.
|
||||
* Only certificates stored by the app itself (managed profiles) can be exported;
|
||||
* keys installed in the system KeyChain are non-exportable by design.
|
||||
*/
|
||||
static String userCertificateData(VpnProfile profile)
|
||||
{
|
||||
if (!(profile instanceof ManagedVpnProfile))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
ManagedUserCertificate cert = ((ManagedVpnProfile)profile).getUserCertificate();
|
||||
if (cert == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
String repacked = repackageWithoutPassword(cert);
|
||||
return !TextUtils.isEmpty(repacked) ? repacked : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The profile file format has no field for the PKCS#12 password, so the container
|
||||
* is rewritten with an empty password. The full certificate chain is preserved.
|
||||
*/
|
||||
private static String repackageWithoutPassword(ManagedUserCertificate cert)
|
||||
{
|
||||
try
|
||||
{
|
||||
char[] password = cert.getPrivateKeyPassword() == null ? new char[0]
|
||||
: cert.getPrivateKeyPassword().toCharArray();
|
||||
KeyStore in = KeyStore.getInstance("PKCS12");
|
||||
in.load(new ByteArrayInputStream(Base64.decode(cert.getData(), Base64.DEFAULT)), password);
|
||||
|
||||
KeyStore out = KeyStore.getInstance("PKCS12");
|
||||
out.load(null, null);
|
||||
for (Enumeration<String> aliases = in.aliases(); aliases.hasMoreElements(); )
|
||||
{
|
||||
String alias = aliases.nextElement();
|
||||
Key key = in.getKey(alias, password);
|
||||
Certificate[] chain = in.getCertificateChain(alias);
|
||||
if (key != null)
|
||||
{
|
||||
out.setKeyEntry(alias, key, null, chain != null ? chain : new Certificate[0]);
|
||||
}
|
||||
else
|
||||
{
|
||||
Certificate certificate = in.getCertificate(alias);
|
||||
if (certificate != null)
|
||||
{
|
||||
out.setCertificateEntry(alias, certificate);
|
||||
}
|
||||
}
|
||||
}
|
||||
ByteArrayOutputStream outStream = new ByteArrayOutputStream();
|
||||
out.store(outStream, null); /* empty password */
|
||||
return Base64.encodeToString(outStream.toByteArray(), Base64.NO_WRAP);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
e.printStackTrace();
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean hasFlag(VpnProfile profile, int flag)
|
||||
{
|
||||
Integer flags = profile.getFlags();
|
||||
return flags != null && (flags & flag) != 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a space-separated string to a JSON array (inverse of what the
|
||||
* importer joins with spaces).
|
||||
*/
|
||||
private static JSONArray toArray(String value)
|
||||
{
|
||||
JSONArray array = new JSONArray();
|
||||
if (!TextUtils.isEmpty(value))
|
||||
{
|
||||
for (String item : value.trim().split("\\s+"))
|
||||
{
|
||||
if (!TextUtils.isEmpty(item))
|
||||
{
|
||||
array.put(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
return array;
|
||||
}
|
||||
|
||||
private static File writeFile(Context context, String uuid, String data) throws IOException
|
||||
{
|
||||
File dir = ProfileExportProvider.exportDirectory(context);
|
||||
/* drop exports of other profiles so the cache does not grow indefinitely */
|
||||
File[] files = dir.listFiles();
|
||||
if (files != null)
|
||||
{
|
||||
for (File file : files)
|
||||
{
|
||||
file.delete();
|
||||
}
|
||||
}
|
||||
File file = ProfileExportProvider.exportFile(context, uuid + FILE_EXTENSION);
|
||||
try (FileWriter writer = new FileWriter(file))
|
||||
{
|
||||
writer.write(data);
|
||||
}
|
||||
return file;
|
||||
}
|
||||
|
||||
/**
|
||||
* File name offered to the receiving app, derived from the profile name.
|
||||
*/
|
||||
private static String displayName(String name)
|
||||
{
|
||||
String sanitized = name == null ? "" : name.replaceAll("[\\\\/:*?\"<>|\\p{Cntrl}]", "_").trim();
|
||||
sanitized = sanitized.replaceAll("^[. ]+|[. ]+$", "");
|
||||
if (TextUtils.isEmpty(sanitized))
|
||||
{
|
||||
sanitized = "profile";
|
||||
}
|
||||
if (sanitized.length() > 100)
|
||||
{
|
||||
sanitized = sanitized.substring(0, 100);
|
||||
}
|
||||
return sanitized + FILE_EXTENSION;
|
||||
}
|
||||
}
|
||||
+381
-19
@@ -19,6 +19,7 @@ package org.strongswan.android.logic;
|
||||
import android.app.Service;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.content.SharedPreferences;
|
||||
import android.os.Binder;
|
||||
import android.os.Bundle;
|
||||
import android.os.Handler;
|
||||
@@ -26,28 +27,41 @@ import android.os.IBinder;
|
||||
import android.os.Looper;
|
||||
import android.os.Message;
|
||||
import android.os.SystemClock;
|
||||
import android.util.Log;
|
||||
|
||||
import org.strongswan.android.R;
|
||||
import org.strongswan.android.data.VpnProfile;
|
||||
import org.strongswan.android.ui.widget.ShxWidgetState;
|
||||
import org.strongswan.android.data.VpnProfileDataSource;
|
||||
import org.strongswan.android.data.VpnType;
|
||||
import org.strongswan.android.logic.events.Events;
|
||||
import org.strongswan.android.logic.profiles.Resilience;
|
||||
import org.strongswan.android.logic.imc.ImcState;
|
||||
import org.strongswan.android.logic.imc.RemediationInstruction;
|
||||
import org.strongswan.android.logic.connectivity.WhiteListManager;
|
||||
import org.strongswan.android.ui.VpnProfileControlActivity;
|
||||
|
||||
import java.lang.ref.WeakReference;
|
||||
import java.util.Collections;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedList;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.Callable;
|
||||
|
||||
import androidx.core.content.ContextCompat;
|
||||
import androidx.preference.PreferenceManager;
|
||||
|
||||
import org.strongswan.android.utils.Constants;
|
||||
|
||||
public class VpnStateService extends Service
|
||||
{
|
||||
private final HashSet<VpnStateListener> mListeners = new HashSet<VpnStateListener>();
|
||||
private final IBinder mBinder = new LocalBinder();
|
||||
/* the instance of this service in the current process, or null if it is
|
||||
* not running; used by the watchdog to tell a live process from a fresh
|
||||
* one (the receiver runs in the same, single process) */
|
||||
private static volatile VpnStateService sInstance;
|
||||
private long mConnectionID = 0;
|
||||
private Handler mHandler;
|
||||
private VpnProfile mProfile;
|
||||
@@ -56,12 +70,41 @@ public class VpnStateService extends Service
|
||||
private ImcState mImcState = ImcState.UNKNOWN;
|
||||
private final LinkedList<RemediationInstruction> mRemediationInstructions = new LinkedList<RemediationInstruction>();
|
||||
private static final long RETRY_INTERVAL = 1000;
|
||||
/* while a retry is still far away the countdown only ticks in coarse steps,
|
||||
* saving main-thread wakeups (and notification rebuilds) while waiting */
|
||||
private static final long RETRY_COARSE_INTERVAL = 5000;
|
||||
private static final long RETRY_COARSE_THRESHOLD = 15000;
|
||||
/* while an ISP whitelist is active the retry countdown is frozen at this interval */
|
||||
private static final long RETRY_SUPPRESS_MS = 10000;
|
||||
/* cap the retry interval at 2 minutes */
|
||||
private static final long MAX_RETRY_INTERVAL = 120000;
|
||||
/* if the daemon neither completes nor reports a failure within this window
|
||||
* we surface it as UNREACHABLE so the retry machinery takes over */
|
||||
private static final long CONNECTING_TIMEOUT = 90000;
|
||||
/* minimum interval between network-change-triggered immediate retries */
|
||||
private static final long NETWORK_RETRY_DEBOUNCE_MS = 5000;
|
||||
private static final int RETRY_MSG = 1;
|
||||
private static final int CONNECTING_TIMEOUT_MSG = 2;
|
||||
private final RetryTimeoutProvider mTimeoutProvider = new RetryTimeoutProvider();
|
||||
private long mRetryTimeout;
|
||||
private long mRetryIn;
|
||||
/* true while the countdown is parked because the ISP whitelist blocks the server */
|
||||
private boolean mRetryFrozen;
|
||||
/* timestamp of the last network-change-triggered immediate retry */
|
||||
private long mLastNetworkRetry;
|
||||
/* double-start guard: profile and issue time of the last connect() that
|
||||
* got through, cleared by disconnect() */
|
||||
private static final long CONNECT_DEDUPE_MS = 10000;
|
||||
private String mLastConnectUuid;
|
||||
private long mLastConnectIssued;
|
||||
|
||||
private static final String TAG = VpnStateService.class.getSimpleName();
|
||||
|
||||
/** Tick size for the retry countdown: coarse steps early, seconds at the end. */
|
||||
private static long retryStep(long retryIn)
|
||||
{
|
||||
return Math.min(retryIn > RETRY_COARSE_THRESHOLD ? RETRY_COARSE_INTERVAL : RETRY_INTERVAL, retryIn);
|
||||
}
|
||||
|
||||
public enum State
|
||||
{
|
||||
@@ -107,6 +150,7 @@ public class VpnStateService extends Service
|
||||
@Override
|
||||
public void onCreate()
|
||||
{
|
||||
sInstance = this;
|
||||
/* this handler allows us to notify listeners from the UI thread and
|
||||
* not from the threads that actually report any state changes */
|
||||
mHandler = new RetryHandler(getMainLooper(), this);
|
||||
@@ -121,6 +165,20 @@ public class VpnStateService extends Service
|
||||
@Override
|
||||
public void onDestroy()
|
||||
{
|
||||
if (sInstance == this)
|
||||
{
|
||||
sInstance = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The instance of this service in the current process, or null if it is
|
||||
* not (yet) running. Since the app is single-process, the watchdog
|
||||
* receiver can use this to detect that the previous process died.
|
||||
*/
|
||||
public static VpnStateService getLiveInstance()
|
||||
{
|
||||
return sInstance;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -144,6 +202,27 @@ public class VpnStateService extends Service
|
||||
mListeners.remove(listener);
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify all registered listeners about a state change. Iterates over a
|
||||
* copy (a listener may unregister itself from its callback) and isolates
|
||||
* the listeners from each other: one failing listener must neither skip
|
||||
* the others nor crash the main thread.
|
||||
*/
|
||||
private void notifyStateListeners()
|
||||
{
|
||||
for (VpnStateListener listener : new ArrayList<>(mListeners))
|
||||
{
|
||||
try
|
||||
{
|
||||
listener.stateChanged();
|
||||
}
|
||||
catch (RuntimeException e)
|
||||
{
|
||||
DiagLog.i(TAG, "state listener failed: " + e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current VPN profile.
|
||||
*
|
||||
@@ -267,9 +346,48 @@ public class VpnStateService extends Service
|
||||
*/
|
||||
public void disconnect()
|
||||
{
|
||||
/* reset any potential retry timer and error state */
|
||||
disconnect(false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect any existing connection and shutdown the daemon, the
|
||||
* VpnService is not stopped but it is reset so new connections can be
|
||||
* started.
|
||||
*
|
||||
* @param autoManaged true if this was done by the trusted-networks
|
||||
* auto-connect (the tunnel is parked on purpose and is expected to come
|
||||
* back when the network no longer matches, so the watchdog stays armed)
|
||||
*/
|
||||
public void disconnect(boolean autoManaged)
|
||||
{
|
||||
/* reset any potential retry timer and error state, and re-arm the
|
||||
* double-start guard (a connect after a deliberate disconnect is a
|
||||
* fresh request and must not be deduped) */
|
||||
resetRetryTimer();
|
||||
setError(ErrorState.NO_ERROR);
|
||||
mLastConnectIssued = 0;
|
||||
|
||||
if (autoManaged)
|
||||
{ /* the auto-connect feature parked the tunnel on a trusted network;
|
||||
* mark the session as expected so the watchdog can restore it if
|
||||
* this process dies before the network changes (auto-connect dies
|
||||
* with the process and would otherwise never reconnect) */
|
||||
SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(this);
|
||||
prefs.edit().remove(Constants.PREF_WATCHDOG_EXPECTED_PROFILE)
|
||||
.putBoolean(Constants.PREF_WATCHDOG_AUTO_EXPECTED, true).apply();
|
||||
WatchdogReceiver.schedule(this);
|
||||
DiagLog.i(TAG, "auto-connect parked the tunnel, watchdog stays armed");
|
||||
}
|
||||
else
|
||||
{ /* the user explicitly disconnected, so the watchdog must not restore
|
||||
* the connection after this */
|
||||
Events.connectionStopped(this);
|
||||
SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(this);
|
||||
prefs.edit().remove(Constants.PREF_WATCHDOG_EXPECTED_PROFILE)
|
||||
.putBoolean(Constants.PREF_WATCHDOG_AUTO_EXPECTED, false).apply();
|
||||
WatchdogReceiver.cancel(this);
|
||||
DiagLog.i(TAG, "user disconnect");
|
||||
}
|
||||
|
||||
/* as soon as the TUN device is created by calling establish() on the
|
||||
* VpnService.Builder object the system binds to the service and keeps
|
||||
@@ -280,7 +398,15 @@ public class VpnStateService extends Service
|
||||
Context context = getApplicationContext();
|
||||
Intent intent = new Intent(context, CharonVpnService.class);
|
||||
intent.setAction(CharonVpnService.DISCONNECT_ACTION);
|
||||
context.startService(intent);
|
||||
try
|
||||
{
|
||||
context.startService(intent);
|
||||
}
|
||||
catch (IllegalStateException e)
|
||||
{ /* background start of a service that is not running (Android 8+):
|
||||
* without the service there is no tunnel to tear down */
|
||||
DiagLog.i(TAG, "disconnect: VPN service not running (" + e.getMessage() + ")");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -291,9 +417,25 @@ public class VpnStateService extends Service
|
||||
* @param fromScratch true if this is a manual retry/reconnect or a completely new connection
|
||||
*/
|
||||
public void connect(Bundle profileInfo, boolean fromScratch)
|
||||
{
|
||||
connect(profileInfo, fromScratch, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect (or reconnect) a profile
|
||||
*
|
||||
* @param profileInfo optional profile info (basically the UUID and password), taken from the
|
||||
* previous profile if null
|
||||
* @param fromScratch true if this is a manual retry/reconnect or a completely new connection
|
||||
* @param preserveLog true if this is an automatic start whose charon log should
|
||||
* be kept (e.g. recovery after the process died)
|
||||
*/
|
||||
public void connect(Bundle profileInfo, boolean fromScratch, boolean preserveLog)
|
||||
{
|
||||
/* we assume we have the necessary permission */
|
||||
Context context = getApplicationContext();
|
||||
/* any connect request (app, tile, widget, shortcut, pause alarm) ends a running pause */
|
||||
PauseManager.cancel(context);
|
||||
Intent intent = new Intent(context, CharonVpnService.class);
|
||||
if (profileInfo == null)
|
||||
{
|
||||
@@ -302,6 +444,31 @@ public class VpnStateService extends Service
|
||||
/* pass the previous password along */
|
||||
profileInfo.putString(VpnProfileDataSource.KEY_PASSWORD, mProfile.getPassword());
|
||||
}
|
||||
String uuid = profileInfo.getString(VpnProfileDataSource.KEY_UUID);
|
||||
if (uuid == null && mProfile != null)
|
||||
{
|
||||
uuid = mProfile.getUUID().toString();
|
||||
}
|
||||
if (fromScratch)
|
||||
{ /* a user's connect starts a new resilience attempt */
|
||||
Resilience.onConnect(uuid, profileInfo);
|
||||
}
|
||||
/* double-start guard: while a healthy connect attempt for the same
|
||||
* profile is in flight (or was just issued and the service has not
|
||||
* reached startConnection() yet) a second start intent only churns
|
||||
* the daemon - after an app self-update up to four starters (update
|
||||
* receiver, process-start auto-connect, watchdog, UI) raced through
|
||||
* here within 20 seconds. Deliberate retries during an error backoff
|
||||
* (mError set) and requests for another profile always pass. */
|
||||
if (uuid != null && uuid.equals(mLastConnectUuid) && mError == ErrorState.NO_ERROR
|
||||
&& !profileInfo.getBoolean(Resilience.KEY_INTERNAL, false)
|
||||
&& (mState == State.CONNECTING
|
||||
|| ((mState == State.DISABLED || mState == State.DISCONNECTING)
|
||||
&& SystemClock.uptimeMillis() - mLastConnectIssued < CONNECT_DEDUPE_MS)))
|
||||
{
|
||||
DiagLog.i(TAG, "connect: same profile already connecting, ignored");
|
||||
return;
|
||||
}
|
||||
if (fromScratch)
|
||||
{
|
||||
/* reset if this is a manual retry or a new connection */
|
||||
@@ -311,8 +478,48 @@ public class VpnStateService extends Service
|
||||
{ /* mark this as an automatic retry */
|
||||
profileInfo.putBoolean(CharonVpnService.KEY_IS_RETRY, true);
|
||||
}
|
||||
if (preserveLog)
|
||||
{ /* don't wipe the previous session's log; needed to diagnose why an
|
||||
* automatic reconnect was necessary in the first place */
|
||||
profileInfo.putBoolean(CharonVpnService.KEY_PRESERVE_LOG, true);
|
||||
}
|
||||
intent.putExtras(profileInfo);
|
||||
ContextCompat.startForegroundService(context, intent);
|
||||
try
|
||||
{
|
||||
ContextCompat.startForegroundService(context, intent);
|
||||
}
|
||||
catch (IllegalStateException e)
|
||||
{ /* Android 12+ forbids starting a foreground service from the
|
||||
* background (e.g. auto-connect on a network change or from the
|
||||
* quick settings tile). Exact alarms are on the allowlist for
|
||||
* such starts, so deliver the start via a one-shot alarm. */
|
||||
DiagLog.i(TAG, "FGS start blocked while in background, scheduling FgsStartRetry");
|
||||
FgsStartRetry.schedule(context, intent);
|
||||
}
|
||||
DiagLog.i(TAG, "connect uuid=" + profileInfo.getString(VpnProfileDataSource.KEY_UUID) +
|
||||
" fromScratch=" + fromScratch + " preserveLog=" + preserveLog);
|
||||
/* remember that a connection is expected so the watchdog can restore
|
||||
* it if this process dies (Always-on VPN is restarted by the system
|
||||
* and bypasses this path anyway) */
|
||||
if (uuid != null)
|
||||
{
|
||||
mLastConnectUuid = uuid;
|
||||
mLastConnectIssued = SystemClock.uptimeMillis();
|
||||
SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context);
|
||||
if (!uuid.equals(prefs.getString(Constants.PREF_WATCHDOG_EXPECTED_PROFILE, null)))
|
||||
{
|
||||
prefs.edit().putString(Constants.PREF_WATCHDOG_EXPECTED_PROFILE, uuid).apply();
|
||||
}
|
||||
/* a real connection supersedes an auto-parked session: with the
|
||||
* expected profile set the watchdog restores this connection
|
||||
* regardless of the network (a session the user started manually
|
||||
* comes back even on a trusted Wi-Fi) */
|
||||
if (prefs.getBoolean(Constants.PREF_WATCHDOG_AUTO_EXPECTED, false))
|
||||
{
|
||||
prefs.edit().putBoolean(Constants.PREF_WATCHDOG_AUTO_EXPECTED, false).apply();
|
||||
}
|
||||
WatchdogReceiver.schedule(context);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -346,6 +553,63 @@ public class VpnStateService extends Service
|
||||
connect(null, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconnect to the previous profile without user interaction (health
|
||||
* check, routing managers). Unlike reconnect() this never launches UI
|
||||
* and keeps the charon log of the failed session so an automatic
|
||||
* reconnect can still be diagnosed afterwards.
|
||||
*/
|
||||
public void reconnectPreservingLog()
|
||||
{
|
||||
if (mProfile == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
connect(null, true, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Called by CharonVpnService when the set of underlying (physical)
|
||||
* networks below the tunnel changed (e.g. Wi-Fi was lost, cellular became
|
||||
* available).
|
||||
*
|
||||
* While the retry machinery is waiting out a backoff that was sized for
|
||||
* the previous network, reconnect immediately: the backoff is meaningless
|
||||
* after a network change, and waiting it out just delays the handover.
|
||||
*/
|
||||
public void onUnderlyingNetworksChanged()
|
||||
{
|
||||
mHandler.post(new Runnable()
|
||||
{
|
||||
@Override
|
||||
public void run()
|
||||
{
|
||||
if (mState != State.CONNECTING || mError == ErrorState.NO_ERROR || mRetryTimeout <= 0)
|
||||
{ /* either a connection attempt is already running (no retry
|
||||
* pending) or we are not retrying at all - nothing to do */
|
||||
return;
|
||||
}
|
||||
long now = SystemClock.uptimeMillis();
|
||||
if (now - mLastNetworkRetry < NETWORK_RETRY_DEBOUNCE_MS)
|
||||
{ /* network callbacks can be chatty during a handover */
|
||||
return;
|
||||
}
|
||||
if (WhiteListManager.getInstance(VpnStateService.this).shouldBlockReconnect())
|
||||
{ /* same gate as the regular retry countdown */
|
||||
return;
|
||||
}
|
||||
mLastNetworkRetry = now;
|
||||
Log.i(TAG, "Underlying networks changed while retrying, reconnecting immediately");
|
||||
DiagLog.i(TAG, "network changed during backoff, retrying immediately");
|
||||
mRetryTimeout = 0;
|
||||
mRetryIn = 0;
|
||||
mRetryFrozen = false;
|
||||
mHandler.removeMessages(RETRY_MSG);
|
||||
connect(null, false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update state and notify all listeners about the change. By using a Handler
|
||||
* this is done from the main UI thread and not the initial reporter thread.
|
||||
@@ -365,15 +629,17 @@ public class VpnStateService extends Service
|
||||
{
|
||||
if (change.call())
|
||||
{ /* otherwise there is no need to notify the listeners */
|
||||
for (VpnStateListener listener : mListeners)
|
||||
{
|
||||
listener.stateChanged();
|
||||
}
|
||||
notifyStateListeners();
|
||||
/* the home screen widget mirrors the state */
|
||||
VpnProfile widgetProfile = mProfile;
|
||||
ShxWidgetState.publish(VpnStateService.this, mState, getErrorState(),
|
||||
widgetProfile != null ? widgetProfile.getName() : null,
|
||||
widgetProfile != null ? widgetProfile.getUUID().toString() : null);
|
||||
}
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
e.printStackTrace();
|
||||
DiagLog.i(TAG, "state change failed: " + e);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -402,6 +668,7 @@ public class VpnStateService extends Service
|
||||
VpnStateService.this.mError = ErrorState.NO_ERROR;
|
||||
VpnStateService.this.mImcState = ImcState.UNKNOWN;
|
||||
VpnStateService.this.mRemediationInstructions.clear();
|
||||
scheduleConnectingTimeout();
|
||||
return true;
|
||||
}
|
||||
});
|
||||
@@ -428,6 +695,20 @@ public class VpnStateService extends Service
|
||||
if (VpnStateService.this.mState != state)
|
||||
{
|
||||
VpnStateService.this.mState = state;
|
||||
if (state == State.CONNECTED)
|
||||
{
|
||||
Events.connectionRestored(VpnStateService.this);
|
||||
Resilience.onConnected(VpnStateService.this);
|
||||
}
|
||||
if (state == State.CONNECTING)
|
||||
{ /* reentered while the daemon reestablishes the tunnel; bound
|
||||
* the total time so a lost alert can't strand us here */
|
||||
scheduleConnectingTimeout();
|
||||
}
|
||||
else
|
||||
{
|
||||
cancelConnectingTimeout();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
@@ -451,6 +732,14 @@ public class VpnStateService extends Service
|
||||
{
|
||||
if (VpnStateService.this.mError != error)
|
||||
{
|
||||
/* the server does not answer: backup ports, then the
|
||||
* reserve server, before the regular backoff */
|
||||
if (VpnStateService.this.mError == ErrorState.NO_ERROR
|
||||
&& (error == ErrorState.UNREACHABLE || error == ErrorState.LOOKUP_FAILED)
|
||||
&& Resilience.onUnreachable(VpnStateService.this))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if (VpnStateService.this.mError == ErrorState.NO_ERROR)
|
||||
{
|
||||
setRetryTimer(error);
|
||||
@@ -460,6 +749,17 @@ public class VpnStateService extends Service
|
||||
resetRetryTimer();
|
||||
}
|
||||
VpnStateService.this.mError = error;
|
||||
if (error != ErrorState.NO_ERROR)
|
||||
{ /* repeats while retrying are merged into one event */
|
||||
Events.connectionError(VpnStateService.this, error);
|
||||
}
|
||||
if (error == ErrorState.PASSWORD_MISSING)
|
||||
{ /* only a person can type the password: the watchdog must not
|
||||
* bring this attempt back after the process restarts */
|
||||
PreferenceManager.getDefaultSharedPreferences(VpnStateService.this).edit()
|
||||
.remove(Constants.PREF_WATCHDOG_EXPECTED_PROFILE)
|
||||
.remove(Constants.PREF_WATCHDOG_AUTO_EXPECTED).apply();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
@@ -525,11 +825,16 @@ public class VpnStateService extends Service
|
||||
private void setRetryTimer(ErrorState error)
|
||||
{
|
||||
mRetryTimeout = mRetryIn = mTimeoutProvider.getTimeout(error);
|
||||
mRetryFrozen = false;
|
||||
DiagLog.i(TAG, "error " + error + ", retry in " + (mRetryTimeout / 1000) + "s");
|
||||
/* never run two countdown chains in parallel (double-speed countdown,
|
||||
* double notification updates and two reconnects at zero) */
|
||||
mHandler.removeMessages(RETRY_MSG);
|
||||
if (mRetryTimeout <= 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
mHandler.sendMessageAtTime(mHandler.obtainMessage(RETRY_MSG), SystemClock.uptimeMillis() + RETRY_INTERVAL);
|
||||
mHandler.sendMessageAtTime(mHandler.obtainMessage(RETRY_MSG), SystemClock.uptimeMillis() + retryStep(mRetryIn));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -537,8 +842,30 @@ public class VpnStateService extends Service
|
||||
*/
|
||||
private void resetRetryTimer()
|
||||
{
|
||||
mHandler.removeMessages(RETRY_MSG);
|
||||
mRetryTimeout = 0;
|
||||
mRetryIn = 0;
|
||||
mRetryFrozen = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Arm the one-shot CONNECTING timeout. Uses a handler message counted in
|
||||
* uptime, so it never wakes the device and simply freezes while it dozes.
|
||||
* Called from the main thread (via notifyListeners) only.
|
||||
*/
|
||||
private void scheduleConnectingTimeout()
|
||||
{
|
||||
mHandler.removeMessages(CONNECTING_TIMEOUT_MSG);
|
||||
mHandler.sendMessageAtTime(mHandler.obtainMessage(CONNECTING_TIMEOUT_MSG),
|
||||
SystemClock.uptimeMillis() + CONNECTING_TIMEOUT);
|
||||
}
|
||||
|
||||
/**
|
||||
* Disarm the CONNECTING timeout. Called from the main thread only.
|
||||
*/
|
||||
private void cancelConnectingTimeout()
|
||||
{
|
||||
mHandler.removeMessages(CONNECTING_TIMEOUT_MSG);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -557,26 +884,61 @@ public class VpnStateService extends Service
|
||||
@Override
|
||||
public void handleMessage(Message msg)
|
||||
{
|
||||
/* handle retry countdown */
|
||||
if (mService.get().mRetryTimeout <= 0)
|
||||
VpnStateService service = mService.get();
|
||||
if (msg.what == CONNECTING_TIMEOUT_MSG)
|
||||
{
|
||||
if (service != null && service.mState == State.CONNECTING &&
|
||||
service.mError == ErrorState.NO_ERROR)
|
||||
{ /* the daemon neither completed the connection nor reported a
|
||||
* failure; surface it as UNREACHABLE so the regular retry
|
||||
* machinery (with its whitelist handling) takes over */
|
||||
service.setError(ErrorState.UNREACHABLE);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (service == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
mService.get().mRetryIn -= RETRY_INTERVAL;
|
||||
if (mService.get().mRetryIn > 0)
|
||||
/* handle retry countdown */
|
||||
if (service.mRetryTimeout <= 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (service.mRetryFrozen)
|
||||
{ /* the countdown is parked while the ISP whitelist blocks the server;
|
||||
* re-check once per suppression period and go back to sleep - this
|
||||
* reaches connect() again on its own once the whitelist clears */
|
||||
if (WhiteListManager.getInstance(service).shouldBlockReconnect())
|
||||
{
|
||||
sendMessageAtTime(obtainMessage(RETRY_MSG), SystemClock.uptimeMillis() + RETRY_SUPPRESS_MS);
|
||||
return;
|
||||
}
|
||||
service.connect(null, false);
|
||||
return;
|
||||
}
|
||||
service.mRetryIn -= retryStep(service.mRetryIn);
|
||||
if (service.mRetryIn > 0)
|
||||
{
|
||||
/* calculate next interval before notifying listeners */
|
||||
long next = SystemClock.uptimeMillis() + RETRY_INTERVAL;
|
||||
long next = SystemClock.uptimeMillis() + retryStep(service.mRetryIn);
|
||||
|
||||
for (VpnStateListener listener : mService.get().mListeners)
|
||||
{
|
||||
listener.stateChanged();
|
||||
}
|
||||
service.notifyStateListeners();
|
||||
sendMessageAtTime(obtainMessage(RETRY_MSG), next);
|
||||
}
|
||||
else
|
||||
{
|
||||
mService.get().connect(null, false);
|
||||
if (WhiteListManager.getInstance(service).shouldBlockReconnect())
|
||||
{ /* the ISP whitelist blocks the server, reconnecting is pointless;
|
||||
* park the countdown until the whitelist clears instead of
|
||||
* ticking every second */
|
||||
service.mRetryFrozen = true;
|
||||
service.mRetryIn = RETRY_SUPPRESS_MS;
|
||||
service.notifyStateListeners();
|
||||
sendMessageAtTime(obtainMessage(RETRY_MSG), SystemClock.uptimeMillis() + RETRY_SUPPRESS_MS);
|
||||
return;
|
||||
}
|
||||
service.connect(null, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+469
@@ -0,0 +1,469 @@
|
||||
/*
|
||||
* Copyright (C) 2026 SHX VPN
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or modify it
|
||||
* under the terms of the GNU General Public License as published by the
|
||||
* Free Software Foundation; either version 2 of the License, or (at your
|
||||
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful, but
|
||||
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
||||
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
|
||||
* for more details.
|
||||
*/
|
||||
|
||||
package org.strongswan.android.logic;
|
||||
|
||||
import android.app.AlarmManager;
|
||||
import android.app.PendingIntent;
|
||||
import android.content.BroadcastReceiver;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.content.SharedPreferences;
|
||||
import android.net.ConnectivityManager;
|
||||
import android.net.Network;
|
||||
import android.net.NetworkCapabilities;
|
||||
import android.os.Build;
|
||||
import android.os.SystemClock;
|
||||
import android.util.Log;
|
||||
|
||||
import androidx.preference.PreferenceManager;
|
||||
|
||||
import org.strongswan.android.data.VpnProfileDataSource;
|
||||
import org.strongswan.android.data.VpnProfileSource;
|
||||
import org.strongswan.android.data.VpnProfile;
|
||||
import org.strongswan.android.logic.autoconnect.AutoConnectManager;
|
||||
import org.strongswan.android.logic.connectivity.WhiteListManager;
|
||||
import org.strongswan.android.utils.Constants;
|
||||
|
||||
/**
|
||||
* Restores the VPN connection after the hosting process died. The process
|
||||
* hosting CharonVpnService dies completely on a native crash of the daemon,
|
||||
* and the service is deliberately NOT_STICKY, so without this nothing would
|
||||
* bring the tunnel back.
|
||||
*
|
||||
* Energy design: the maintenance alarm is a NON-WAKEUP exact alarm, so it
|
||||
* never wakes the device; it fires only while the device is awake anyway
|
||||
* (Doze defers it until then). A second WAKEUP safety-net alarm bounds the
|
||||
* recovery time while the device sleeps (e.g. the process was evicted on a
|
||||
* trusted Wi-Fi overnight). Once a state proves steady - the tunnel parked
|
||||
* on a trusted network, or a healthy established connection - both alarms
|
||||
* back off (5 min maintenance, 30 min/1 h safety net), because in those
|
||||
* states a live process recovers via its own callbacks within seconds and
|
||||
* the alarms only guard a dead one. The checks themselves are a couple of
|
||||
* memory and preference reads - no network probes, no wake locks, no
|
||||
* periodic work while the connection is healthy. A crash is therefore
|
||||
* repaired within seconds of the next time the device is used.
|
||||
*
|
||||
* Trusted-networks auto sessions: while parked on a trusted Wi-Fi the VPN is
|
||||
* expected to be DOWN (the auto session is "expected" in the sense that the
|
||||
* tunnel must come back when the network changes). If the process died
|
||||
* while parked, auto-connect died with it, so on cellular this receiver
|
||||
* restarts the tunnel using the auto-connect profile; on Wi-Fi the decision
|
||||
* belongs to auto-connect (which starts with the fresh process and knows
|
||||
* about SSIDs), so we stay out of its way.
|
||||
*/
|
||||
public class WatchdogReceiver extends BroadcastReceiver
|
||||
{
|
||||
private static final String TAG = WatchdogReceiver.class.getSimpleName();
|
||||
private static final int REQUEST_CODE = 43;
|
||||
private static final int WAKEUP_REQUEST_CODE = 44;
|
||||
private static final long WATCHDOG_INTERVAL_MS = 60000;
|
||||
/* wakeup safety net; allow-while-idle alarms are throttled to a 9-minute
|
||||
* minimum interval in Doze, so 10 minutes is effectively exact enough */
|
||||
private static final long WATCHDOG_WAKEUP_INTERVAL_MS = 600000;
|
||||
/* after this many consecutive checks that find the tunnel parked on a
|
||||
* trusted network, stretch the maintenance interval: while parked there
|
||||
* is nothing to do (auto-connect owns the return via its callbacks) and
|
||||
* the 60 s cadence only burns battery and floods diag.log overnight.
|
||||
* The wakeup safety net is stretched to 30 minutes as well: a live
|
||||
* process reacts to network changes within seconds, so it only guards
|
||||
* the rare "process died, then the user left the trusted Wi-Fi" corner.
|
||||
* Static on purpose: a parked tunnel implies a live process, so the
|
||||
* counter survives; a dead process starts fresh with the short interval. */
|
||||
private static final int PARKED_BACKOFF_AFTER = 5;
|
||||
private static final long WATCHDOG_PARKED_INTERVAL_MS = 5 * 60000;
|
||||
private static final long WATCHDOG_PARKED_WAKEUP_INTERVAL_MS = 30 * 60000;
|
||||
/* steady state of an established connection: charon's own DPD/rekey and
|
||||
* the in-process retry machinery own recovery, so the alarms only guard
|
||||
* a dead process - repaired within seconds of the device being used.
|
||||
* The wakeup alarm in Doze is stretched to an hour (from 10 minutes),
|
||||
* cutting the overnight CPU wakeups while the tunnel is healthy. */
|
||||
private static final int CONNECTED_BACKOFF_AFTER = 5;
|
||||
private static final long WATCHDOG_CONNECTED_INTERVAL_MS = 5 * 60000;
|
||||
private static final long WATCHDOG_CONNECTED_WAKEUP_INTERVAL_MS = 60 * 60000;
|
||||
private static volatile int sParkedChecks;
|
||||
private static volatile int sConnectedChecks;
|
||||
/** cadence announced in diag.log last ("parked"/"connected"/null), so a
|
||||
* steady state is logged once on entry instead of on every fire */
|
||||
private static volatile String sLastCadence;
|
||||
|
||||
/** What onReceive decided to do. */
|
||||
public enum Action
|
||||
{
|
||||
/** nothing expected / feature off / nothing to do */
|
||||
NONE,
|
||||
/** a connection is expected but the process is gone - restart it */
|
||||
RESTART,
|
||||
/** an auto session is expected, but Wi-Fi is up - auto-connect owns the decision */
|
||||
SKIP_WIFI,
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure decision logic, unit-testable.
|
||||
*
|
||||
* @param featureEnabled the watchdog feature is enabled
|
||||
* @param expectedProfileSet an explicit connection is expected (UUID stored)
|
||||
* @param autoExpected an auto session is expected to return when the network allows it
|
||||
* @param whitelistBlocks the ISP whitelist currently blocks the server
|
||||
* @param serviceTracking the live VpnStateService tracks a connection (state != DISABLED)
|
||||
* @param processAlive the hosting process is alive (live service instance found)
|
||||
* @param wifiPresent any Wi-Fi network is currently available
|
||||
* @param autoConnectProfileConfigured auto-connect has a profile configured
|
||||
*/
|
||||
static Action decideAction(boolean featureEnabled, boolean expectedProfileSet, boolean autoExpected,
|
||||
boolean whitelistBlocks, boolean serviceTracking, boolean processAlive,
|
||||
boolean wifiPresent, boolean autoConnectProfileConfigured)
|
||||
{
|
||||
if (!featureEnabled || (!expectedProfileSet && !autoExpected))
|
||||
{
|
||||
return Action.NONE;
|
||||
}
|
||||
if (whitelistBlocks)
|
||||
{ /* the ISP whitelist parks the in-process retry machinery too */
|
||||
return Action.NONE;
|
||||
}
|
||||
if (serviceTracking)
|
||||
{ /* the process is alive and the service is actively tracking a
|
||||
* connection; its own retry and timeout logic handles recovery */
|
||||
return Action.NONE;
|
||||
}
|
||||
if (!expectedProfileSet && processAlive)
|
||||
{ /* an auto session is expected but only parked (process alive);
|
||||
* auto-connect is running and reconnects via its own callbacks */
|
||||
return Action.NONE;
|
||||
}
|
||||
if (autoExpected && wifiPresent && autoConnectProfileConfigured)
|
||||
{ /* the auto session was parked on a Wi-Fi; whether the tunnel
|
||||
* belongs up here is auto-connect's call (SSID-based), and it
|
||||
* starts with any fresh process */
|
||||
return Action.SKIP_WIFI;
|
||||
}
|
||||
return Action.RESTART;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a check counts towards the steady-state backoff of a live
|
||||
* process: connected, retrying/connecting, or held back by the ISP
|
||||
* whitelist. In all of these the in-process machinery owns recovery and
|
||||
* the alarm can only guard against the process dying - firing every
|
||||
* minute (and waking Doze every 10 minutes) for hours of failed retries
|
||||
* or whitelist blocking only burns battery.
|
||||
*/
|
||||
static boolean isAliveSteady(Action action, boolean processAlive, boolean parked)
|
||||
{
|
||||
return action == Action.NONE && processAlive && !parked;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onReceive(Context context, Intent intent)
|
||||
{
|
||||
context = context.getApplicationContext();
|
||||
SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context);
|
||||
String uuid = prefs.getString(Constants.PREF_WATCHDOG_EXPECTED_PROFILE, null);
|
||||
boolean autoExpected = prefs.getBoolean(Constants.PREF_WATCHDOG_AUTO_EXPECTED, false);
|
||||
boolean enabled = prefs.getBoolean(Constants.PREF_WATCHDOG_ENABLED, true);
|
||||
if (!enabled || (uuid == null && !autoExpected))
|
||||
{ /* nothing is expected to be connected (or the feature is off);
|
||||
* schedule() re-arms the alarms when that changes */
|
||||
return;
|
||||
}
|
||||
reschedule(context);
|
||||
/* the ISP whitelist blocks the server; the in-process retry
|
||||
* machinery parks itself in that case, so should we */
|
||||
boolean whitelistBlocks = WhiteListManager.getInstance(context).shouldBlockReconnect();
|
||||
VpnStateService service = VpnStateService.getLiveInstance();
|
||||
boolean processAlive = service != null;
|
||||
boolean serviceTracking = processAlive && service.getState() != VpnStateService.State.DISABLED;
|
||||
boolean wifiPresent = isWifiPresent(context);
|
||||
boolean autoProfileConfigured = AutoConnectManager.getInstance(context).getCurrentConfig().getProfileUuid() != null;
|
||||
Action action = decideAction(enabled, uuid != null, autoExpected, whitelistBlocks, serviceTracking,
|
||||
processAlive, wifiPresent, autoProfileConfigured);
|
||||
boolean parked = uuid == null && autoExpected && processAlive && !serviceTracking && action == Action.NONE;
|
||||
boolean alive = isAliveSteady(action, processAlive, parked);
|
||||
if (parked)
|
||||
{
|
||||
sParkedChecks++;
|
||||
sConnectedChecks = 0;
|
||||
}
|
||||
else if (alive)
|
||||
{
|
||||
sConnectedChecks++;
|
||||
sParkedChecks = 0;
|
||||
}
|
||||
else
|
||||
{ /* any other outcome is interesting - go back to the fast cadence */
|
||||
sParkedChecks = 0;
|
||||
sConnectedChecks = 0;
|
||||
}
|
||||
boolean parkedSteady = parked && sParkedChecks >= PARKED_BACKOFF_AFTER;
|
||||
boolean connectedSteady = alive && sConnectedChecks >= CONNECTED_BACKOFF_AFTER;
|
||||
String cadence = parkedSteady ? "parked" : connectedSteady ? "alive" : null;
|
||||
if (cadence != null)
|
||||
{ /* steady state: log the transition once, then stay silent - a
|
||||
* per-fire line only floods diag.log and burns flash I/O */
|
||||
if (!cadence.equals(sLastCadence))
|
||||
{
|
||||
DiagLog.i(TAG, "steady state (" + cadence + "), backing off checks to " +
|
||||
(parkedSteady ? "5m/30m" : "5m/60m"));
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
DiagLog.i(TAG, "check: uuid=" + (uuid != null) + " autoExpected=" + autoExpected +
|
||||
" processAlive=" + processAlive + " tracking=" + serviceTracking +
|
||||
" whitelist=" + whitelistBlocks + " wifi=" + wifiPresent + " -> " + action);
|
||||
}
|
||||
sLastCadence = cadence;
|
||||
switch (action)
|
||||
{
|
||||
case NONE:
|
||||
case SKIP_WIFI:
|
||||
return;
|
||||
case RESTART:
|
||||
{
|
||||
if (uuid == null)
|
||||
{
|
||||
uuid = resolveAutoProfile(context, prefs);
|
||||
if (uuid == null)
|
||||
{
|
||||
DiagLog.i(TAG, "auto session expected but no profile configured, not restarting");
|
||||
return;
|
||||
}
|
||||
}
|
||||
/* a profile that signs in with an unsaved password would only end
|
||||
* in «password missing» after every restore: drop the expectation
|
||||
* and say so instead */
|
||||
VpnProfile expected = loadProfile(context, uuid);
|
||||
if (expected != null && org.strongswan.android.logic.profiles.ProfileExtrasKt.needsPassword(expected))
|
||||
{
|
||||
DiagLog.i(TAG, "expected profile needs a password, not restoring");
|
||||
prefs.edit().remove(Constants.PREF_WATCHDOG_EXPECTED_PROFILE)
|
||||
.remove(Constants.PREF_WATCHDOG_AUTO_EXPECTED).apply();
|
||||
org.strongswan.android.logic.events.Events.restoreNeedsPassword(context, uuid,
|
||||
expected.getName() != null ? expected.getName() : expected.getGateway());
|
||||
return;
|
||||
}
|
||||
/* either the process died (the receiver runs in a fresh one) or the
|
||||
* service is idle while a connection is expected - restart it. In
|
||||
* the rare second case this may duplicate a pending in-process retry
|
||||
* once, which is harmless (retry flag set, same profile). */
|
||||
Log.i(TAG, "A connection was expected but the VPN process is gone, restarting it");
|
||||
DiagLog.i(TAG, "restarting the VPN process");
|
||||
Intent start = new Intent(context, CharonVpnService.class);
|
||||
start.putExtra(VpnProfileDataSource.KEY_UUID, uuid);
|
||||
start.putExtra(CharonVpnService.KEY_IS_RETRY, true);
|
||||
try
|
||||
{
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O)
|
||||
{
|
||||
context.startForegroundService(start);
|
||||
}
|
||||
else
|
||||
{
|
||||
context.startService(start);
|
||||
}
|
||||
}
|
||||
catch (IllegalStateException e)
|
||||
{ /* background start blocked (should not happen from an exact alarm,
|
||||
* but stay on the safe side) */
|
||||
DiagLog.i(TAG, "FGS start blocked, falling back to FgsStartRetry");
|
||||
FgsStartRetry.schedule(context, start);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static VpnProfile loadProfile(Context context, String uuid)
|
||||
{
|
||||
VpnProfileSource source = new VpnProfileSource(context);
|
||||
try
|
||||
{
|
||||
source.open();
|
||||
return source.getVpnProfile(java.util.UUID.fromString(uuid));
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
finally
|
||||
{
|
||||
source.close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Profile the trusted-networks auto session should use: the configured
|
||||
* auto-connect profile, falling back to the most recently used one.
|
||||
*/
|
||||
private static String resolveAutoProfile(Context context, SharedPreferences prefs)
|
||||
{
|
||||
String uuid = AutoConnectManager.getInstance(context).getCurrentConfig().getProfileUuid();
|
||||
if (uuid == null)
|
||||
{
|
||||
uuid = prefs.getString(Constants.PREF_MRU_VPN_PROFILE, null);
|
||||
}
|
||||
return uuid;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether any Wi-Fi network is currently available. A cheap transport
|
||||
* check - deliberately no SSID lookup (slow and needs location).
|
||||
*/
|
||||
private static boolean isWifiPresent(Context context)
|
||||
{
|
||||
ConnectivityManager cm = (ConnectivityManager)context.getSystemService(Context.CONNECTIVITY_SERVICE);
|
||||
if (cm == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
try
|
||||
{
|
||||
for (Network network : cm.getAllNetworks())
|
||||
{
|
||||
NetworkCapabilities caps = cm.getNetworkCapabilities(network);
|
||||
if (caps != null && caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) &&
|
||||
!caps.hasTransport(NetworkCapabilities.TRANSPORT_VPN))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
e.printStackTrace();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Arm the next maintenance check after a state transition (connect,
|
||||
* park, settings toggle). Resets the steady-state counters so the
|
||||
* checks return to the fast cadence until the new state proves itself
|
||||
* stable again, then delegates to reschedule().
|
||||
*/
|
||||
public static void schedule(Context context)
|
||||
{
|
||||
sParkedChecks = 0;
|
||||
sConnectedChecks = 0;
|
||||
sLastCadence = null;
|
||||
reschedule(context);
|
||||
}
|
||||
|
||||
/**
|
||||
* Arm the next maintenance check with the cadence the steady-state
|
||||
* counters currently ask for. A one-shot non-wakeup alarm (fires only
|
||||
* while the device is awake) plus a one-shot wakeup safety net that
|
||||
* bounds the recovery time while the device sleeps. No-op when the
|
||||
* feature is disabled or no connection is expected.
|
||||
*/
|
||||
private static void reschedule(Context context)
|
||||
{
|
||||
context = context.getApplicationContext();
|
||||
SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context);
|
||||
boolean autoExpected = prefs.getBoolean(Constants.PREF_WATCHDOG_AUTO_EXPECTED, false);
|
||||
if (prefs.getString(Constants.PREF_WATCHDOG_EXPECTED_PROFILE, null) == null && !autoExpected ||
|
||||
!prefs.getBoolean(Constants.PREF_WATCHDOG_ENABLED, true))
|
||||
{
|
||||
return;
|
||||
}
|
||||
AlarmManager am = (AlarmManager)context.getSystemService(Context.ALARM_SERVICE);
|
||||
if (am == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
boolean parkedBackoff = sParkedChecks >= PARKED_BACKOFF_AFTER;
|
||||
boolean connectedBackoff = sConnectedChecks >= CONNECTED_BACKOFF_AFTER;
|
||||
long interval = parkedBackoff ? WATCHDOG_PARKED_INTERVAL_MS
|
||||
: connectedBackoff ? WATCHDOG_CONNECTED_INTERVAL_MS : WATCHDOG_INTERVAL_MS;
|
||||
long wakeupInterval = parkedBackoff ? WATCHDOG_PARKED_WAKEUP_INTERVAL_MS
|
||||
: connectedBackoff ? WATCHDOG_CONNECTED_WAKEUP_INTERVAL_MS : WATCHDOG_WAKEUP_INTERVAL_MS;
|
||||
arm(am, pendingIntent(context, REQUEST_CODE), SystemClock.elapsedRealtime() + interval, false);
|
||||
arm(am, pendingIntent(context, WAKEUP_REQUEST_CODE), SystemClock.elapsedRealtime() + wakeupInterval, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a one-shot alarm. Wakeup alarms use allow-while-idle variants so
|
||||
* they also fire in Doze (throttled to a 9-minute minimum there); regular
|
||||
* ones use exact alarms that never wake the device.
|
||||
*/
|
||||
private static void arm(AlarmManager am, PendingIntent pi, long when, boolean wakeup)
|
||||
{
|
||||
try
|
||||
{
|
||||
boolean exact = Build.VERSION.SDK_INT < Build.VERSION_CODES.S || am.canScheduleExactAlarms();
|
||||
if (wakeup)
|
||||
{
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M)
|
||||
{
|
||||
if (exact)
|
||||
{
|
||||
am.setExactAndAllowWhileIdle(AlarmManager.ELAPSED_REALTIME_WAKEUP, when, pi);
|
||||
}
|
||||
else
|
||||
{
|
||||
am.setAndAllowWhileIdle(AlarmManager.ELAPSED_REALTIME_WAKEUP, when, pi);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
am.setExact(AlarmManager.ELAPSED_REALTIME_WAKEUP, when, pi);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
if (exact)
|
||||
{
|
||||
am.setExact(AlarmManager.ELAPSED_REALTIME, when, pi);
|
||||
}
|
||||
else
|
||||
{
|
||||
am.set(AlarmManager.ELAPSED_REALTIME, when, pi);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (SecurityException e)
|
||||
{ /* the exact-alarm grant was revoked in between; inexact is fine */
|
||||
if (wakeup && Build.VERSION.SDK_INT >= Build.VERSION_CODES.M)
|
||||
{
|
||||
am.setAndAllowWhileIdle(AlarmManager.ELAPSED_REALTIME_WAKEUP, when, pi);
|
||||
}
|
||||
else if (!wakeup)
|
||||
{
|
||||
am.set(AlarmManager.ELAPSED_REALTIME, when, pi);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the maintenance alarms (explicit disconnect).
|
||||
*/
|
||||
public static void cancel(Context context)
|
||||
{
|
||||
context = context.getApplicationContext();
|
||||
AlarmManager am = (AlarmManager)context.getSystemService(Context.ALARM_SERVICE);
|
||||
if (am == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
am.cancel(pendingIntent(context, REQUEST_CODE));
|
||||
am.cancel(pendingIntent(context, WAKEUP_REQUEST_CODE));
|
||||
}
|
||||
|
||||
private static PendingIntent pendingIntent(Context context, int requestCode)
|
||||
{
|
||||
return PendingIntent.getBroadcast(context, requestCode,
|
||||
new Intent(context, WatchdogReceiver.class),
|
||||
PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE);
|
||||
}
|
||||
}
|
||||
+109
-9
@@ -24,16 +24,25 @@ import android.os.Bundle
|
||||
import android.os.Handler
|
||||
import android.os.IBinder
|
||||
import android.os.Looper
|
||||
import android.os.SystemClock
|
||||
import android.util.Log
|
||||
import androidx.lifecycle.DefaultLifecycleObserver
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import androidx.lifecycle.ProcessLifecycleOwner
|
||||
import androidx.preference.PreferenceManager
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONException
|
||||
import org.json.JSONObject
|
||||
import org.strongswan.android.data.VpnProfileDataSource
|
||||
import org.strongswan.android.logic.CharonVpnService
|
||||
import org.strongswan.android.logic.DiagLog
|
||||
import org.strongswan.android.logic.PauseManager
|
||||
import org.strongswan.android.logic.events.Events
|
||||
import org.strongswan.android.logic.VpnStateService
|
||||
import org.strongswan.android.logic.WatchdogReceiver
|
||||
import org.strongswan.android.logic.connectivity.WhiteListManager
|
||||
import org.strongswan.android.ui.VpnProfileControlActivity
|
||||
import org.strongswan.android.utils.Constants
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
|
||||
/**
|
||||
@@ -51,6 +60,10 @@ class AutoConnectManager private constructor(
|
||||
val enabled: Boolean = false,
|
||||
val profileUuid: String? = null,
|
||||
val trustedSsids: Set<String> = emptySet(),
|
||||
/** F_Automation «In a trusted Wi-Fi → turn the VPN off» */
|
||||
val parkOnTrusted: Boolean = true,
|
||||
/** F_Automation «Mobile network or a foreign Wi-Fi → turn the VPN on» */
|
||||
val connectOnUntrusted: Boolean = true,
|
||||
)
|
||||
|
||||
enum class NetworkKind
|
||||
@@ -78,6 +91,8 @@ class AutoConnectManager private constructor(
|
||||
private var currentNetwork: NetworkKind? = null
|
||||
/* marks the session this manager started itself */
|
||||
private val autoSession = AtomicBoolean(false)
|
||||
/* elapsedRealtime of the last park caused by a trusted Wi-Fi (flap guard) */
|
||||
private var lastParkAt = 0L
|
||||
|
||||
private val monitor = NetworkMonitor(context, { config.trustedSsids }, ::onNetworkChanged)
|
||||
private val lifecycleBound = AtomicBoolean(false)
|
||||
@@ -136,6 +151,9 @@ class AutoConnectManager private constructor(
|
||||
return
|
||||
}
|
||||
handler.removeCallbacksAndMessages(null)
|
||||
/* the runnable that clears this flag was just removed with the
|
||||
* callbacks: clear it here or every later connect is swallowed */
|
||||
pendingEvaluate.set(false)
|
||||
service?.unregisterListener(this)
|
||||
try
|
||||
{
|
||||
@@ -145,6 +163,18 @@ class AutoConnectManager private constructor(
|
||||
{
|
||||
}
|
||||
service = null
|
||||
/* the feature no longer manages sessions: drop the auto-expected marker
|
||||
* and keep the watchdog armed only if an explicit connection is expected */
|
||||
val defaultPrefs = PreferenceManager.getDefaultSharedPreferences(context)
|
||||
defaultPrefs.edit().putBoolean(Constants.PREF_WATCHDOG_AUTO_EXPECTED, false).apply()
|
||||
if (defaultPrefs.getString(Constants.PREF_WATCHDOG_EXPECTED_PROFILE, null) == null)
|
||||
{
|
||||
WatchdogReceiver.cancel(context)
|
||||
}
|
||||
else
|
||||
{
|
||||
WatchdogReceiver.schedule(context)
|
||||
}
|
||||
}
|
||||
|
||||
fun reloadConfig()
|
||||
@@ -200,6 +230,7 @@ class AutoConnectManager private constructor(
|
||||
}
|
||||
currentNetwork = kind
|
||||
evaluate()
|
||||
org.strongswan.android.logic.scenarios.ScenarioEngine.evaluate(context)
|
||||
}
|
||||
|
||||
override fun stateChanged()
|
||||
@@ -214,8 +245,26 @@ class AutoConnectManager private constructor(
|
||||
/**
|
||||
* Pure decision function, unit-testable.
|
||||
*/
|
||||
private fun evaluate(config: Config, network: NetworkKind?, vpnActive: Boolean, autoSession: Boolean): Action =
|
||||
evaluateDecision(config, network, vpnActive, autoSession)
|
||||
private fun evaluate(config: Config, network: NetworkKind?, vpnActive: Boolean, autoSession: Boolean): Action
|
||||
{
|
||||
val action = evaluateDecision(
|
||||
config, network, vpnActive, autoSession,
|
||||
whiteListBlocksReconnect = WhiteListManager.getInstance(context).shouldBlockReconnect(),
|
||||
)
|
||||
/* the user paused the VPN: the pause alarm brings it back, not a network change */
|
||||
if (action == Action.CONNECT && PauseManager.isPaused(context))
|
||||
{
|
||||
return Action.IGNORE
|
||||
}
|
||||
/* a user scenario above the network rules is in force */
|
||||
return if (action != Action.IGNORE && org.strongswan.android.logic.scenarios.ScenarioEngine.inControl(context)) Action.IGNORE else action
|
||||
}
|
||||
|
||||
/** Re-runs the connect decision, e.g. when the white-list gate clears. */
|
||||
fun reevaluate()
|
||||
{
|
||||
evaluate()
|
||||
}
|
||||
|
||||
private fun evaluate()
|
||||
{
|
||||
@@ -232,8 +281,16 @@ class AutoConnectManager private constructor(
|
||||
Action.DISCONNECT ->
|
||||
{
|
||||
Log.i(TAG, "Trusted network detected, disconnecting")
|
||||
DiagLog.i(TAG, "trusted network detected, parking the tunnel")
|
||||
autoSession.set(false)
|
||||
svc.disconnect()
|
||||
lastParkAt = SystemClock.elapsedRealtime()
|
||||
/* mark the auto session as expected so the watchdog can restore
|
||||
* it if the process dies while parked and the device later
|
||||
* moves to cellular (auto-connect dies with the process) */
|
||||
PreferenceManager.getDefaultSharedPreferences(context).edit()
|
||||
.putBoolean(Constants.PREF_WATCHDOG_AUTO_EXPECTED, true).apply()
|
||||
svc.disconnect(true)
|
||||
Events.autoConnect(context, parked = true)
|
||||
}
|
||||
Action.IGNORE ->
|
||||
{
|
||||
@@ -248,6 +305,21 @@ class AutoConnectManager private constructor(
|
||||
{
|
||||
return
|
||||
}
|
||||
/* Wi-Fi at the edge of range flaps with a ~10-15 s period: a park
|
||||
* followed by a quick "untrusted" flip - or a network kind that keeps
|
||||
* bouncing - must not bounce the tunnel through full reconnects (one
|
||||
* such flap produced a 7-second-lived session and a wasted IKE
|
||||
* handshake). Require a longer stable-untrusted window before
|
||||
* starting (the runnable re-evaluates and quietly gives up if a
|
||||
* trusted Wi-Fi returned in between). */
|
||||
val delay = if (SystemClock.elapsedRealtime() - lastParkAt < RECENT_PARK_WINDOW_MS || monitor.isFlapping())
|
||||
{
|
||||
FLAP_CONNECT_DELAY_MS
|
||||
}
|
||||
else
|
||||
{
|
||||
CONNECT_DELAY_MS
|
||||
}
|
||||
handler.postDelayed({
|
||||
pendingEvaluate.set(false)
|
||||
val svc = service
|
||||
@@ -263,7 +335,7 @@ class AutoConnectManager private constructor(
|
||||
startVpn(svc, uuid)
|
||||
}
|
||||
}
|
||||
}, CONNECT_DELAY_MS)
|
||||
}, delay)
|
||||
}
|
||||
|
||||
private fun startVpn(svc: VpnStateService, uuid: String)
|
||||
@@ -277,11 +349,26 @@ class AutoConnectManager private constructor(
|
||||
Log.e(TAG, "VpnService.prepare failed", e)
|
||||
true
|
||||
}
|
||||
val livePassword = svc.profile?.takeIf { it.getUUID().toString() == uuid }?.password
|
||||
if (!needsConsent && livePassword == null && org.strongswan.android.logic.profiles.blockedByPassword(context, uuid))
|
||||
{ /* nobody is there to type the password */
|
||||
autoSession.set(false)
|
||||
return
|
||||
}
|
||||
if (!needsConsent)
|
||||
{
|
||||
DiagLog.i(TAG, "auto session starting, profile=$uuid")
|
||||
/* a real connection is expected again; connect() re-arms the
|
||||
* watchdog with the profile, so the auto-parked marker goes away */
|
||||
PreferenceManager.getDefaultSharedPreferences(context).edit()
|
||||
.putBoolean(Constants.PREF_WATCHDOG_AUTO_EXPECTED, false).apply()
|
||||
val extras = Bundle()
|
||||
extras.putString(VpnProfileDataSource.KEY_UUID, uuid)
|
||||
livePassword?.let { extras.putString(VpnProfileDataSource.KEY_PASSWORD, it) }
|
||||
/* automatic start: keep the previous session's charon log */
|
||||
extras.putBoolean(CharonVpnService.KEY_PRESERVE_LOG, true)
|
||||
svc.connect(extras, true)
|
||||
Events.autoConnect(context, parked = false)
|
||||
return
|
||||
}
|
||||
val intent = Intent(context, VpnProfileControlActivity::class.java)
|
||||
@@ -313,6 +400,8 @@ class AutoConnectManager private constructor(
|
||||
arr.optString(i)?.takeIf { it.isNotBlank() }
|
||||
}.toSet()
|
||||
} ?: emptySet(),
|
||||
parkOnTrusted = obj.optBoolean("parkOnTrusted", true),
|
||||
connectOnUntrusted = obj.optBoolean("connectOnUntrusted", true),
|
||||
)
|
||||
}
|
||||
catch (_: JSONException)
|
||||
@@ -327,6 +416,8 @@ class AutoConnectManager private constructor(
|
||||
obj.put("enabled", config.enabled)
|
||||
obj.put("profileUuid", config.profileUuid ?: JSONObject.NULL)
|
||||
obj.put("trustedSsids", JSONArray(config.trustedSsids.sorted()))
|
||||
obj.put("parkOnTrusted", config.parkOnTrusted)
|
||||
obj.put("connectOnUntrusted", config.connectOnUntrusted)
|
||||
prefs.edit().putString(KEY_CONFIG, obj.toString()).apply()
|
||||
}
|
||||
|
||||
@@ -354,16 +445,23 @@ class AutoConnectManager private constructor(
|
||||
private const val PREFS_NAME = "one.shx.strongswan.ext.AUTO_CONNECT"
|
||||
private const val KEY_CONFIG = "config"
|
||||
private const val CONNECT_DELAY_MS = 3000L
|
||||
/* anti-flap: a start right after a trusted-Wi-Fi park waits longer
|
||||
* (the network must stay cellular through the whole window) */
|
||||
private const val FLAP_CONNECT_DELAY_MS = 10_000L
|
||||
private const val RECENT_PARK_WINDOW_MS = 120_000L
|
||||
|
||||
/**
|
||||
* Pure decision function: trusted Wi-Fi means VPN down, everything else
|
||||
* means VPN up.
|
||||
* Pure decision function, unit-testable. Trusted Wi-Fi parks only
|
||||
* sessions that the automation started itself; a connection the user
|
||||
* started manually stays up until the user disconnects it, regardless
|
||||
* of the network.
|
||||
*/
|
||||
fun evaluateDecision(
|
||||
config: Config,
|
||||
network: NetworkKind?,
|
||||
vpnActive: Boolean,
|
||||
autoSession: Boolean,
|
||||
whiteListBlocksReconnect: Boolean = false,
|
||||
): Action
|
||||
{
|
||||
if (!config.enabled || config.profileUuid == null)
|
||||
@@ -373,12 +471,14 @@ class AutoConnectManager private constructor(
|
||||
return when (network)
|
||||
{
|
||||
null -> Action.IGNORE
|
||||
NetworkKind.TRUSTED_WIFI -> if (vpnActive) Action.DISCONNECT else Action.IGNORE
|
||||
NetworkKind.TRUSTED_WIFI ->
|
||||
if (config.parkOnTrusted && vpnActive && autoSession) Action.DISCONNECT else Action.IGNORE
|
||||
/* SSID is redacted in background until location is readable. Keep an
|
||||
* existing tunnel (do not treat unknown as untrusted) and still
|
||||
* auto-start if the VPN is down. */
|
||||
NetworkKind.UNKNOWN_WIFI -> if (!vpnActive) Action.CONNECT else Action.IGNORE
|
||||
NetworkKind.UNTRUSTED_WIFI, NetworkKind.CELLULAR -> if (!vpnActive) Action.CONNECT else Action.IGNORE
|
||||
NetworkKind.UNKNOWN_WIFI -> if (config.connectOnUntrusted && !vpnActive && !whiteListBlocksReconnect) Action.CONNECT else Action.IGNORE
|
||||
NetworkKind.UNTRUSTED_WIFI, NetworkKind.CELLULAR ->
|
||||
if (config.connectOnUntrusted && !vpnActive && !whiteListBlocksReconnect) Action.CONNECT else Action.IGNORE
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+73
-5
@@ -23,7 +23,9 @@ import android.net.wifi.WifiInfo
|
||||
import android.os.Build
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.os.SystemClock
|
||||
import android.util.Log
|
||||
import org.strongswan.android.logic.DiagLog
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
/**
|
||||
@@ -50,6 +52,8 @@ class NetworkMonitor(
|
||||
private var registered = false
|
||||
private var lastReported: AutoConnectManager.NetworkKind? = null
|
||||
private var ssidRetries = 0
|
||||
/* rolling log of network-kind change times, for flap detection (main thread only) */
|
||||
private val kindChangeTimes = ArrayDeque<Long>()
|
||||
|
||||
private val events = object
|
||||
{
|
||||
@@ -73,12 +77,12 @@ class NetworkMonitor(
|
||||
{
|
||||
networks.remove(network)
|
||||
ssids.remove(network)
|
||||
scheduleEvaluate()
|
||||
}
|
||||
else
|
||||
else if (remember(network, capabilities))
|
||||
{
|
||||
remember(network, capabilities)
|
||||
scheduleEvaluate()
|
||||
}
|
||||
scheduleEvaluate()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -133,6 +137,7 @@ class NetworkMonitor(
|
||||
ssids.clear()
|
||||
lastReported = null
|
||||
ssidRetries = 0
|
||||
kindChangeTimes.clear()
|
||||
}
|
||||
|
||||
fun forceEvaluate()
|
||||
@@ -189,12 +194,39 @@ class NetworkMonitor(
|
||||
return null
|
||||
}
|
||||
|
||||
private fun remember(network: Network, caps: NetworkCapabilities)
|
||||
/**
|
||||
* Store the latest state of a network. Returns whether anything the
|
||||
* trust decision depends on changed: capability callbacks fire on every
|
||||
* signal-strength/validation tick, and re-evaluating on those only burns
|
||||
* battery - the debounced runnable wakes the main thread and rescans all
|
||||
* networks for no result. Only a transport change or a new SSID counts.
|
||||
*/
|
||||
private fun remember(network: Network, caps: NetworkCapabilities): Boolean
|
||||
{
|
||||
val previous = networks[network]
|
||||
val oldSsid = ssids[network]
|
||||
val ssid = SsidReader.fromWifiInfo(caps.transportInfo as? WifiInfo)
|
||||
networks[network] = caps
|
||||
SsidReader.fromWifiInfo(caps.transportInfo as? WifiInfo)?.let { ssids[network] = it }
|
||||
if (ssid != null)
|
||||
{
|
||||
ssids[network] = ssid
|
||||
}
|
||||
if (previous == null)
|
||||
{
|
||||
return true
|
||||
}
|
||||
if (!sameTransports(previous, caps))
|
||||
{
|
||||
return true
|
||||
}
|
||||
return ssid != null && ssid != oldSsid
|
||||
}
|
||||
|
||||
private fun sameTransports(a: NetworkCapabilities, b: NetworkCapabilities): Boolean =
|
||||
a.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) == b.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) &&
|
||||
a.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) == b.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) &&
|
||||
a.hasTransport(NetworkCapabilities.TRANSPORT_VPN) == b.hasTransport(NetworkCapabilities.TRANSPORT_VPN)
|
||||
|
||||
private fun ssidFor(network: Network, caps: NetworkCapabilities): String?
|
||||
{
|
||||
ssids[network]?.let { return it }
|
||||
@@ -275,11 +307,44 @@ class NetworkMonitor(
|
||||
if (kind != lastReported)
|
||||
{
|
||||
Log.i(TAG, "Network kind $lastReported -> $kind")
|
||||
DiagLog.i(TAG, "network kind $lastReported -> $kind")
|
||||
lastReported = kind
|
||||
recordKindChange(SystemClock.elapsedRealtime())
|
||||
onNetworkChanged(kind)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remember a kind change for [isFlapping], dropping entries that fell out
|
||||
* of the flap window (plus a hard cap so a pathological callback storm
|
||||
* cannot grow the log unbounded).
|
||||
*/
|
||||
private fun recordKindChange(now: Long)
|
||||
{
|
||||
kindChangeTimes.addLast(now)
|
||||
while (kindChangeTimes.size > KIND_CHANGE_LOG_MAX ||
|
||||
(kindChangeTimes.isNotEmpty() && now - kindChangeTimes.first() > FLAP_WINDOW_MS))
|
||||
{
|
||||
kindChangeTimes.removeFirst()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the network kind is bouncing: at least [FLAP_MIN_CHANGES] kind
|
||||
* changes (including the latest one) within [FLAP_WINDOW_MS]. Callers
|
||||
* stretch out auto-connect delays while the underlying network settles
|
||||
* instead of bouncing the tunnel through short-lived sessions.
|
||||
*/
|
||||
fun isFlapping(): Boolean
|
||||
{
|
||||
val now = SystemClock.elapsedRealtime()
|
||||
while (kindChangeTimes.isNotEmpty() && now - kindChangeTimes.first() > FLAP_WINDOW_MS)
|
||||
{
|
||||
kindChangeTimes.removeFirst()
|
||||
}
|
||||
return kindChangeTimes.size >= FLAP_MIN_CHANGES
|
||||
}
|
||||
|
||||
private val evaluateRunnable = Runnable { evaluateNow() }
|
||||
|
||||
companion object
|
||||
@@ -288,5 +353,8 @@ class NetworkMonitor(
|
||||
private const val DEBOUNCE_MS = 1500L
|
||||
private const val SSID_RETRY_MS = 400L
|
||||
private const val SSID_RETRY_MAX = 8
|
||||
private const val FLAP_WINDOW_MS = 5 * 60_000L
|
||||
private const val FLAP_MIN_CHANGES = 2
|
||||
private const val KIND_CHANGE_LOG_MAX = 16
|
||||
}
|
||||
}
|
||||
+227
@@ -0,0 +1,227 @@
|
||||
/*
|
||||
* Copyright (C) 2026 SHX VPN
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or modify it
|
||||
* under the terms of the GNU General Public License as published by the
|
||||
* Free Software Foundation; either version 2 of the License, or (at your
|
||||
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful, but
|
||||
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
||||
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
|
||||
* for more details.
|
||||
*/
|
||||
|
||||
package org.strongswan.android.logic.connectivity
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Network
|
||||
import android.os.Build
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.IOException
|
||||
import java.net.InetSocketAddress
|
||||
import java.net.Socket
|
||||
import java.net.URL
|
||||
import java.util.concurrent.TimeUnit
|
||||
import javax.net.ssl.HttpsURLConnection
|
||||
|
||||
/** What a probe measures. External targets go through the tunnel, SERVER goes direct. */
|
||||
enum class ProbeTarget
|
||||
{
|
||||
SERVER,
|
||||
GOOGLE,
|
||||
YOUTUBE,
|
||||
CLOUDFLARE,
|
||||
}
|
||||
|
||||
data class ProbeResult(
|
||||
val target: ProbeTarget,
|
||||
val ok: Boolean,
|
||||
val rttMs: Long?,
|
||||
val timestampMs: Long = System.currentTimeMillis(),
|
||||
/** true when the probe bypassed the tunnel (server ping). */
|
||||
val direct: Boolean = target == ProbeTarget.SERVER,
|
||||
)
|
||||
|
||||
/** Public exit (as seen by the internet on the other end of the tunnel). */
|
||||
data class ExitInfo(
|
||||
val ip: String,
|
||||
val country: String?,
|
||||
)
|
||||
|
||||
/**
|
||||
* TCP-connect "ping" for external targets and ICMP ping for the VPN server.
|
||||
*
|
||||
* External targets use raw IPs (8.8.8.8/1.1.1.1) to avoid DNS as a failure
|
||||
* source; youtube.com additionally exercises the tunnel's DNS.
|
||||
*/
|
||||
object ConnectivityProbe
|
||||
{
|
||||
private const val GOOGLE_HOST = "8.8.8.8"
|
||||
private const val GOOGLE_PORT = 53
|
||||
private const val CLOUDFLARE_HOST = "1.1.1.1"
|
||||
private const val CLOUDFLARE_PORT = 53
|
||||
private const val YOUTUBE_HOST = "youtube.com"
|
||||
private const val YOUTUBE_PORT = 443
|
||||
private const val PING_BINARY = "/system/bin/ping"
|
||||
|
||||
suspend fun probe(target: ProbeTarget, timeoutMs: Int, context: Context, gateway: String?): ProbeResult =
|
||||
when (target)
|
||||
{
|
||||
ProbeTarget.SERVER ->
|
||||
{
|
||||
val ping = pingServerDirect(context, gateway, timeoutMs)
|
||||
ProbeResult(target = target, ok = ping != null, rttMs = ping?.first)
|
||||
}
|
||||
ProbeTarget.GOOGLE -> tcpPing(ProbeTarget.GOOGLE, GOOGLE_HOST, GOOGLE_PORT, timeoutMs)
|
||||
ProbeTarget.CLOUDFLARE -> tcpPing(ProbeTarget.CLOUDFLARE, CLOUDFLARE_HOST, CLOUDFLARE_PORT, timeoutMs)
|
||||
ProbeTarget.YOUTUBE -> tcpPing(ProbeTarget.YOUTUBE, YOUTUBE_HOST, YOUTUBE_PORT, timeoutMs)
|
||||
}
|
||||
|
||||
/**
|
||||
* TCP connect timing, used as "ping". The socket is created on the default
|
||||
* network, which is the VPN tunnel while connected, so the RTT reflects
|
||||
* the tunnel path to the target.
|
||||
*/
|
||||
suspend fun tcpPing(target: ProbeTarget, host: String, port: Int, timeoutMs: Int, network: Network? = null): ProbeResult =
|
||||
withContext(Dispatchers.IO)
|
||||
{
|
||||
val started = System.nanoTime()
|
||||
try
|
||||
{
|
||||
/* the socket factory of the target network; createSocket() can
|
||||
* throw (e.g. EPERM when the caller is disallowed from that
|
||||
* network), so it must stay inside the try */
|
||||
val socket = if (network != null) network.socketFactory.createSocket() else Socket()
|
||||
/* close right away: a leaked socket holds an fd until GC and
|
||||
* the peer's later FIN/RST wakes the radio a second time */
|
||||
socket.use {
|
||||
it.tcpNoDelay = true
|
||||
it.connect(InetSocketAddress(host, port), timeoutMs)
|
||||
}
|
||||
ProbeResult(target = target, ok = true, rttMs = (System.nanoTime() - started) / 1_000_000)
|
||||
}
|
||||
catch (_: Exception)
|
||||
{
|
||||
ProbeResult(target = target, ok = false, rttMs = null, direct = network != null)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Direct ICMP ping to the VPN server via /system/bin/ping. The gateway is
|
||||
* resolved through the underlying (non-VPN) network so the tunnel's DNS is
|
||||
* never used, and the ICMP packet bypasses the TUN interface. Returns
|
||||
* (rttMs, resolvedIp) or null on failure.
|
||||
*/
|
||||
suspend fun pingServerDirect(context: Context, gateway: String?, timeoutMs: Int): Pair<Long, String>? =
|
||||
withContext(Dispatchers.IO)
|
||||
{
|
||||
val host = gateway?.trim().takeIf { !it.isNullOrBlank() } ?: return@withContext null
|
||||
val network = UnderlyingNetwork.resolve(context)
|
||||
val address = UnderlyingNetwork.resolveHost(network, host)?.firstOrNull()
|
||||
?: return@withContext null
|
||||
val ip = address.hostAddress ?: return@withContext null
|
||||
val seconds = ((timeoutMs + 999) / 1000).coerceAtLeast(1)
|
||||
val process = try
|
||||
{
|
||||
ProcessBuilder(PING_BINARY, "-c", "1", "-W", seconds.toString(), ip)
|
||||
.redirectErrorStream(true)
|
||||
.start()
|
||||
}
|
||||
catch (_: IOException)
|
||||
{
|
||||
return@withContext null
|
||||
}
|
||||
try
|
||||
{
|
||||
val output = process.inputStream.bufferedReader().readText()
|
||||
if (!waitForOrTimeout(process, timeoutMs + 1000L) || process.exitValue() != 0)
|
||||
{
|
||||
return@withContext null
|
||||
}
|
||||
parsePingRtt(output)?.let { it to ip }
|
||||
}
|
||||
finally
|
||||
{
|
||||
process.destroy()
|
||||
}
|
||||
}
|
||||
|
||||
/** Extracts "time=xx.x ms" from ping output. Visible for tests. */
|
||||
fun parsePingRtt(output: String): Long?
|
||||
{
|
||||
val match = Regex("time[=<]([0-9]+(?:\\.[0-9]+)?)\\s*ms", RegexOption.IGNORE_CASE).find(output) ?: return null
|
||||
return match.groupValues[1].toDoubleOrNull()?.toLong()
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the public exit IP (and country code) as seen from the other
|
||||
* end of the tunnel, via Cloudflare's cdn-cgi/trace. The plain
|
||||
* connection rides the VPN (the app's default network while connected),
|
||||
* so the answer reflects the tunnel egress. Returns null on any failure.
|
||||
*/
|
||||
suspend fun traceExit(timeoutMs: Int): ExitInfo? = withContext(Dispatchers.IO)
|
||||
{
|
||||
try
|
||||
{
|
||||
val connection = URL("https://www.cloudflare.com/cdn-cgi/trace").openConnection()
|
||||
as HttpsURLConnection
|
||||
try
|
||||
{
|
||||
connection.connectTimeout = timeoutMs
|
||||
connection.readTimeout = timeoutMs
|
||||
connection.instanceFollowRedirects = false
|
||||
val text = connection.inputStream.bufferedReader().use { it.readText() }
|
||||
parseTraceExit(text)
|
||||
}
|
||||
finally
|
||||
{
|
||||
connection.disconnect()
|
||||
}
|
||||
}
|
||||
catch (_: Exception)
|
||||
{
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/** Extracts ip= / loc= from a cdn-cgi/trace body. Visible for tests. */
|
||||
fun parseTraceExit(text: String): ExitInfo?
|
||||
{
|
||||
var ip: String? = null
|
||||
var loc: String? = null
|
||||
text.lineSequence().forEach { line ->
|
||||
val value = line.substringAfter('=', "").trim()
|
||||
when
|
||||
{
|
||||
line.startsWith("ip=") -> ip = value.takeIf { it.isNotEmpty() }
|
||||
line.startsWith("loc=") -> loc = value.takeIf { it.isNotEmpty() }
|
||||
}
|
||||
}
|
||||
return ip?.let { ExitInfo(ip = it, country = loc) }
|
||||
}
|
||||
|
||||
private fun waitForOrTimeout(process: Process, timeoutMs: Long): Boolean
|
||||
{
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O)
|
||||
{
|
||||
return process.waitFor(timeoutMs, TimeUnit.MILLISECONDS)
|
||||
}
|
||||
/* pre-O: no timed waitFor() - poll coarsely instead of spinning */
|
||||
val deadline = System.currentTimeMillis() + timeoutMs
|
||||
while (System.currentTimeMillis() < deadline)
|
||||
{
|
||||
try
|
||||
{
|
||||
process.exitValue()
|
||||
return true
|
||||
}
|
||||
catch (_: IllegalThreadStateException)
|
||||
{
|
||||
}
|
||||
Thread.sleep(250)
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
+879
@@ -0,0 +1,879 @@
|
||||
/*
|
||||
* Copyright (C) 2026 SHX VPN
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or modify it
|
||||
* under the terms of the GNU General Public License as published by the
|
||||
* Free Software Foundation; either version 2 of the License, or (at your
|
||||
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful, but
|
||||
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
||||
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
|
||||
* for more details.
|
||||
*/
|
||||
|
||||
package org.strongswan.android.logic.connectivity
|
||||
|
||||
import android.app.Service
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.content.ServiceConnection
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.Network
|
||||
import android.net.NetworkCapabilities
|
||||
import android.net.NetworkRequest
|
||||
import android.os.BatteryManager
|
||||
import android.os.Build
|
||||
import android.os.Handler
|
||||
import android.os.IBinder
|
||||
import android.os.Looper
|
||||
import android.os.SystemClock
|
||||
import android.os.PowerManager
|
||||
import android.util.Log
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONException
|
||||
import org.json.JSONObject
|
||||
import org.strongswan.android.logic.CharonVpnService
|
||||
import org.strongswan.android.logic.VpnStateService
|
||||
import org.strongswan.android.logic.events.Events
|
||||
|
||||
/** What to do when a check fails. */
|
||||
enum class HealthAction
|
||||
{
|
||||
RECONNECT,
|
||||
IGNORE,
|
||||
}
|
||||
|
||||
/** When the VPN server itself is probed directly (outside the tunnel). */
|
||||
enum class ServerCheckMode
|
||||
{
|
||||
/** Probe the server on every cycle. */
|
||||
ALWAYS,
|
||||
/** Only consider a server failure when the tunnel is dead too (protects against servers that block ICMP). */
|
||||
WITH_TUNNEL,
|
||||
/** Never probe the server. */
|
||||
OFF,
|
||||
}
|
||||
|
||||
data class HealthConfig(
|
||||
val enabled: Boolean = false,
|
||||
val intervalSec: Int = 30,
|
||||
val aggressiveMode: Boolean = false,
|
||||
/** Stretch intervals and pause aggressive mode while the screen is off, power saver is on or the battery is low. */
|
||||
val adaptiveMode: Boolean = true,
|
||||
val timeoutMs: Int = 3000,
|
||||
val targets: Set<ProbeTarget> = setOf(ProbeTarget.GOOGLE, ProbeTarget.YOUTUBE, ProbeTarget.CLOUDFLARE),
|
||||
val serverCheckMode: ServerCheckMode = ServerCheckMode.WITH_TUNNEL,
|
||||
val serverAction: HealthAction = HealthAction.RECONNECT,
|
||||
val serverMaxAttempts: Int = 3,
|
||||
val tunnelDeadAction: HealthAction = HealthAction.RECONNECT,
|
||||
val tunnelDeadMaxAttempts: Int = 3,
|
||||
val partialDownAction: HealthAction = HealthAction.IGNORE,
|
||||
)
|
||||
|
||||
/** Device power state that modulates probe intensity in adaptive mode. */
|
||||
data class PowerState(
|
||||
val screenOn: Boolean = true,
|
||||
val powerSaveMode: Boolean = false,
|
||||
val batteryLow: Boolean = false,
|
||||
)
|
||||
|
||||
/**
|
||||
* Watches tunnel health while the VPN is connected: probes external targets
|
||||
* through the tunnel and the VPN server directly on the underlying network.
|
||||
* Based on [decide] it may reconnect, with per-rule attempt budgets and a
|
||||
* confirming probe (unless aggressive mode) to avoid false reconnects.
|
||||
*
|
||||
* The process stays alive with the VPN foreground service, so checks continue
|
||||
* with the screen off. No alarms or wakelocks: plain coroutines that simply
|
||||
* follow process liveness, and Doze may stretch intervals - acceptable for a
|
||||
* safety net on top of IKE DPD.
|
||||
*
|
||||
* With adaptive mode enabled the probe intensity follows the device power
|
||||
* state: screen off stretches the interval, power saver or a low battery
|
||||
* additionally disables fast polls (see [adaptToPowerState]).
|
||||
*/
|
||||
class HealthCheckManager private constructor(
|
||||
private val context: Context,
|
||||
) : VpnStateService.VpnStateListener
|
||||
{
|
||||
enum class Decision
|
||||
{
|
||||
RECONNECT_SERVER,
|
||||
RECONNECT_TUNNEL,
|
||||
IGNORE,
|
||||
}
|
||||
|
||||
private val handler = Handler(Looper.getMainLooper())
|
||||
private val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
private val scope = CoroutineScope(SupervisorJob() + kotlinx.coroutines.Dispatchers.Main.immediate)
|
||||
private val cycleMutex = Mutex()
|
||||
private var config = HealthConfig()
|
||||
private var running = false
|
||||
/* last state seen by onStateChanged(): VpnStateService also notifies on
|
||||
* error/IMC/rebind changes, which must not restart a pending cycle */
|
||||
private var lastState: VpnStateService.State? = null
|
||||
private var service: VpnStateService? = null
|
||||
private var cycleJob: Job? = null
|
||||
private var confirmJob: Job? = null
|
||||
private var pendingDecision: Decision? = null
|
||||
private var serverAttempts = 0
|
||||
private var tunnelAttempts = 0
|
||||
private var fastPollShifts = 0
|
||||
private var powerState = PowerState()
|
||||
/* tunnel inbound bytes at the previous cycle (-1: unknown) and when the last real probe ran */
|
||||
private var lastCycleInbound = -1L
|
||||
private var lastProbeAt = 0L
|
||||
|
||||
/* tracks screen/battery state so probing can back off while the device sleeps */
|
||||
private val powerReceiver = object : BroadcastReceiver()
|
||||
{
|
||||
override fun onReceive(ctx: Context, intent: Intent)
|
||||
{
|
||||
when (intent.action)
|
||||
{
|
||||
Intent.ACTION_SCREEN_ON -> powerState = powerState.copy(screenOn = true)
|
||||
Intent.ACTION_SCREEN_OFF -> powerState = powerState.copy(screenOn = false)
|
||||
PowerManager.ACTION_POWER_SAVE_MODE_CHANGED -> refreshPowerSaveMode()
|
||||
Intent.ACTION_BATTERY_LOW -> powerState = powerState.copy(batteryLow = true)
|
||||
Intent.ACTION_BATTERY_OKAY -> powerState = powerState.copy(batteryLow = false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* event trigger: the system re-validates networks (e.g. on screen unlock) */
|
||||
private var vpnNetwork: Network? = null
|
||||
private var vpnValidated = false
|
||||
private val vpnCallback = object : ConnectivityManager.NetworkCallback()
|
||||
{
|
||||
override fun onAvailable(network: Network)
|
||||
{
|
||||
vpnNetwork = network
|
||||
vpnValidated = false
|
||||
}
|
||||
|
||||
override fun onCapabilitiesChanged(network: Network, capabilities: NetworkCapabilities)
|
||||
{
|
||||
if (network != vpnNetwork)
|
||||
{
|
||||
return
|
||||
}
|
||||
val validated = capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)
|
||||
if (vpnValidated && !validated)
|
||||
{
|
||||
Log.i(TAG, "VPN network lost validation, triggering immediate check")
|
||||
triggerImmediateCheck()
|
||||
}
|
||||
vpnValidated = validated
|
||||
}
|
||||
|
||||
override fun onLost(network: Network)
|
||||
{
|
||||
if (network == vpnNetwork)
|
||||
{
|
||||
vpnNetwork = null
|
||||
vpnValidated = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val connection = object : ServiceConnection
|
||||
{
|
||||
override fun onServiceConnected(name: ComponentName?, binder: IBinder?)
|
||||
{
|
||||
service = (binder as VpnStateService.LocalBinder).service
|
||||
service?.registerListener(this@HealthCheckManager)
|
||||
onStateChanged()
|
||||
}
|
||||
|
||||
override fun onServiceDisconnected(name: ComponentName?)
|
||||
{
|
||||
service = null
|
||||
}
|
||||
}
|
||||
|
||||
fun start()
|
||||
{
|
||||
config = loadConfig()
|
||||
if (!config.enabled)
|
||||
{
|
||||
return
|
||||
}
|
||||
if (running)
|
||||
{
|
||||
return
|
||||
}
|
||||
running = true
|
||||
context.bindService(
|
||||
Intent(context, VpnStateService::class.java), connection, Service.BIND_AUTO_CREATE
|
||||
)
|
||||
registerVpnCallback()
|
||||
registerPowerReceiver()
|
||||
}
|
||||
|
||||
fun stop()
|
||||
{
|
||||
if (!running)
|
||||
{
|
||||
return
|
||||
}
|
||||
running = false
|
||||
cancelCycles()
|
||||
unregisterVpnCallback()
|
||||
unregisterPowerReceiver()
|
||||
service?.unregisterListener(this)
|
||||
try
|
||||
{
|
||||
context.unbindService(connection)
|
||||
}
|
||||
catch (_: IllegalArgumentException)
|
||||
{
|
||||
}
|
||||
service = null
|
||||
lastState = null
|
||||
resetCounters()
|
||||
}
|
||||
|
||||
fun reloadConfig()
|
||||
{
|
||||
config = loadConfig()
|
||||
if (config.enabled)
|
||||
{
|
||||
start()
|
||||
/* re-arm with the new settings even if the state is unchanged */
|
||||
lastState = null
|
||||
onStateChanged()
|
||||
}
|
||||
else
|
||||
{
|
||||
stop()
|
||||
}
|
||||
}
|
||||
|
||||
val currentConfig: HealthConfig
|
||||
get()
|
||||
{
|
||||
if (!running)
|
||||
{
|
||||
config = loadConfig()
|
||||
}
|
||||
return config
|
||||
}
|
||||
|
||||
fun updateConfig(transform: (HealthConfig) -> HealthConfig)
|
||||
{
|
||||
val updated = transform(config)
|
||||
saveConfig(updated)
|
||||
config = updated
|
||||
if (updated.enabled)
|
||||
{
|
||||
start()
|
||||
/* re-arm with the new settings even if the state is unchanged */
|
||||
lastState = null
|
||||
onStateChanged()
|
||||
}
|
||||
else
|
||||
{
|
||||
stop()
|
||||
}
|
||||
}
|
||||
|
||||
override fun stateChanged()
|
||||
{
|
||||
handler.post { onStateChanged() }
|
||||
}
|
||||
|
||||
private fun onStateChanged()
|
||||
{
|
||||
if (!running)
|
||||
{
|
||||
return
|
||||
}
|
||||
val state = service?.state
|
||||
val previous = lastState
|
||||
lastState = state
|
||||
when (state)
|
||||
{
|
||||
VpnStateService.State.CONNECTED ->
|
||||
{
|
||||
if (previous != VpnStateService.State.CONNECTED)
|
||||
{ /* let the fresh tunnel settle before the first probe */
|
||||
scheduleCycle(INITIAL_DELAY_MS)
|
||||
}
|
||||
}
|
||||
VpnStateService.State.DISABLED ->
|
||||
{
|
||||
cancelCycles()
|
||||
resetCounters()
|
||||
}
|
||||
else -> cancelCycles()
|
||||
}
|
||||
}
|
||||
|
||||
private fun triggerImmediateCheck()
|
||||
{
|
||||
if (!running || service?.state != VpnStateService.State.CONNECTED)
|
||||
{
|
||||
return
|
||||
}
|
||||
scope.launch {
|
||||
val ran = tryRunCycle()
|
||||
if (!ran)
|
||||
{
|
||||
/* a cycle is already in flight; its result will reschedule */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs one probe cycle unless another one is already running.
|
||||
* Returns false when a cycle was already in progress.
|
||||
*/
|
||||
private suspend fun tryRunCycle(): Boolean
|
||||
{
|
||||
if (!cycleMutex.tryLock())
|
||||
{
|
||||
return false
|
||||
}
|
||||
try
|
||||
{
|
||||
runCycleLocked()
|
||||
return true
|
||||
}
|
||||
finally
|
||||
{
|
||||
cycleMutex.unlock()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun runCycleLocked()
|
||||
{
|
||||
val svc = service ?: return
|
||||
if (svc.state != VpnStateService.State.CONNECTED)
|
||||
{
|
||||
return
|
||||
}
|
||||
val gateway = svc.profile?.gateway
|
||||
val adapted = adaptToPowerState(config, powerState)
|
||||
val nowMs = SystemClock.elapsedRealtime()
|
||||
/* passive liveness: payload that came back through the tunnel since the
|
||||
* previous cycle proves it works without waking the radio for probes */
|
||||
val inbound = tunnelInboundBytes()
|
||||
val inboundDelta = if (lastCycleInbound >= 0 && inbound >= 0) inbound - lastCycleInbound else -1L
|
||||
lastCycleInbound = inbound
|
||||
if (trafficProvesAlive(
|
||||
adapted, serverAttempts + tunnelAttempts, fastPollShifts, pendingDecision != null,
|
||||
inboundDelta, nowMs - lastProbeAt,
|
||||
))
|
||||
{
|
||||
scheduleCycle(effectiveIntervalSec(adapted) * 1000L)
|
||||
return
|
||||
}
|
||||
lastProbeAt = nowMs
|
||||
/* screen off: one target is enough (the confirmation probe uses it too) */
|
||||
val targets = if (adapted.adaptiveMode && !powerState.screenOn && config.targets.size > 1)
|
||||
{
|
||||
setOf(config.targets.first())
|
||||
}
|
||||
else
|
||||
{
|
||||
config.targets
|
||||
}
|
||||
val mode = config.serverCheckMode
|
||||
val timeout = config.timeoutMs
|
||||
|
||||
val externalResults = coroutineScope {
|
||||
targets.map { target ->
|
||||
async { ConnectivityProbe.probe(target, timeout, context, gateway) }
|
||||
}.map { it.await() }
|
||||
}
|
||||
val externalOk = externalResults.count { it.ok }
|
||||
val externalTotal = externalResults.size
|
||||
/* the direct server ping (a fork of /system/bin/ping plus a DNS lookup
|
||||
* outside the tunnel) only runs when its result can matter: always in
|
||||
* ALWAYS mode, in WITH_TUNNEL mode only once the tunnel looks dead */
|
||||
val serverDown = if (shouldProbeServer(mode, externalOk, externalTotal))
|
||||
{
|
||||
!ConnectivityProbe.probe(ProbeTarget.SERVER, timeout, context, gateway).ok
|
||||
}
|
||||
else
|
||||
{
|
||||
null
|
||||
}
|
||||
|
||||
val decision = decide(config, serverDown, externalOk, externalTotal, serverAttempts, tunnelAttempts)
|
||||
val healthy = serverDown != true && (externalTotal == 0 || externalOk == externalTotal)
|
||||
|
||||
when
|
||||
{
|
||||
healthy ->
|
||||
{
|
||||
pendingDecision = null
|
||||
resetCounters()
|
||||
scheduleCycle(effectiveIntervalSec(adaptToPowerState(config, powerState)) * 1000L)
|
||||
}
|
||||
decision == Decision.IGNORE ->
|
||||
{
|
||||
pendingDecision = null
|
||||
scheduleFastPoll()
|
||||
}
|
||||
else -> handleFailure(decision)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleFailure(decision: Decision)
|
||||
{
|
||||
if (needsConfirmation(adaptToPowerState(config, powerState)) && pendingDecision == null)
|
||||
{
|
||||
/* first failing cycle: confirm with a single probe before acting */
|
||||
pendingDecision = decision
|
||||
Log.i(TAG, "Check failed ($decision), scheduling confirmation probe")
|
||||
confirmJob?.cancel()
|
||||
confirmJob = scope.launch {
|
||||
delay(CONFIRM_DELAY_MS)
|
||||
if (pendingDecision == decision)
|
||||
{
|
||||
confirmAndAct(decision)
|
||||
}
|
||||
}
|
||||
scheduleFastPoll()
|
||||
return
|
||||
}
|
||||
act(decision)
|
||||
}
|
||||
|
||||
private suspend fun confirmAndAct(decision: Decision)
|
||||
{
|
||||
val svc = service ?: return
|
||||
if (svc.state != VpnStateService.State.CONNECTED)
|
||||
{
|
||||
pendingDecision = null
|
||||
return
|
||||
}
|
||||
val gateway = svc.profile?.gateway
|
||||
/* single probe of the failing dimension, not a whole cycle */
|
||||
val confirmed = when (decision)
|
||||
{
|
||||
Decision.RECONNECT_SERVER ->
|
||||
!ConnectivityProbe.probe(ProbeTarget.SERVER, config.timeoutMs, context, gateway).ok
|
||||
else ->
|
||||
{
|
||||
val target = config.targets.firstOrNull() ?: ProbeTarget.GOOGLE
|
||||
!ConnectivityProbe.probe(target, config.timeoutMs, context, gateway).ok
|
||||
}
|
||||
}
|
||||
if (confirmed)
|
||||
{
|
||||
Log.i(TAG, "Failure confirmed, reconnecting ($decision)")
|
||||
act(decision)
|
||||
}
|
||||
else
|
||||
{
|
||||
Log.i(TAG, "Confirmation probe succeeded, treating previous failure as transient")
|
||||
pendingDecision = null
|
||||
fastPollShifts = 0
|
||||
scheduleCycle(effectiveIntervalSec(adaptToPowerState(config, powerState)) * 1000L)
|
||||
}
|
||||
}
|
||||
|
||||
private fun act(decision: Decision)
|
||||
{
|
||||
pendingDecision = null
|
||||
if (decision != Decision.IGNORE && WhiteListManager.getInstance(context).shouldBlockReconnect())
|
||||
{ /* the ISP whitelist explains the failing probes, restarting the tunnel
|
||||
* will not help; re-check soon so we act once the whitelist clears */
|
||||
Log.i(TAG, "Whitelist active, suppressing reconnect ($decision)")
|
||||
scheduleFastPoll()
|
||||
return
|
||||
}
|
||||
when (decision)
|
||||
{
|
||||
Decision.RECONNECT_SERVER ->
|
||||
{
|
||||
if (serverAttempts < config.serverMaxAttempts)
|
||||
{
|
||||
serverAttempts++
|
||||
Log.i(TAG, "Reconnecting, server unreachable (attempt $serverAttempts/${config.serverMaxAttempts})")
|
||||
service?.reconnectPreservingLog()
|
||||
Events.healthReconnect(context, serverDown = true)
|
||||
}
|
||||
}
|
||||
Decision.RECONNECT_TUNNEL ->
|
||||
{
|
||||
if (tunnelAttempts < config.tunnelDeadMaxAttempts)
|
||||
{
|
||||
tunnelAttempts++
|
||||
Log.i(TAG, "Reconnecting, tunnel dead (attempt $tunnelAttempts/${config.tunnelDeadMaxAttempts})")
|
||||
service?.reconnectPreservingLog()
|
||||
Events.healthReconnect(context, serverDown = false)
|
||||
}
|
||||
}
|
||||
Decision.IGNORE ->
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun scheduleCycle(delayMs: Long)
|
||||
{
|
||||
cycleJob?.cancel()
|
||||
cycleJob = scope.launch {
|
||||
delay(delayMs)
|
||||
tryRunCycle()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedules the next cycle while a failure is being observed: fast at
|
||||
* first, then with exponential backoff up to the regular interval so a
|
||||
* persisting failure doesn't keep probing (and waking the radio) at full
|
||||
* rate. Under power saver or a low battery fast polls are skipped
|
||||
* entirely.
|
||||
*/
|
||||
private fun scheduleFastPoll()
|
||||
{
|
||||
val adapted = adaptToPowerState(config, powerState)
|
||||
val intervalMs = if (powerState.powerSaveMode || powerState.batteryLow)
|
||||
{
|
||||
adapted.intervalSec * 1000L
|
||||
}
|
||||
else
|
||||
{
|
||||
minOf(FAST_POLL_MS * (1L shl minOf(fastPollShifts, FAST_POLL_MAX_SHIFTS)), adapted.intervalSec * 1000L)
|
||||
}
|
||||
fastPollShifts++
|
||||
scheduleCycle(intervalMs)
|
||||
}
|
||||
|
||||
private fun refreshPowerSaveMode()
|
||||
{
|
||||
val pm = context.getSystemService(Context.POWER_SERVICE) as? PowerManager
|
||||
powerState = powerState.copy(powerSaveMode = pm?.isPowerSaveMode ?: false)
|
||||
}
|
||||
|
||||
private fun registerPowerReceiver()
|
||||
{
|
||||
val pm = context.getSystemService(Context.POWER_SERVICE) as? PowerManager
|
||||
powerState = PowerState(
|
||||
screenOn = pm?.isInteractive ?: true,
|
||||
powerSaveMode = pm?.isPowerSaveMode ?: false,
|
||||
batteryLow = isBatteryLowNow(),
|
||||
)
|
||||
/* BATTERY_LOW/OKAY are not sticky: the initial state comes from the
|
||||
* sticky ACTION_BATTERY_CHANGED, later changes from the broadcasts */
|
||||
val filter = IntentFilter().apply {
|
||||
addAction(Intent.ACTION_SCREEN_ON)
|
||||
addAction(Intent.ACTION_SCREEN_OFF)
|
||||
addAction(PowerManager.ACTION_POWER_SAVE_MODE_CHANGED)
|
||||
addAction(Intent.ACTION_BATTERY_LOW)
|
||||
addAction(Intent.ACTION_BATTERY_OKAY)
|
||||
}
|
||||
try
|
||||
{
|
||||
context.registerReceiver(powerReceiver, filter, null, handler)
|
||||
}
|
||||
catch (e: Exception)
|
||||
{
|
||||
Log.w(TAG, "Failed to register power state receiver", e)
|
||||
}
|
||||
}
|
||||
|
||||
private fun isBatteryLowNow(): Boolean
|
||||
{
|
||||
return try
|
||||
{
|
||||
val sticky = context.registerReceiver(null, IntentFilter(Intent.ACTION_BATTERY_CHANGED))
|
||||
?: return false
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P)
|
||||
{
|
||||
sticky.getBooleanExtra(BatteryManager.EXTRA_BATTERY_LOW, false)
|
||||
}
|
||||
else
|
||||
{ /* the platform's default low-battery threshold */
|
||||
val level = sticky.getIntExtra(BatteryManager.EXTRA_LEVEL, -1)
|
||||
val scale = sticky.getIntExtra(BatteryManager.EXTRA_SCALE, -1)
|
||||
level >= 0 && scale > 0 && level * 100 / scale <= 15
|
||||
}
|
||||
}
|
||||
catch (_: Exception)
|
||||
{
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private fun unregisterPowerReceiver()
|
||||
{
|
||||
try
|
||||
{
|
||||
context.unregisterReceiver(powerReceiver)
|
||||
}
|
||||
catch (_: Exception)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
private fun cancelCycles()
|
||||
{
|
||||
cycleJob?.cancel()
|
||||
cycleJob = null
|
||||
confirmJob?.cancel()
|
||||
confirmJob = null
|
||||
pendingDecision = null
|
||||
}
|
||||
|
||||
private fun resetCounters()
|
||||
{
|
||||
serverAttempts = 0
|
||||
tunnelAttempts = 0
|
||||
fastPollShifts = 0
|
||||
pendingDecision = null
|
||||
lastCycleInbound = -1L
|
||||
lastProbeAt = 0L
|
||||
}
|
||||
|
||||
/** Inbound byte counter of the tunnel's CHILD_SAs, -1 when unavailable. */
|
||||
private fun tunnelInboundBytes(): Long
|
||||
{
|
||||
return try
|
||||
{
|
||||
CharonVpnService.queryTraffic()?.getOrNull(0) ?: -1L
|
||||
}
|
||||
catch (_: Throwable)
|
||||
{
|
||||
-1L
|
||||
}
|
||||
}
|
||||
|
||||
private fun registerVpnCallback()
|
||||
{
|
||||
val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager ?: return
|
||||
val request = NetworkRequest.Builder()
|
||||
.addTransportType(NetworkCapabilities.TRANSPORT_VPN)
|
||||
.build()
|
||||
try
|
||||
{
|
||||
cm.registerNetworkCallback(request, vpnCallback, handler)
|
||||
}
|
||||
catch (e: Exception)
|
||||
{
|
||||
Log.w(TAG, "Failed to register VPN network callback", e)
|
||||
}
|
||||
}
|
||||
|
||||
private fun unregisterVpnCallback()
|
||||
{
|
||||
val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager ?: return
|
||||
try
|
||||
{
|
||||
cm.unregisterNetworkCallback(vpnCallback)
|
||||
}
|
||||
catch (_: Exception)
|
||||
{
|
||||
}
|
||||
vpnNetwork = null
|
||||
vpnValidated = false
|
||||
}
|
||||
|
||||
private fun loadConfig(): HealthConfig
|
||||
{
|
||||
val json = prefs.getString(KEY_CONFIG, null) ?: return HealthConfig()
|
||||
return try
|
||||
{
|
||||
val obj = JSONObject(json)
|
||||
HealthConfig(
|
||||
enabled = obj.optBoolean("enabled", false),
|
||||
intervalSec = obj.optInt("intervalSec", 30),
|
||||
aggressiveMode = obj.optBoolean("aggressiveMode", false),
|
||||
adaptiveMode = obj.optBoolean("adaptiveMode", true),
|
||||
timeoutMs = obj.optInt("timeoutMs", 3000),
|
||||
targets = obj.optJSONArray("targets")?.let { arr ->
|
||||
(0 until arr.length()).mapNotNull { i ->
|
||||
arr.optString(i).let { name -> ProbeTarget.entries.firstOrNull { it.name == name } }
|
||||
}.filter { it != ProbeTarget.SERVER }.toSet()
|
||||
} ?: setOf(ProbeTarget.GOOGLE, ProbeTarget.YOUTUBE, ProbeTarget.CLOUDFLARE),
|
||||
serverCheckMode = ServerCheckMode.entries.firstOrNull { it.name == obj.optString("serverCheckMode") }
|
||||
?: ServerCheckMode.WITH_TUNNEL,
|
||||
serverAction = HealthAction.entries.firstOrNull { it.name == obj.optString("serverAction") }
|
||||
?: HealthAction.RECONNECT,
|
||||
serverMaxAttempts = obj.optInt("serverMaxAttempts", 3),
|
||||
tunnelDeadAction = HealthAction.entries.firstOrNull { it.name == obj.optString("tunnelDeadAction") }
|
||||
?: HealthAction.RECONNECT,
|
||||
tunnelDeadMaxAttempts = obj.optInt("tunnelDeadMaxAttempts", 3),
|
||||
partialDownAction = HealthAction.entries.firstOrNull { it.name == obj.optString("partialDownAction") }
|
||||
?: HealthAction.IGNORE,
|
||||
)
|
||||
}
|
||||
catch (_: JSONException)
|
||||
{
|
||||
HealthConfig()
|
||||
}
|
||||
}
|
||||
|
||||
private fun saveConfig(config: HealthConfig)
|
||||
{
|
||||
val obj = JSONObject()
|
||||
obj.put("enabled", config.enabled)
|
||||
obj.put("intervalSec", config.intervalSec)
|
||||
obj.put("aggressiveMode", config.aggressiveMode)
|
||||
obj.put("adaptiveMode", config.adaptiveMode)
|
||||
obj.put("timeoutMs", config.timeoutMs)
|
||||
obj.put("targets", JSONArray(config.targets.map { it.name }.sorted()))
|
||||
obj.put("serverCheckMode", config.serverCheckMode.name)
|
||||
obj.put("serverAction", config.serverAction.name)
|
||||
obj.put("serverMaxAttempts", config.serverMaxAttempts)
|
||||
obj.put("tunnelDeadAction", config.tunnelDeadAction.name)
|
||||
obj.put("tunnelDeadMaxAttempts", config.tunnelDeadMaxAttempts)
|
||||
obj.put("partialDownAction", config.partialDownAction.name)
|
||||
prefs.edit().putString(KEY_CONFIG, obj.toString()).apply()
|
||||
}
|
||||
|
||||
companion object
|
||||
{
|
||||
private const val TAG = "HealthCheckManager"
|
||||
private const val PREFS_NAME = "one.shx.strongswan.ext.HEALTH_CHECK"
|
||||
private const val KEY_CONFIG = "config"
|
||||
private const val INITIAL_DELAY_MS = 2000L
|
||||
private const val CONFIRM_DELAY_MS = 2000L
|
||||
private const val FAST_POLL_MS = 5000L
|
||||
private const val FAST_POLL_MAX_SHIFTS = 3
|
||||
private const val AGGRESSIVE_INTERVAL_SEC = 5
|
||||
|
||||
/** Inbound tunnel payload since the last cycle that counts as proof of life. */
|
||||
internal const val PASSIVE_MIN_INBOUND_BYTES = 4096L
|
||||
|
||||
/** Even with traffic flowing a real probe runs at least this often. */
|
||||
internal const val PASSIVE_MAX_PROBE_AGE_MS = 5 * 60 * 1000L
|
||||
|
||||
/**
|
||||
* Pure function: whether the cycle may skip its probes because the
|
||||
* tunnel carried inbound data since the previous cycle. Only in
|
||||
* adaptive mode, never in aggressive mode, never while a failure is
|
||||
* being observed, and never for longer than [PASSIVE_MAX_PROBE_AGE_MS].
|
||||
* Outbound-only traffic proves nothing, so it does not count.
|
||||
*/
|
||||
fun trafficProvesAlive(
|
||||
adapted: HealthConfig,
|
||||
failedAttempts: Int,
|
||||
fastPollShifts: Int,
|
||||
decisionPending: Boolean,
|
||||
inboundDelta: Long,
|
||||
sinceLastProbeMs: Long,
|
||||
): Boolean =
|
||||
adapted.adaptiveMode && !adapted.aggressiveMode &&
|
||||
failedAttempts == 0 && fastPollShifts == 0 && !decisionPending &&
|
||||
inboundDelta >= PASSIVE_MIN_INBOUND_BYTES &&
|
||||
sinceLastProbeMs in 0 until PASSIVE_MAX_PROBE_AGE_MS
|
||||
|
||||
/** Minimum probe interval in adaptive mode while the screen is off. */
|
||||
internal const val SCREEN_OFF_MIN_INTERVAL_SEC = 60
|
||||
|
||||
/** Probe interval in adaptive mode while power saver is on or the battery is low. */
|
||||
internal const val POWER_SAVE_INTERVAL_SEC = 300
|
||||
|
||||
/** Effective interval between cycles: aggressive mode overrides the user interval. */
|
||||
fun effectiveIntervalSec(config: HealthConfig): Int =
|
||||
if (config.aggressiveMode) AGGRESSIVE_INTERVAL_SEC else config.intervalSec
|
||||
|
||||
/**
|
||||
* Pure function: the config actually applied for the given device power
|
||||
* state. With adaptive mode on, aggressive mode is paused and the
|
||||
* interval stretched while the screen is off; under power saver or a low
|
||||
* battery the interval goes to the maximum (fast polls are skipped in
|
||||
* [scheduleFastPoll] based on the same state).
|
||||
*/
|
||||
fun adaptToPowerState(config: HealthConfig, power: PowerState): HealthConfig
|
||||
{
|
||||
if (!config.adaptiveMode)
|
||||
{
|
||||
return config
|
||||
}
|
||||
return when
|
||||
{
|
||||
power.powerSaveMode || power.batteryLow ->
|
||||
config.copy(aggressiveMode = false, intervalSec = maxOf(config.intervalSec, POWER_SAVE_INTERVAL_SEC))
|
||||
!power.screenOn ->
|
||||
config.copy(aggressiveMode = false, intervalSec = maxOf(config.intervalSec, SCREEN_OFF_MIN_INTERVAL_SEC))
|
||||
else -> config
|
||||
}
|
||||
}
|
||||
|
||||
/** Aggressive mode reconnects without a confirming probe. */
|
||||
fun needsConfirmation(config: HealthConfig): Boolean = !config.aggressiveMode
|
||||
|
||||
/**
|
||||
* Pure decision function, unit-testable.
|
||||
*
|
||||
* @param serverDown null when the server is not probed, true when the direct ping failed
|
||||
* @param externalOk how many external targets answered through the tunnel
|
||||
* @param externalTotal how many external targets were probed
|
||||
* @param serverAttempts reconnect decisions already taken for the server rule
|
||||
* @param tunnelAttempts reconnect decisions already taken for the tunnel rules
|
||||
*/
|
||||
/** Whether the server probe can influence [decide] this cycle. */
|
||||
@JvmStatic
|
||||
fun shouldProbeServer(mode: ServerCheckMode, externalOk: Int, externalTotal: Int): Boolean =
|
||||
when (mode)
|
||||
{
|
||||
ServerCheckMode.OFF -> false
|
||||
ServerCheckMode.ALWAYS -> true
|
||||
ServerCheckMode.WITH_TUNNEL -> externalTotal > 0 && externalOk == 0
|
||||
}
|
||||
|
||||
fun decide(
|
||||
config: HealthConfig,
|
||||
serverDown: Boolean?,
|
||||
externalOk: Int,
|
||||
externalTotal: Int,
|
||||
serverAttempts: Int,
|
||||
tunnelAttempts: Int,
|
||||
): Decision
|
||||
{
|
||||
if (!config.enabled)
|
||||
{
|
||||
return Decision.IGNORE
|
||||
}
|
||||
val tunnelDead = externalTotal > 0 && externalOk == 0
|
||||
val serverFail = config.serverCheckMode != ServerCheckMode.OFF &&
|
||||
serverDown == true &&
|
||||
(config.serverCheckMode == ServerCheckMode.ALWAYS ||
|
||||
(config.serverCheckMode == ServerCheckMode.WITH_TUNNEL && tunnelDead))
|
||||
if (serverFail && config.serverAction == HealthAction.RECONNECT &&
|
||||
serverAttempts < config.serverMaxAttempts)
|
||||
{
|
||||
return Decision.RECONNECT_SERVER
|
||||
}
|
||||
if (tunnelDead && config.tunnelDeadAction == HealthAction.RECONNECT &&
|
||||
tunnelAttempts < config.tunnelDeadMaxAttempts)
|
||||
{
|
||||
return Decision.RECONNECT_TUNNEL
|
||||
}
|
||||
val partial = externalTotal > 0 && externalOk in 1 until externalTotal
|
||||
if (partial && config.partialDownAction == HealthAction.RECONNECT &&
|
||||
tunnelAttempts < config.tunnelDeadMaxAttempts)
|
||||
{
|
||||
return Decision.RECONNECT_TUNNEL
|
||||
}
|
||||
return Decision.IGNORE
|
||||
}
|
||||
|
||||
@Volatile
|
||||
private var instance: HealthCheckManager? = null
|
||||
|
||||
@JvmStatic
|
||||
fun getInstance(context: Context): HealthCheckManager =
|
||||
instance ?: synchronized(this) {
|
||||
instance ?: HealthCheckManager(context.applicationContext).also { instance = it }
|
||||
}
|
||||
}
|
||||
}
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
/*
|
||||
* Copyright (C) 2026 SHX VPN
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or modify it
|
||||
* under the terms of the GNU General Public License as published by the
|
||||
* Free Software Foundation; either version 2 of the License, or (at your
|
||||
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful, but
|
||||
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
||||
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
|
||||
* for more details.
|
||||
*/
|
||||
|
||||
package org.strongswan.android.logic.connectivity
|
||||
|
||||
import android.content.Context
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.Network
|
||||
import android.net.NetworkCapabilities
|
||||
|
||||
/**
|
||||
* Finds the underlying (non-VPN) network so the VPN server can be reached
|
||||
* directly, outside the tunnel. Wi-Fi is preferred over cellular, mirroring
|
||||
* [org.strongswan.android.logic.autoconnect.NetworkMonitor].
|
||||
*/
|
||||
object UnderlyingNetwork
|
||||
{
|
||||
fun resolve(context: Context): Network?
|
||||
{
|
||||
val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager ?: return null
|
||||
var wifi: Network? = null
|
||||
var cellular: Network? = null
|
||||
var validatedWifi: Network? = null
|
||||
var validatedCellular: Network? = null
|
||||
for (network in cm.allNetworks)
|
||||
{
|
||||
val caps = try
|
||||
{
|
||||
cm.getNetworkCapabilities(network)
|
||||
}
|
||||
catch (_: Exception)
|
||||
{
|
||||
null
|
||||
} ?: continue
|
||||
if (caps.hasTransport(NetworkCapabilities.TRANSPORT_VPN))
|
||||
{
|
||||
continue
|
||||
}
|
||||
/* captive portals answer connects for anything, so an unvalidated
|
||||
* network would probe as "everything reachable" and hide blocking */
|
||||
val validated = caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)
|
||||
if (caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI))
|
||||
{
|
||||
wifi = network
|
||||
if (validated)
|
||||
{
|
||||
validatedWifi = network
|
||||
}
|
||||
}
|
||||
else if (caps.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR))
|
||||
{
|
||||
cellular = network
|
||||
if (validated)
|
||||
{
|
||||
validatedCellular = network
|
||||
}
|
||||
}
|
||||
}
|
||||
return validatedWifi ?: validatedCellular ?: wifi ?: cellular
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a hostname through the given network so the VPN tunnel's DNS
|
||||
* is never used for the direct server probe.
|
||||
*/
|
||||
fun resolveHost(network: Network?, host: String): List<java.net.InetAddress>?
|
||||
{
|
||||
return try
|
||||
{
|
||||
if (network != null) network.getAllByName(host).toList() else java.net.InetAddress.getAllByName(host).toList()
|
||||
}
|
||||
catch (_: Exception)
|
||||
{
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
+734
@@ -0,0 +1,734 @@
|
||||
/*
|
||||
* Copyright (C) 2026 SHX VPN
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or modify it
|
||||
* under the terms of the GNU General Public License as published by the
|
||||
* Free Software Foundation; either version 2 of the License, or (at your
|
||||
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful, but
|
||||
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
||||
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
|
||||
* for more details.
|
||||
*/
|
||||
|
||||
package org.strongswan.android.logic.connectivity
|
||||
|
||||
import android.app.Service
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.ServiceConnection
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.Network
|
||||
import android.net.NetworkCapabilities
|
||||
import android.net.NetworkRequest
|
||||
import android.os.Handler
|
||||
import android.os.IBinder
|
||||
import android.os.Looper
|
||||
import android.os.SystemClock
|
||||
import android.util.Log
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONException
|
||||
import org.json.JSONObject
|
||||
import org.strongswan.android.logic.VpnStateService
|
||||
import org.strongswan.android.logic.events.Events
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
/** Whether the ISP currently blocks direct access to foreign hosts. */
|
||||
enum class WhiteListStatus
|
||||
{
|
||||
/** No usable data yet. */
|
||||
UNKNOWN,
|
||||
/** Foreign hosts unreachable directly while domestic ones still answer. */
|
||||
ACTIVE,
|
||||
/** Foreign hosts reachable directly. */
|
||||
INACTIVE,
|
||||
}
|
||||
|
||||
/**
|
||||
* A probe endpoint, serialized to preferences as "host:port" (IPv4 only).
|
||||
*/
|
||||
data class ProbeEndpoint(val host: String, val port: Int)
|
||||
{
|
||||
fun serialize(): String = "$host:$port"
|
||||
|
||||
companion object
|
||||
{
|
||||
fun parse(value: String): ProbeEndpoint?
|
||||
{
|
||||
val index = value.lastIndexOf(':')
|
||||
if (index <= 0)
|
||||
{
|
||||
return null
|
||||
}
|
||||
val host = value.substring(0, index)
|
||||
val port = value.substring(index + 1).toIntOrNull() ?: return null
|
||||
if (host.isEmpty() || port !in 1..65535)
|
||||
{
|
||||
return null
|
||||
}
|
||||
return ProbeEndpoint(host, port)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class WhiteListConfig(
|
||||
val enabled: Boolean = false,
|
||||
/** Overrides for the foreign probe endpoints as "host:port"; empty keeps the built-in defaults. */
|
||||
val foreignTargets: List<String> = emptyList(),
|
||||
)
|
||||
|
||||
data class WhiteListState(
|
||||
val config: WhiteListConfig = WhiteListConfig(),
|
||||
val status: WhiteListStatus = WhiteListStatus.UNKNOWN,
|
||||
val foreignDirectOk: Boolean? = null,
|
||||
val domesticDirectOk: Boolean? = null,
|
||||
val foreignDirectRttMs: Long? = null,
|
||||
val domesticDirectRttMs: Long? = null,
|
||||
val lastCheckMs: Long = 0,
|
||||
)
|
||||
|
||||
/**
|
||||
* Result of one direct probe round (all probes run outside the tunnel),
|
||||
* one entry per configured foreign target. Failed probes report false/null
|
||||
* RTT; when there is no usable network at all every entry is null.
|
||||
*/
|
||||
data class WhiteListRound(
|
||||
val foreignOks: List<Boolean?>,
|
||||
val foreignRttMs: List<Long?>,
|
||||
val domesticOk: Boolean?,
|
||||
val domesticRttMs: Long?,
|
||||
)
|
||||
{
|
||||
/** Any foreign target reachable; null while nothing conclusive was probed. */
|
||||
val foreignDirectOk: Boolean?
|
||||
get() = when
|
||||
{
|
||||
foreignOks.isEmpty() -> null
|
||||
foreignOks.any { it == true } -> true
|
||||
foreignOks.all { it == false } -> false
|
||||
else -> null
|
||||
}
|
||||
|
||||
/** Fastest reply among the reachable foreign targets. */
|
||||
val foreignDirectRttMs: Long?
|
||||
get() = foreignRttMs.filterNotNull().minOrNull()
|
||||
}
|
||||
|
||||
/**
|
||||
* Detects "white listing" by ISPs (foreign hosts blocked directly, domestic
|
||||
* hosts still reachable) by probing several foreign endpoints and one
|
||||
* domestic host DIRECTLY on the underlying network, never through the
|
||||
* tunnel. One reachable foreign host disproves whitelisting; ACTIVE is only
|
||||
* reported after every foreign probe failed (each retried once) in two
|
||||
* consecutive rounds, so a flaky round cannot produce a false verdict.
|
||||
*
|
||||
* While the whitelist is [WhiteListStatus.ACTIVE], reconnecting to the VPN
|
||||
* server is pointless (the server is unreachable), so reconnection attempts
|
||||
* are paused: the VpnStateService retry countdown freezes, HealthCheckManager
|
||||
* skips its reconnect actions and AutoConnectManager ignores connect requests.
|
||||
* As soon as the status becomes [WhiteListStatus.INACTIVE] those paths resume
|
||||
* by themselves and the connection is (re)established. The ACTIVE verdict
|
||||
* fails open when it has gone stale, so the tunnel can never be parked
|
||||
* indefinitely by a wrong verdict.
|
||||
*
|
||||
* Battery: fully event-driven, no polling loop of its own. Automatic probes
|
||||
* run on underlying network changes, on tunnel loss, and at most once per
|
||||
* minute while the UI is visible (tunnel up or down); the availability card's
|
||||
* refresh button triggers one immediately. The automatic recheck while the
|
||||
* whitelist stays active and the tunnel is down follows a capped exponential
|
||||
* backoff. No wakelocks or alarms: plain coroutines that follow process
|
||||
* liveness.
|
||||
*/
|
||||
class WhiteListManager private constructor(private val context: Context) : VpnStateService.VpnStateListener
|
||||
{
|
||||
private val handler = Handler(Looper.getMainLooper())
|
||||
private val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
|
||||
private val cycleMutex = Mutex()
|
||||
|
||||
@Volatile
|
||||
private var config = WhiteListConfig()
|
||||
private var running = false
|
||||
|
||||
@Volatile
|
||||
private var machine = StatusMachine()
|
||||
|
||||
@Volatile
|
||||
private var lastCheckElapsed = 0L
|
||||
|
||||
@Volatile
|
||||
private var backoffAttempt = 0
|
||||
private var recheckJob: Job? = null
|
||||
private var service: VpnStateService? = null
|
||||
private var lastSeenState: VpnStateService.State? = null
|
||||
|
||||
private val _state = MutableStateFlow(WhiteListState())
|
||||
val state: StateFlow<WhiteListState> = _state
|
||||
|
||||
/* last classification-relevant capabilities per network (see capabilityKey) */
|
||||
private val lastCapabilities = ConcurrentHashMap<Network, String>()
|
||||
|
||||
/* event trigger: the underlying network changed, the ISP may classify differently now */
|
||||
private val networkCallback = object : ConnectivityManager.NetworkCallback()
|
||||
{
|
||||
override fun onAvailable(network: Network)
|
||||
{
|
||||
onNetworkEvent()
|
||||
}
|
||||
|
||||
override fun onCapabilitiesChanged(network: Network, capabilities: NetworkCapabilities)
|
||||
{ /* fired on every signal-strength change too: only a change that
|
||||
* can alter the ISP's classification counts as an event (it also
|
||||
* resets the recheck backoff) */
|
||||
val key = capabilityKey(capabilities)
|
||||
if (lastCapabilities.put(network, key) != key)
|
||||
{
|
||||
onNetworkEvent()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onLost(network: Network)
|
||||
{
|
||||
lastCapabilities.remove(network)
|
||||
onNetworkEvent()
|
||||
}
|
||||
}
|
||||
|
||||
private val connection = object : ServiceConnection
|
||||
{
|
||||
override fun onServiceConnected(name: ComponentName?, binder: IBinder?)
|
||||
{
|
||||
service = (binder as VpnStateService.LocalBinder).service
|
||||
service?.registerListener(this@WhiteListManager)
|
||||
}
|
||||
|
||||
override fun onServiceDisconnected(name: ComponentName?)
|
||||
{
|
||||
service = null
|
||||
}
|
||||
}
|
||||
|
||||
fun start()
|
||||
{
|
||||
config = loadConfig()
|
||||
if (!config.enabled)
|
||||
{
|
||||
return
|
||||
}
|
||||
if (running)
|
||||
{
|
||||
return
|
||||
}
|
||||
running = true
|
||||
publish()
|
||||
context.bindService(Intent(context, VpnStateService::class.java), connection, Service.BIND_AUTO_CREATE)
|
||||
registerNetworkCallback()
|
||||
Log.i(TAG, "Started")
|
||||
}
|
||||
|
||||
fun stop()
|
||||
{
|
||||
if (!running)
|
||||
{
|
||||
return
|
||||
}
|
||||
running = false
|
||||
recheckJob?.cancel()
|
||||
recheckJob = null
|
||||
unregisterNetworkCallback()
|
||||
lastCapabilities.clear()
|
||||
service?.unregisterListener(this)
|
||||
try
|
||||
{
|
||||
context.unbindService(connection)
|
||||
}
|
||||
catch (_: IllegalArgumentException)
|
||||
{
|
||||
}
|
||||
service = null
|
||||
lastSeenState = null
|
||||
machine = StatusMachine()
|
||||
backoffAttempt = 0
|
||||
lastCheckElapsed = 0L
|
||||
publish()
|
||||
Log.i(TAG, "Stopped")
|
||||
}
|
||||
|
||||
fun reloadConfig()
|
||||
{
|
||||
config = loadConfig()
|
||||
if (config.enabled)
|
||||
{
|
||||
start()
|
||||
}
|
||||
else
|
||||
{
|
||||
stop()
|
||||
}
|
||||
}
|
||||
|
||||
val currentConfig: WhiteListConfig
|
||||
get()
|
||||
{
|
||||
if (!running)
|
||||
{
|
||||
config = loadConfig()
|
||||
}
|
||||
return config
|
||||
}
|
||||
|
||||
fun updateConfig(transform: (WhiteListConfig) -> WhiteListConfig)
|
||||
{
|
||||
val updated = transform(config)
|
||||
saveConfig(updated)
|
||||
config = updated
|
||||
if (updated.enabled)
|
||||
{
|
||||
start()
|
||||
scope.launch { runCheck(force = true) }
|
||||
}
|
||||
else
|
||||
{
|
||||
stop()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether reconnect attempts should currently be paused. Cheap, safe from
|
||||
* any thread. Fails open once the ACTIVE verdict went stale: an
|
||||
* unverifiable status must not keep the tunnel down forever; the
|
||||
* triggered recheck restores ACTIVE if the whitelist is still in effect.
|
||||
*/
|
||||
fun shouldBlockReconnect(): Boolean
|
||||
{
|
||||
if (!running || !config.enabled)
|
||||
{
|
||||
return false
|
||||
}
|
||||
if (machine.status != WhiteListStatus.ACTIVE)
|
||||
{
|
||||
return false
|
||||
}
|
||||
if (SystemClock.elapsedRealtime() - lastCheckElapsed > ACTIVE_TTL_MS)
|
||||
{
|
||||
scope.launch { runCheck(force = false) }
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/** Called when the app becomes visible: refresh the cached status if stale. */
|
||||
fun refreshIfStale()
|
||||
{
|
||||
if (!running || !config.enabled)
|
||||
{
|
||||
return
|
||||
}
|
||||
scope.launch { runCheck(force = false) }
|
||||
}
|
||||
|
||||
/** Runs a probe round immediately (e.g. the user tapped refresh). */
|
||||
fun checkNow()
|
||||
{
|
||||
if (!running || !config.enabled)
|
||||
{
|
||||
return
|
||||
}
|
||||
scope.launch { runCheck(force = true) }
|
||||
}
|
||||
|
||||
override fun stateChanged()
|
||||
{
|
||||
handler.post { onStateChanged() }
|
||||
}
|
||||
|
||||
private fun onStateChanged()
|
||||
{
|
||||
if (!running)
|
||||
{
|
||||
return
|
||||
}
|
||||
val svc = service ?: return
|
||||
val state = svc.state
|
||||
val changed = state != lastSeenState
|
||||
lastSeenState = state
|
||||
if (state == VpnStateService.State.CONNECTED)
|
||||
{ /* tunnel up: nothing to reconnect, probes are not needed */
|
||||
recheckJob?.cancel()
|
||||
return
|
||||
}
|
||||
/* the retry countdown notifies listeners every second; only act on transitions */
|
||||
if (!changed)
|
||||
{
|
||||
return
|
||||
}
|
||||
backoffAttempt = 0
|
||||
/* a flapping tunnel (CONNECTING -> DISCONNECTING -> DISABLED -> ...)
|
||||
* must not trigger a fresh direct probe round on every transition */
|
||||
val force = SystemClock.elapsedRealtime() - lastCheckElapsed >= TRANSITION_MIN_GAP_MS
|
||||
scope.launch { runCheck(force = force) }
|
||||
}
|
||||
|
||||
private fun onNetworkEvent()
|
||||
{
|
||||
if (!running)
|
||||
{
|
||||
return
|
||||
}
|
||||
backoffAttempt = 0
|
||||
if (service?.state == VpnStateService.State.CONNECTED)
|
||||
{ /* nothing to reconnect while the tunnel is up */
|
||||
return
|
||||
}
|
||||
scope.launch { runCheck(force = false) }
|
||||
}
|
||||
|
||||
/** The configured foreign probe endpoints, falling back to the built-in defaults. */
|
||||
private fun effectiveForeignTargets(): List<ProbeEndpoint> =
|
||||
config.foreignTargets.mapNotNull { ProbeEndpoint.parse(it) }.ifEmpty { DEFAULT_FOREIGN_TARGETS }
|
||||
|
||||
private suspend fun runCheck(force: Boolean)
|
||||
{
|
||||
if (!running)
|
||||
{
|
||||
return
|
||||
}
|
||||
if (!cycleMutex.tryLock())
|
||||
{
|
||||
return
|
||||
}
|
||||
try
|
||||
{
|
||||
val now = SystemClock.elapsedRealtime()
|
||||
if (!force && now - lastCheckElapsed < FRESH_MS)
|
||||
{
|
||||
return
|
||||
}
|
||||
val targets = effectiveForeignTargets()
|
||||
var round = probeRound(context, targets)
|
||||
lastCheckElapsed = SystemClock.elapsedRealtime()
|
||||
var candidate = decide(round.foreignOks, round.domesticOk)
|
||||
if (candidate == WhiteListStatus.ACTIVE && machine.status != WhiteListStatus.ACTIVE)
|
||||
{ /* confirm a first ACTIVE verdict with a second round: one flaky
|
||||
* round must not flip the status and pause reconnects */
|
||||
Log.i(TAG, "ACTIVE candidate, confirming with a second round")
|
||||
delay(ACTIVE_CONFIRM_DELAY_MS)
|
||||
round = probeRound(context, targets)
|
||||
lastCheckElapsed = SystemClock.elapsedRealtime()
|
||||
candidate = decide(round.foreignOks, round.domesticOk)
|
||||
}
|
||||
val foreignLog = round.foreignOks.mapIndexed { index, ok ->
|
||||
"${targets.getOrNull(index)?.host ?: index}=${ok ?: "?"}/${round.foreignRttMs.getOrNull(index) ?: "-"}ms"
|
||||
}
|
||||
Log.i(TAG, "Direct round: ${foreignLog.joinToString(" ")}, " +
|
||||
"domestic=${round.domesticOk}/${round.domesticRttMs ?: "-"}ms -> $candidate")
|
||||
val before = machine
|
||||
machine = before.advance(candidate)
|
||||
publish(round)
|
||||
if (before.status != WhiteListStatus.ACTIVE && machine.status == WhiteListStatus.ACTIVE)
|
||||
{
|
||||
Events.whitelist(context, true)
|
||||
android.os.Handler(android.os.Looper.getMainLooper()).post { org.strongswan.android.logic.profiles.Resilience.onWhitelist(context, true); org.strongswan.android.logic.scenarios.ScenarioEngine.evaluate(context) }
|
||||
}
|
||||
else if (before.status == WhiteListStatus.ACTIVE && machine.status == WhiteListStatus.INACTIVE)
|
||||
{
|
||||
Events.whitelist(context, false)
|
||||
android.os.Handler(android.os.Looper.getMainLooper()).post { org.strongswan.android.logic.profiles.Resilience.onWhitelist(context, false); org.strongswan.android.logic.scenarios.ScenarioEngine.evaluate(context) }
|
||||
}
|
||||
if (machine.status == WhiteListStatus.ACTIVE)
|
||||
{ /* keep watching for the whitelist to clear, at a low rate */
|
||||
backoffAttempt++
|
||||
scheduleRecheck(nextBackoffMs(backoffAttempt))
|
||||
}
|
||||
else
|
||||
{
|
||||
backoffAttempt = 0
|
||||
recheckJob?.cancel()
|
||||
recheckJob = null
|
||||
}
|
||||
if (before.status == WhiteListStatus.ACTIVE && machine.status == WhiteListStatus.INACTIVE)
|
||||
{ /* the ISP cleared the whitelist; let the automation reconsider connecting */
|
||||
Log.i(TAG, "Whitelist cleared, re-evaluating auto connect")
|
||||
handler.post {
|
||||
org.strongswan.android.logic.autoconnect.AutoConnectManager.getInstance(context).reevaluate()
|
||||
}
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
cycleMutex.unlock()
|
||||
}
|
||||
}
|
||||
|
||||
private fun scheduleRecheck(delayMs: Long)
|
||||
{
|
||||
recheckJob?.cancel()
|
||||
recheckJob = scope.launch {
|
||||
delay(delayMs)
|
||||
/* forced: the first backoff steps are shorter than FRESH_MS, an
|
||||
* unforced recheck returned early and ended the chain */
|
||||
runCheck(force = true)
|
||||
}
|
||||
}
|
||||
|
||||
private fun publish(round: WhiteListRound? = null)
|
||||
{
|
||||
val previous = _state.value
|
||||
_state.value = WhiteListState(
|
||||
config = config,
|
||||
status = machine.status,
|
||||
foreignDirectOk = round?.foreignDirectOk,
|
||||
domesticDirectOk = round?.domesticOk,
|
||||
foreignDirectRttMs = round?.foreignDirectRttMs,
|
||||
domesticDirectRttMs = round?.domesticRttMs,
|
||||
lastCheckMs = if (round != null || previous.lastCheckMs == 0L) System.currentTimeMillis() else previous.lastCheckMs,
|
||||
)
|
||||
}
|
||||
|
||||
private fun registerNetworkCallback()
|
||||
{
|
||||
val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager ?: return
|
||||
val request = NetworkRequest.Builder()
|
||||
.addCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN)
|
||||
.build()
|
||||
try
|
||||
{
|
||||
cm.registerNetworkCallback(request, networkCallback, handler)
|
||||
}
|
||||
catch (e: Exception)
|
||||
{
|
||||
Log.w(TAG, "Failed to register network callback", e)
|
||||
}
|
||||
}
|
||||
|
||||
private fun unregisterNetworkCallback()
|
||||
{
|
||||
val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager ?: return
|
||||
try
|
||||
{
|
||||
cm.unregisterNetworkCallback(networkCallback)
|
||||
}
|
||||
catch (_: Exception)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadConfig(): WhiteListConfig
|
||||
{
|
||||
val json = prefs.getString(KEY_CONFIG, null) ?: return WhiteListConfig()
|
||||
return try
|
||||
{
|
||||
val obj = JSONObject(json)
|
||||
val targets = obj.optJSONArray("foreignTargets")?.let { arr ->
|
||||
(0 until arr.length()).mapNotNull { ProbeEndpoint.parse(arr.optString(it))?.serialize() }
|
||||
}.orEmpty()
|
||||
WhiteListConfig(enabled = obj.optBoolean("enabled", false), foreignTargets = targets)
|
||||
}
|
||||
catch (_: JSONException)
|
||||
{
|
||||
WhiteListConfig()
|
||||
}
|
||||
}
|
||||
|
||||
private fun saveConfig(config: WhiteListConfig)
|
||||
{
|
||||
val obj = JSONObject()
|
||||
obj.put("enabled", config.enabled)
|
||||
/* custom targets are stored only when set, so code updates to the
|
||||
* built-in defaults keep reaching existing installs */
|
||||
if (config.foreignTargets.isNotEmpty())
|
||||
{
|
||||
val arr = JSONArray()
|
||||
config.foreignTargets.forEach { arr.put(it) }
|
||||
obj.put("foreignTargets", arr)
|
||||
}
|
||||
prefs.edit().putString(KEY_CONFIG, obj.toString()).apply()
|
||||
}
|
||||
|
||||
/**
|
||||
* Tracks the reported status with hysteresis: leaving UNKNOWN needs a
|
||||
* single confirming round, flipping between ACTIVE and INACTIVE needs
|
||||
* [CONFIRMATIONS] consecutive rounds so one dropped probe does not flap
|
||||
* the status. (The manager additionally confirms a first ACTIVE verdict
|
||||
* with a second probe round before it ever reaches this machine.)
|
||||
*/
|
||||
data class StatusMachine(
|
||||
val status: WhiteListStatus = WhiteListStatus.UNKNOWN,
|
||||
val activeStreak: Int = 0,
|
||||
val inactiveStreak: Int = 0,
|
||||
)
|
||||
{
|
||||
fun advance(candidate: WhiteListStatus): StatusMachine
|
||||
{
|
||||
val activeStreak = if (candidate == WhiteListStatus.ACTIVE) this.activeStreak + 1 else 0
|
||||
val inactiveStreak = if (candidate == WhiteListStatus.INACTIVE) this.inactiveStreak + 1 else 0
|
||||
val confirmations = if (status == WhiteListStatus.UNKNOWN) 1 else CONFIRMATIONS_REQUIRED
|
||||
val status = when
|
||||
{
|
||||
this.status != WhiteListStatus.ACTIVE && activeStreak >= confirmations -> WhiteListStatus.ACTIVE
|
||||
this.status == WhiteListStatus.ACTIVE && inactiveStreak >= confirmations -> WhiteListStatus.INACTIVE
|
||||
else -> this.status
|
||||
}
|
||||
return StatusMachine(status, activeStreak, inactiveStreak)
|
||||
}
|
||||
}
|
||||
|
||||
companion object
|
||||
{
|
||||
private const val TAG = "WhiteListManager"
|
||||
private const val PREFS_NAME = "one.shx.strongswan.ext.WHITE_LIST"
|
||||
private const val KEY_CONFIG = "config"
|
||||
|
||||
/** Rounds required to flip between ACTIVE and INACTIVE. */
|
||||
const val CONFIRMATIONS_REQUIRED = 2
|
||||
|
||||
/**
|
||||
* Foreign endpoints probed directly over TCP/443, so port-53 filtering
|
||||
* cannot fake a verdict: Quad9 and OpenDNS DNS-over-HTTPS plus GitHub.
|
||||
* All are normally reachable from Russian networks but get cut off by
|
||||
* whitelist-style blocking. 8.8.8.8 and 1.1.1.1 are NOT usable here:
|
||||
* both are blocked in RF even without any whitelisting.
|
||||
*/
|
||||
val DEFAULT_FOREIGN_TARGETS = listOf(
|
||||
ProbeEndpoint("9.9.9.9", 443),
|
||||
ProbeEndpoint("208.67.222.222", 443),
|
||||
ProbeEndpoint("140.82.121.4", 443),
|
||||
)
|
||||
|
||||
/**
|
||||
* A domestic endpoint whitelists keep reachable (Yandex DNS), probed
|
||||
* by IP; distinguishes "foreign blocked" from "no network at all".
|
||||
*/
|
||||
val DOMESTIC_TARGET = ProbeEndpoint("77.88.8.8", 53)
|
||||
|
||||
private const val PROBE_TIMEOUT_MS = 3000
|
||||
|
||||
/** A failed foreign probe is retried once before it counts as blocked. */
|
||||
private const val PROBE_RETRY_DELAY_MS = 700L
|
||||
|
||||
/** A first ACTIVE verdict is re-probed after this delay to confirm. */
|
||||
private const val ACTIVE_CONFIRM_DELAY_MS = 2000L
|
||||
|
||||
/** Cached results younger than this are not re-probed. */
|
||||
private const val FRESH_MS = 60_000L
|
||||
|
||||
/** State transitions force a new round only if the last one is at least this old. */
|
||||
private const val TRANSITION_MIN_GAP_MS = 15_000L
|
||||
|
||||
/** The parts of the capabilities that matter for the classification. */
|
||||
@JvmStatic
|
||||
internal fun capabilityKey(caps: NetworkCapabilities): String
|
||||
{
|
||||
val transports = intArrayOf(NetworkCapabilities.TRANSPORT_WIFI, NetworkCapabilities.TRANSPORT_CELLULAR,
|
||||
NetworkCapabilities.TRANSPORT_ETHERNET, NetworkCapabilities.TRANSPORT_VPN)
|
||||
.filter { caps.hasTransport(it) }
|
||||
val capabilities = intArrayOf(NetworkCapabilities.NET_CAPABILITY_INTERNET,
|
||||
NetworkCapabilities.NET_CAPABILITY_VALIDATED, NetworkCapabilities.NET_CAPABILITY_NOT_METERED)
|
||||
.filter { caps.hasCapability(it) }
|
||||
return "$transports/$capabilities"
|
||||
}
|
||||
|
||||
/**
|
||||
* A cached ACTIVE status stops suppressing reconnects once no recheck
|
||||
* has confirmed it for this long (fail open). Kept above the backoff
|
||||
* cap plus the probe duration so the scheduled rechecks keep the
|
||||
* verdict fresh without gaps.
|
||||
*/
|
||||
private const val ACTIVE_TTL_MS = 7 * 60_000L
|
||||
|
||||
private val BACKOFF_STEPS_MS = longArrayOf(15_000L, 30_000L, 60_000L, 120_000L, 300_000L)
|
||||
|
||||
/**
|
||||
* Pure classification of one probe round: whitelisting means every
|
||||
* foreign host failed (each after a retry) while the domestic one
|
||||
* answered. One reachable foreign host disproves it, anything else
|
||||
* (no network, domestic failure) is inconclusive.
|
||||
*/
|
||||
fun decide(foreignOks: List<Boolean?>, domesticOk: Boolean?): WhiteListStatus = when
|
||||
{
|
||||
foreignOks.isEmpty() -> WhiteListStatus.UNKNOWN
|
||||
foreignOks.any { it == true } -> WhiteListStatus.INACTIVE
|
||||
domesticOk == true && foreignOks.all { it == false } -> WhiteListStatus.ACTIVE
|
||||
else -> WhiteListStatus.UNKNOWN
|
||||
}
|
||||
|
||||
/** Capped exponential backoff for rechecks while the whitelist stays active. */
|
||||
fun nextBackoffMs(attempt: Int): Long =
|
||||
BACKOFF_STEPS_MS[(attempt - 1).coerceIn(0, BACKOFF_STEPS_MS.lastIndex)]
|
||||
|
||||
/**
|
||||
* Probes all configured foreign targets and the domestic one
|
||||
* concurrently, directly on the underlying network: the sockets are
|
||||
* created from [Network.socketFactory], so the tunnel is never used.
|
||||
* Foreign probes that fail are retried once. All results are null
|
||||
* when there is no usable network.
|
||||
*/
|
||||
suspend fun probeRound(context: Context, foreignTargets: List<ProbeEndpoint> = DEFAULT_FOREIGN_TARGETS): WhiteListRound
|
||||
{
|
||||
val network = UnderlyingNetwork.resolve(context)
|
||||
?: return WhiteListRound(
|
||||
foreignOks = List(foreignTargets.size) { null },
|
||||
foreignRttMs = List(foreignTargets.size) { null },
|
||||
domesticOk = null,
|
||||
domesticRttMs = null,
|
||||
)
|
||||
return coroutineScope {
|
||||
val foreign = foreignTargets.map { async { probeDirect(network, it) } }
|
||||
val domestic = async { probeDirect(network, DOMESTIC_TARGET) }
|
||||
var foreignResults = foreign.awaitAll()
|
||||
if (foreignResults.any { !it.first })
|
||||
{ /* a single lost packet must not look like blocking */
|
||||
foreignResults = foreignResults.mapIndexed { index, result ->
|
||||
async {
|
||||
if (result.first)
|
||||
{
|
||||
result
|
||||
}
|
||||
else
|
||||
{
|
||||
delay(PROBE_RETRY_DELAY_MS)
|
||||
probeDirect(network, foreignTargets[index])
|
||||
}
|
||||
}
|
||||
}.awaitAll()
|
||||
}
|
||||
val domesticResult = domestic.await()
|
||||
WhiteListRound(
|
||||
foreignOks = foreignResults.map { it.first },
|
||||
foreignRttMs = foreignResults.map { it.second },
|
||||
domesticOk = domesticResult.first,
|
||||
domesticRttMs = domesticResult.second,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun probeDirect(network: Network, target: ProbeEndpoint): Pair<Boolean, Long?>
|
||||
{ /* the target tag is result metadata only, unused for direct probes */
|
||||
val result = ConnectivityProbe.tcpPing(ProbeTarget.GOOGLE, target.host, target.port, PROBE_TIMEOUT_MS, network)
|
||||
return result.ok to result.rttMs
|
||||
}
|
||||
|
||||
@Volatile
|
||||
private var instance: WhiteListManager? = null
|
||||
|
||||
@JvmStatic
|
||||
fun getInstance(context: Context): WhiteListManager =
|
||||
instance ?: synchronized(this) {
|
||||
instance ?: WhiteListManager(context.applicationContext).also { instance = it }
|
||||
}
|
||||
}
|
||||
}
|
||||
+438
@@ -0,0 +1,438 @@
|
||||
/*
|
||||
* Copyright (C) 2026 SHX VPN
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or modify it
|
||||
* under the terms of the GNU General Public License as published by the
|
||||
* Free Software Foundation; either version 2 of the License, or (at your
|
||||
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful, but
|
||||
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
||||
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
|
||||
* for more details.
|
||||
*/
|
||||
|
||||
package org.strongswan.android.logic.events
|
||||
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.ProcessLifecycleOwner
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import org.strongswan.android.R
|
||||
import org.strongswan.android.ui.InboxActivity
|
||||
import java.io.File
|
||||
import java.util.concurrent.Executors
|
||||
|
||||
/** How an event looks and whether it waits for the user. */
|
||||
enum class EventKind
|
||||
{
|
||||
/** something is wrong right now (pushed on the "problems" channel) */
|
||||
WARN,
|
||||
/** the app suggests something and waits for a decision */
|
||||
ASK,
|
||||
/** the app did something by itself */
|
||||
INFO,
|
||||
/** something got better */
|
||||
OK,
|
||||
}
|
||||
|
||||
enum class EventCategory
|
||||
{
|
||||
CONNECTION,
|
||||
ROUTING,
|
||||
APP,
|
||||
}
|
||||
|
||||
/** What the event's button does; the Inbox maps these to screens. */
|
||||
enum class EventAction
|
||||
{
|
||||
NONE,
|
||||
OPEN_HOME,
|
||||
OPEN_ROUTING,
|
||||
OPEN_ROUTE_CHECK,
|
||||
OPEN_LOG,
|
||||
INSTALL_UPDATE,
|
||||
/** let the app in [ShxEvent.target] bypass the VPN */
|
||||
SET_APP_DIRECT,
|
||||
/** show the link update of the profile [ShxEvent.target] (F_ProfileUpdate) */
|
||||
PROFILE_UPDATE,
|
||||
/** open the server page of the profile [ShxEvent.target] */
|
||||
OPEN_SERVER,
|
||||
}
|
||||
|
||||
data class ShxEvent(
|
||||
val id: Long,
|
||||
val at: Long,
|
||||
val kind: EventKind,
|
||||
val category: EventCategory,
|
||||
val title: String,
|
||||
val text: String,
|
||||
val action: EventAction = EventAction.NONE,
|
||||
/** groups repeats of the same event; null for one-offs */
|
||||
val key: String? = null,
|
||||
/** how many times it happened within the merge window */
|
||||
val count: Int = 1,
|
||||
val read: Boolean = false,
|
||||
/** the problem went away by itself; shown dimmed, no longer "waiting" */
|
||||
val resolved: Boolean = false,
|
||||
/** what the action applies to, e.g. a package name */
|
||||
val target: String? = null,
|
||||
)
|
||||
{
|
||||
/** still needs the user's attention */
|
||||
val waiting: Boolean
|
||||
get() = !resolved && (kind == EventKind.WARN || kind == EventKind.ASK)
|
||||
}
|
||||
|
||||
/**
|
||||
* What the app did and noticed by itself, kept for [RETENTION_MS] on the
|
||||
* device. Problems are additionally pushed while the app is in the
|
||||
* background, so the user learns about them without opening it.
|
||||
*/
|
||||
class EventLog private constructor(private val context: Context)
|
||||
{
|
||||
private val file = File(context.filesDir, FILE_NAME)
|
||||
private val io = Executors.newSingleThreadExecutor { r -> Thread(r, "EventLog").apply { isDaemon = true } }
|
||||
private val _events = MutableStateFlow(load())
|
||||
val events: StateFlow<List<ShxEvent>> = _events
|
||||
|
||||
/**
|
||||
* Adds an event. A repeat of an unresolved event with the same [key]
|
||||
* within [MERGE_WINDOW_MS] bumps the existing entry instead of adding a
|
||||
* new one (retries would otherwise flood the list).
|
||||
*/
|
||||
fun record(
|
||||
kind: EventKind,
|
||||
category: EventCategory,
|
||||
title: String,
|
||||
text: String,
|
||||
action: EventAction = EventAction.NONE,
|
||||
key: String? = null,
|
||||
target: String? = null,
|
||||
)
|
||||
{
|
||||
val now = System.currentTimeMillis()
|
||||
var posted: ShxEvent? = null
|
||||
_events.update { list ->
|
||||
val merged = key?.let { k ->
|
||||
list.indexOfFirst { it.key == k && !it.resolved && now - it.at < MERGE_WINDOW_MS }
|
||||
} ?: -1
|
||||
val next = if (merged >= 0)
|
||||
{
|
||||
val old = list[merged]
|
||||
val bumped = old.copy(at = now, title = title, text = text, action = action, target = target, count = old.count + 1, read = false)
|
||||
posted = bumped
|
||||
listOf(bumped) + list.filterIndexed { i, _ -> i != merged }
|
||||
}
|
||||
else
|
||||
{
|
||||
val event = ShxEvent(
|
||||
id = now * 1000 + (list.size % 1000), at = now, kind = kind, category = category,
|
||||
title = title, text = text, action = action, key = key, target = target,
|
||||
)
|
||||
posted = event
|
||||
listOf(event) + list
|
||||
}
|
||||
prune(next, now)
|
||||
}
|
||||
persist()
|
||||
posted?.let {
|
||||
when
|
||||
{
|
||||
it.kind == EventKind.WARN && NotifPrefs.enabled(context, NotifPrefs.PROBLEMS) -> pushIfBackground(it, PROBLEMS_CHANNEL)
|
||||
it.kind == EventKind.ASK && NotifPrefs.enabled(context, NotifPrefs.NEW_APPS) -> pushIfBackground(it, HINTS_CHANNEL)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Records once per [key] for the whole retention time (e.g. one update notice per version). */
|
||||
fun recordOnce(kind: EventKind, category: EventCategory, title: String, text: String, action: EventAction, key: String, target: String? = null)
|
||||
{
|
||||
if (_events.value.none { it.key == key })
|
||||
{
|
||||
record(kind, category, title, text, action, key, target)
|
||||
}
|
||||
}
|
||||
|
||||
/** The user answered an event (did the action or said no): it no longer waits. */
|
||||
fun answer(id: Long)
|
||||
{
|
||||
_events.update { list -> list.map { if (it.id == id) it.copy(resolved = true, read = true) else it } }
|
||||
persist()
|
||||
}
|
||||
|
||||
/** Marks the unresolved events whose key starts with [keyPrefix] as resolved. */
|
||||
fun resolve(keyPrefix: String)
|
||||
{
|
||||
var changed = false
|
||||
_events.update { list ->
|
||||
list.map {
|
||||
if (!it.resolved && it.key?.startsWith(keyPrefix) == true)
|
||||
{
|
||||
changed = true
|
||||
NotificationManagerCompat.from(context).cancel(TAG, notificationId(it))
|
||||
it.copy(resolved = true)
|
||||
}
|
||||
else it
|
||||
}
|
||||
}
|
||||
if (changed)
|
||||
{
|
||||
persist()
|
||||
}
|
||||
}
|
||||
|
||||
fun markAllRead()
|
||||
{
|
||||
if (_events.value.none { !it.read })
|
||||
{
|
||||
return
|
||||
}
|
||||
val manager = NotificationManagerCompat.from(context)
|
||||
_events.value.filter { !it.read }.forEach { manager.cancel(TAG, notificationId(it)) }
|
||||
_events.update { list -> list.map { if (it.read) it else it.copy(read = true) } }
|
||||
persist()
|
||||
}
|
||||
|
||||
/** Hides an event the user acted on or dismissed. */
|
||||
fun dismiss(id: Long)
|
||||
{
|
||||
_events.update { list -> list.filterNot { it.id == id } }
|
||||
persist()
|
||||
}
|
||||
|
||||
private fun pushIfBackground(event: ShxEvent, channel: String)
|
||||
{
|
||||
val foreground = try
|
||||
{
|
||||
ProcessLifecycleOwner.get().lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)
|
||||
}
|
||||
catch (_: Exception)
|
||||
{
|
||||
false
|
||||
}
|
||||
if (foreground)
|
||||
{ /* the Home screen shows the problem already */
|
||||
return
|
||||
}
|
||||
val manager = NotificationManagerCompat.from(context)
|
||||
if (!manager.areNotificationsEnabled())
|
||||
{
|
||||
return
|
||||
}
|
||||
ensureChannel(context)
|
||||
val open = PendingIntent.getActivity(
|
||||
context, 0, Intent(context, InboxActivity::class.java).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK),
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
val builder = NotificationCompat.Builder(context, channel)
|
||||
.setSmallIcon(R.drawable.ic_notification)
|
||||
.setContentTitle(event.title)
|
||||
.setContentText(event.text)
|
||||
.setStyle(NotificationCompat.BigTextStyle().bigText(event.text))
|
||||
.setCategory(NotificationCompat.CATEGORY_STATUS)
|
||||
.setContentIntent(open)
|
||||
.setAutoCancel(true)
|
||||
.setOnlyAlertOnce(true)
|
||||
/* F_SysNotifs: answers right in the notification */
|
||||
if (event.action == EventAction.SET_APP_DIRECT)
|
||||
{
|
||||
builder.addAction(0, context.getString(R.string.notif_yes), answerIntent(event, true))
|
||||
builder.addAction(0, context.getString(R.string.notif_no), answerIntent(event, false))
|
||||
}
|
||||
else if (event.kind == EventKind.WARN)
|
||||
{
|
||||
builder.addAction(0, context.getString(R.string.notif_details), open)
|
||||
}
|
||||
val notification = builder.build()
|
||||
try
|
||||
{
|
||||
manager.notify(TAG, notificationId(event), notification)
|
||||
}
|
||||
catch (e: SecurityException)
|
||||
{ /* POST_NOTIFICATIONS was revoked in between */
|
||||
Log.w(TAG, "Problem notification not allowed", e)
|
||||
}
|
||||
}
|
||||
|
||||
private fun answerIntent(event: ShxEvent, yes: Boolean): PendingIntent = PendingIntent.getBroadcast(
|
||||
context, (event.id % 100000).toInt() * 2 + if (yes) 1 else 0,
|
||||
Intent(context, EventAnswerReceiver::class.java).putExtra(EXTRA_ID, event.id).putExtra(EXTRA_YES, yes),
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
|
||||
/** «Yes» / «No» of a suggestion pushed as a notification. */
|
||||
internal fun answerFromNotification(id: Long, yes: Boolean)
|
||||
{
|
||||
val event = _events.value.firstOrNull { it.id == id } ?: return
|
||||
if (yes && event.action == EventAction.SET_APP_DIRECT)
|
||||
{
|
||||
event.target?.let { org.strongswan.android.logic.routing.RoutingManager.getInstance(context).setAppDirect(it, true) }
|
||||
}
|
||||
answer(id)
|
||||
NotificationManagerCompat.from(context).cancel(TAG, notificationId(event))
|
||||
}
|
||||
|
||||
/* repeats of a keyed event replace their notification instead of stacking */
|
||||
private fun notificationId(event: ShxEvent): Int = (event.key ?: event.id.toString()).hashCode()
|
||||
|
||||
private fun prune(list: List<ShxEvent>, now: Long): List<ShxEvent> =
|
||||
list.filter { now - it.at < RETENTION_MS }.take(MAX_EVENTS)
|
||||
|
||||
private fun persist()
|
||||
{
|
||||
val snapshot = _events.value
|
||||
io.execute {
|
||||
try
|
||||
{
|
||||
val array = JSONArray()
|
||||
snapshot.forEach { array.put(toJson(it)) }
|
||||
val tmp = File(file.path + ".tmp")
|
||||
tmp.writeText(array.toString())
|
||||
if (!tmp.renameTo(file))
|
||||
{
|
||||
file.writeText(array.toString())
|
||||
tmp.delete()
|
||||
}
|
||||
}
|
||||
catch (e: Exception)
|
||||
{
|
||||
Log.w(TAG, "Saving events failed", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun load(): List<ShxEvent>
|
||||
{
|
||||
return try
|
||||
{
|
||||
if (!file.exists())
|
||||
{
|
||||
return emptyList()
|
||||
}
|
||||
val array = JSONArray(file.readText())
|
||||
val list = (0 until array.length()).mapNotNull { fromJson(array.optJSONObject(it)) }
|
||||
prune(list, System.currentTimeMillis())
|
||||
}
|
||||
catch (e: Exception)
|
||||
{
|
||||
Log.w(TAG, "Loading events failed", e)
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
companion object
|
||||
{
|
||||
private const val TAG = "EventLog"
|
||||
private const val FILE_NAME = "events.json"
|
||||
const val PROBLEMS_CHANNEL = "org.strongswan.android.problems"
|
||||
const val HINTS_CHANNEL = "org.strongswan.android.hints"
|
||||
internal const val EXTRA_ID = "org.strongswan.android.extra.EVENT_ID"
|
||||
internal const val EXTRA_YES = "org.strongswan.android.extra.EVENT_YES"
|
||||
const val RETENTION_MS = 30L * 24 * 60 * 60 * 1000
|
||||
const val MERGE_WINDOW_MS = 30L * 60 * 1000
|
||||
private const val MAX_EVENTS = 300
|
||||
|
||||
@Volatile private var instance: EventLog? = null
|
||||
|
||||
@JvmStatic
|
||||
fun getInstance(context: Context): EventLog =
|
||||
instance ?: synchronized(this) {
|
||||
instance ?: EventLog(context.applicationContext).also { instance = it }
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun ensureChannel(context: Context)
|
||||
{
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O)
|
||||
{
|
||||
val manager = context.getSystemService(NotificationManager::class.java) ?: return
|
||||
val channel = NotificationChannel(
|
||||
PROBLEMS_CHANNEL, context.getString(R.string.events_channel_problems),
|
||||
NotificationManager.IMPORTANCE_DEFAULT,
|
||||
)
|
||||
channel.description = context.getString(R.string.events_channel_problems_desc)
|
||||
manager.createNotificationChannel(channel)
|
||||
/* suggestions arrive quietly (F_NotifSettings «Quiet») */
|
||||
val hints = NotificationChannel(
|
||||
HINTS_CHANNEL, context.getString(R.string.events_channel_hints),
|
||||
NotificationManager.IMPORTANCE_LOW,
|
||||
)
|
||||
hints.description = context.getString(R.string.events_channel_hints_desc)
|
||||
manager.createNotificationChannel(hints)
|
||||
}
|
||||
}
|
||||
|
||||
internal fun toJson(e: ShxEvent): JSONObject = JSONObject()
|
||||
.put("id", e.id).put("at", e.at).put("kind", e.kind.name).put("category", e.category.name)
|
||||
.put("title", e.title).put("text", e.text).put("action", e.action.name)
|
||||
.put("key", e.key ?: JSONObject.NULL).put("count", e.count).put("read", e.read).put("resolved", e.resolved)
|
||||
.put("target", e.target ?: JSONObject.NULL)
|
||||
|
||||
internal fun fromJson(o: JSONObject?): ShxEvent?
|
||||
{
|
||||
o ?: return null
|
||||
return try
|
||||
{
|
||||
ShxEvent(
|
||||
id = o.getLong("id"),
|
||||
at = o.getLong("at"),
|
||||
kind = EventKind.valueOf(o.getString("kind")),
|
||||
category = EventCategory.valueOf(o.getString("category")),
|
||||
title = o.getString("title"),
|
||||
text = o.optString("text"),
|
||||
action = runCatching { EventAction.valueOf(o.optString("action")) }.getOrDefault(EventAction.NONE),
|
||||
key = if (o.isNull("key")) null else o.optString("key"),
|
||||
count = o.optInt("count", 1),
|
||||
read = o.optBoolean("read"),
|
||||
resolved = o.optBoolean("resolved"),
|
||||
target = if (o.isNull("target")) null else o.optString("target"),
|
||||
)
|
||||
}
|
||||
catch (_: Exception)
|
||||
{ /* an entry from an older/newer format: skip it rather than lose all */
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Which events are also pushed (F_NotifSettings); all of them stay in «Events» anyway. */
|
||||
object NotifPrefs
|
||||
{
|
||||
const val PROBLEMS = "pref_notif_problems"
|
||||
const val NEW_APPS = "pref_notif_new_apps"
|
||||
const val UPDATES = "pref_notif_updates"
|
||||
/** «Weekly summary», the only one off by default */
|
||||
const val WEEKLY = "pref_notif_weekly"
|
||||
|
||||
@JvmStatic
|
||||
fun enabled(context: Context, key: String): Boolean =
|
||||
androidx.preference.PreferenceManager.getDefaultSharedPreferences(context).getBoolean(key, key != WEEKLY)
|
||||
|
||||
@JvmStatic
|
||||
fun set(context: Context, key: String, value: Boolean) =
|
||||
androidx.preference.PreferenceManager.getDefaultSharedPreferences(context).edit().putBoolean(key, value).apply()
|
||||
}
|
||||
|
||||
/** Receives «Yes» / «No» from a suggestion notification. */
|
||||
class EventAnswerReceiver : android.content.BroadcastReceiver()
|
||||
{
|
||||
override fun onReceive(context: Context, intent: Intent)
|
||||
{
|
||||
val id = intent.getLongExtra(EventLog.EXTRA_ID, 0L)
|
||||
EventLog.getInstance(context).answerFromNotification(id, intent.getBooleanExtra(EventLog.EXTRA_YES, false))
|
||||
}
|
||||
}
|
||||
+176
@@ -0,0 +1,176 @@
|
||||
/*
|
||||
* Copyright (C) 2026 SHX VPN
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or modify it
|
||||
* under the terms of the GNU General Public License as published by the
|
||||
* Free Software Foundation; either version 2 of the License, or (at your
|
||||
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful, but
|
||||
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
||||
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
|
||||
* for more details.
|
||||
*/
|
||||
|
||||
package org.strongswan.android.logic.events
|
||||
|
||||
import android.content.Context
|
||||
import org.strongswan.android.R
|
||||
import org.strongswan.android.logic.VpnStateService.ErrorState
|
||||
|
||||
/**
|
||||
* The events the app records, one function per occasion, so the call sites
|
||||
* in the service code stay one-liners.
|
||||
*/
|
||||
object Events
|
||||
{
|
||||
private const val KEY_ERROR = "error:"
|
||||
private const val KEY_WHITELIST = "whitelist"
|
||||
|
||||
/** The connection failed; repeats while retrying are merged. */
|
||||
@JvmStatic
|
||||
fun connectionError(context: Context, error: ErrorState)
|
||||
{
|
||||
val (title, text, action) = when (error)
|
||||
{
|
||||
ErrorState.UNREACHABLE -> Triple(R.string.home_problem_unreachable_title, R.string.home_problem_unreachable_text, EventAction.OPEN_HOME)
|
||||
ErrorState.LOOKUP_FAILED -> Triple(R.string.home_problem_lookup_title, R.string.home_problem_lookup_text, EventAction.OPEN_HOME)
|
||||
ErrorState.AUTH_FAILED, ErrorState.PASSWORD_MISSING ->
|
||||
Triple(R.string.home_problem_auth_title, R.string.home_problem_auth_text, EventAction.OPEN_HOME)
|
||||
ErrorState.PEER_AUTH_FAILED, ErrorState.CERTIFICATE_UNAVAILABLE ->
|
||||
Triple(R.string.home_problem_cert_title, R.string.home_problem_cert_text, EventAction.OPEN_LOG)
|
||||
ErrorState.NO_ERROR -> return
|
||||
else -> Triple(R.string.home_problem_generic_title, R.string.home_problem_generic_text, EventAction.OPEN_LOG)
|
||||
}
|
||||
EventLog.getInstance(context).record(
|
||||
EventKind.WARN, EventCategory.CONNECTION, context.getString(title), context.getString(text),
|
||||
action, KEY_ERROR + error.name,
|
||||
)
|
||||
}
|
||||
|
||||
/** The tunnel is up again after a recorded failure. */
|
||||
@JvmStatic
|
||||
fun connectionRestored(context: Context)
|
||||
{
|
||||
val log = EventLog.getInstance(context)
|
||||
if (log.events.value.none { !it.resolved && it.key?.startsWith(KEY_ERROR) == true })
|
||||
{
|
||||
return
|
||||
}
|
||||
log.resolve(KEY_ERROR)
|
||||
log.record(
|
||||
EventKind.OK, EventCategory.CONNECTION, context.getString(R.string.event_restored_title),
|
||||
context.getString(R.string.event_restored_text), key = "restored",
|
||||
)
|
||||
}
|
||||
|
||||
/** The user stopped the VPN: pending connection problems no longer matter. */
|
||||
@JvmStatic
|
||||
fun connectionStopped(context: Context)
|
||||
{
|
||||
EventLog.getInstance(context).resolve(KEY_ERROR)
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun whitelist(context: Context, active: Boolean)
|
||||
{
|
||||
val log = EventLog.getInstance(context)
|
||||
if (active)
|
||||
{
|
||||
log.record(
|
||||
EventKind.WARN, EventCategory.CONNECTION, context.getString(R.string.event_whitelist_on_title),
|
||||
context.getString(R.string.event_whitelist_on_text), EventAction.OPEN_HOME, KEY_WHITELIST,
|
||||
)
|
||||
}
|
||||
else
|
||||
{
|
||||
log.resolve(KEY_WHITELIST)
|
||||
log.record(
|
||||
EventKind.OK, EventCategory.CONNECTION, context.getString(R.string.event_whitelist_off_title),
|
||||
context.getString(R.string.event_whitelist_off_text), key = "whitelist-off",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** The health check restarted the tunnel. */
|
||||
@JvmStatic
|
||||
fun healthReconnect(context: Context, serverDown: Boolean)
|
||||
{
|
||||
EventLog.getInstance(context).record(
|
||||
EventKind.INFO, EventCategory.CONNECTION, context.getString(R.string.event_health_reconnect_title),
|
||||
context.getString(if (serverDown) R.string.event_health_reconnect_server else R.string.event_health_reconnect_tunnel),
|
||||
key = "health-reconnect",
|
||||
)
|
||||
}
|
||||
|
||||
/** Auto-connect started the tunnel ([parked] = false) or parked it on a trusted Wi-Fi. */
|
||||
@JvmStatic
|
||||
fun autoConnect(context: Context, parked: Boolean)
|
||||
{
|
||||
EventLog.getInstance(context).record(
|
||||
EventKind.INFO, EventCategory.CONNECTION,
|
||||
context.getString(if (parked) R.string.event_auto_parked_title else R.string.event_auto_started_title),
|
||||
context.getString(if (parked) R.string.event_auto_parked_text else R.string.event_auto_started_text),
|
||||
key = if (parked) "auto-parked" else "auto-started",
|
||||
)
|
||||
}
|
||||
|
||||
/** A newly installed app looks like a Russian service that is not direct yet. */
|
||||
@JvmStatic
|
||||
fun newRuApp(context: Context, packageName: String, label: String)
|
||||
{
|
||||
EventLog.getInstance(context).recordOnce(
|
||||
EventKind.ASK, EventCategory.ROUTING, context.getString(R.string.event_new_app_title, label),
|
||||
context.getString(R.string.event_new_app_text), EventAction.SET_APP_DIRECT, "new-app:$packageName", packageName,
|
||||
)
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun pauseOver(context: Context)
|
||||
{
|
||||
EventLog.getInstance(context).record(
|
||||
EventKind.INFO, EventCategory.CONNECTION, context.getString(R.string.event_pause_over_title),
|
||||
context.getString(R.string.event_pause_over_text),
|
||||
)
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun updateAvailable(context: Context, versionName: String, versionCode: Long)
|
||||
{
|
||||
EventLog.getInstance(context).recordOnce(
|
||||
EventKind.INFO, EventCategory.APP, context.getString(R.string.event_update_title, versionName),
|
||||
context.getString(R.string.event_update_text), EventAction.INSTALL_UPDATE, "update:$versionCode",
|
||||
)
|
||||
}
|
||||
|
||||
/** The watchdog did not bring back [name]: a password would be needed. */
|
||||
@JvmStatic
|
||||
fun restoreNeedsPassword(context: Context, uuid: String, name: String)
|
||||
{
|
||||
EventLog.getInstance(context).record(
|
||||
EventKind.WARN, EventCategory.CONNECTION, context.getString(R.string.event_restore_pw_title, name),
|
||||
context.getString(R.string.event_restore_pw_text), EventAction.OPEN_SERVER, "restore-pw:$uuid", uuid,
|
||||
)
|
||||
}
|
||||
|
||||
/** The reserve or bridge [target] of [origin] has no saved password, so no switch. */
|
||||
@JvmStatic
|
||||
fun switchNeedsPassword(context: Context, targetUuid: String, target: String, origin: String, bridge: Boolean)
|
||||
{
|
||||
EventLog.getInstance(context).record(
|
||||
EventKind.WARN, EventCategory.CONNECTION, context.getString(R.string.event_switch_pw_title, target),
|
||||
context.getString(if (bridge) R.string.event_switch_pw_bridge else R.string.event_switch_pw_reserve, origin),
|
||||
EventAction.OPEN_SERVER, "switch-pw:$targetUuid", targetUuid,
|
||||
)
|
||||
}
|
||||
|
||||
/** A network rule or a scenario could not start [name]: no saved password. */
|
||||
@JvmStatic
|
||||
fun autoNeedsPassword(context: Context, uuid: String, name: String)
|
||||
{
|
||||
EventLog.getInstance(context).record(
|
||||
EventKind.WARN, EventCategory.CONNECTION, context.getString(R.string.event_auto_pw_title, name),
|
||||
context.getString(R.string.event_auto_pw_text), EventAction.OPEN_SERVER, "auto-pw:$uuid", uuid,
|
||||
)
|
||||
}
|
||||
}
|
||||
+238
@@ -0,0 +1,238 @@
|
||||
/*
|
||||
* Copyright (C) 2026 SHX VPN
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or modify it
|
||||
* under the terms of the GNU General Public License as published by the
|
||||
* Free Software Foundation; either version 2 of the License, or (at your
|
||||
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful, but
|
||||
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
||||
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
|
||||
* for more details.
|
||||
*/
|
||||
|
||||
package org.strongswan.android.logic.profiles
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
|
||||
/** Where a profile came from (F_Profile «Profile source»). */
|
||||
enum class ProfileSourceType { LINK, FILE, QR, MANUAL, UNKNOWN }
|
||||
|
||||
/**
|
||||
* What the app keeps about a profile besides the strongSwan profile itself:
|
||||
* its source (and the update link), and the resilience settings of F_Profile.
|
||||
*/
|
||||
data class ProfileExtra(
|
||||
val uuid: String,
|
||||
val source: ProfileSourceType = ProfileSourceType.UNKNOWN,
|
||||
/** https link for LINK, the file name for FILE */
|
||||
val sourceValue: String? = null,
|
||||
val addedAt: Long = 0L,
|
||||
val autoUpdate: Boolean = true,
|
||||
val checkedAt: Long = 0L,
|
||||
/** number of changes found by the last check, -1 when it failed */
|
||||
val lastChanges: Int = 0,
|
||||
/** ports tried in turn when the main one does not answer */
|
||||
val backupPorts: List<Int> = emptyList(),
|
||||
/** profile used when this one does not connect in [RESERVE_TIMEOUT_S] */
|
||||
val reserveUuid: String? = null,
|
||||
/** profile switched to while the operator whitelists are on */
|
||||
val bridgeUuid: String? = null,
|
||||
val bridgeEnabled: Boolean = false,
|
||||
/** IKE fragmentation (charon «fragmentation = yes»), on unless switched off */
|
||||
val ikeFragmentation: Boolean = true,
|
||||
)
|
||||
|
||||
const val RESERVE_TIMEOUT_S = 15
|
||||
|
||||
/** Store of [ProfileExtra] (profile_extras.json), observable. */
|
||||
object ProfileExtras
|
||||
{
|
||||
private const val TAG = "ProfileExtras"
|
||||
private const val FILE_NAME = "profile_extras.json"
|
||||
|
||||
private val _all = MutableStateFlow<Map<String, ProfileExtra>>(emptyMap())
|
||||
val all: StateFlow<Map<String, ProfileExtra>> = _all
|
||||
@Volatile private var loaded = false
|
||||
|
||||
@JvmStatic
|
||||
fun get(context: Context, uuid: String): ProfileExtra
|
||||
{
|
||||
load(context)
|
||||
return _all.value[uuid] ?: ProfileExtra(uuid)
|
||||
}
|
||||
|
||||
fun flow(context: Context): StateFlow<Map<String, ProfileExtra>>
|
||||
{
|
||||
load(context)
|
||||
return all
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun update(context: Context, uuid: String, transform: (ProfileExtra) -> ProfileExtra)
|
||||
{
|
||||
load(context)
|
||||
_all.update { it + (uuid to transform(it[uuid] ?: ProfileExtra(uuid))) }
|
||||
save(context)
|
||||
}
|
||||
|
||||
/**
|
||||
* A profile was imported: remember where from. A re-import from the
|
||||
* same kind of source keeps the user's settings (auto update, resilience).
|
||||
*/
|
||||
@JvmStatic
|
||||
fun recordImport(context: Context, uuid: String, scheme: String?, uri: String?, qr: Boolean)
|
||||
{
|
||||
val type = when
|
||||
{
|
||||
qr -> ProfileSourceType.QR
|
||||
scheme == "http" || scheme == "https" -> ProfileSourceType.LINK
|
||||
else -> ProfileSourceType.FILE
|
||||
}
|
||||
val value = when (type)
|
||||
{
|
||||
ProfileSourceType.LINK -> uri
|
||||
ProfileSourceType.FILE -> uri?.substringAfterLast('/')?.substringAfterLast("%2F")?.takeIf { it.isNotBlank() }
|
||||
else -> null
|
||||
}
|
||||
update(context, uuid) {
|
||||
/* a link stays the update source even when the new copy came from a file */
|
||||
if (it.source == ProfileSourceType.LINK && type != ProfileSourceType.LINK) it.copy(addedAt = System.currentTimeMillis())
|
||||
else it.copy(source = type, sourceValue = value, addedAt = System.currentTimeMillis(), checkedAt = if (type == ProfileSourceType.LINK) System.currentTimeMillis() else it.checkedAt, lastChanges = 0)
|
||||
}
|
||||
}
|
||||
|
||||
/** A profile created by hand (when nothing is recorded for it yet). */
|
||||
@JvmStatic
|
||||
fun recordManual(context: Context, uuid: String)
|
||||
{
|
||||
load(context)
|
||||
if (_all.value[uuid]?.source.let { it == null || it == ProfileSourceType.UNKNOWN })
|
||||
{
|
||||
update(context, uuid) { it.copy(source = ProfileSourceType.MANUAL, addedAt = System.currentTimeMillis()) }
|
||||
}
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun remove(context: Context, uuid: String)
|
||||
{
|
||||
load(context)
|
||||
_all.update { it - uuid }
|
||||
save(context)
|
||||
}
|
||||
|
||||
private fun load(context: Context)
|
||||
{
|
||||
if (loaded)
|
||||
{
|
||||
return
|
||||
}
|
||||
synchronized(this)
|
||||
{
|
||||
if (loaded)
|
||||
{
|
||||
return
|
||||
}
|
||||
try
|
||||
{
|
||||
val file = File(context.filesDir, FILE_NAME)
|
||||
if (file.exists())
|
||||
{
|
||||
val array = JSONArray(file.readText())
|
||||
_all.value = (0 until array.length()).map { fromJson(array.getJSONObject(it)) }.associateBy { it.uuid }
|
||||
}
|
||||
}
|
||||
catch (e: Exception)
|
||||
{
|
||||
Log.w(TAG, "Loading failed", e)
|
||||
}
|
||||
loaded = true
|
||||
}
|
||||
}
|
||||
|
||||
private fun save(context: Context)
|
||||
{
|
||||
try
|
||||
{
|
||||
val array = JSONArray()
|
||||
_all.value.values.forEach { array.put(toJson(it)) }
|
||||
val file = File(context.filesDir, FILE_NAME)
|
||||
val tmp = File(file.path + ".tmp")
|
||||
tmp.writeText(array.toString())
|
||||
if (!tmp.renameTo(file))
|
||||
{
|
||||
file.writeText(array.toString())
|
||||
tmp.delete()
|
||||
}
|
||||
}
|
||||
catch (e: Exception)
|
||||
{
|
||||
Log.w(TAG, "Saving failed", e)
|
||||
}
|
||||
}
|
||||
|
||||
internal fun toJson(e: ProfileExtra): JSONObject = JSONObject()
|
||||
.put("uuid", e.uuid).put("source", e.source.name).put("value", e.sourceValue ?: JSONObject.NULL)
|
||||
.put("added", e.addedAt).put("auto", e.autoUpdate).put("checked", e.checkedAt).put("changes", e.lastChanges)
|
||||
.put("ports", JSONArray(e.backupPorts)).put("reserve", e.reserveUuid ?: JSONObject.NULL)
|
||||
.put("bridge", e.bridgeUuid ?: JSONObject.NULL).put("bridgeOn", e.bridgeEnabled).put("frag", e.ikeFragmentation)
|
||||
|
||||
internal fun fromJson(o: JSONObject): ProfileExtra
|
||||
{
|
||||
val ports = o.optJSONArray("ports")
|
||||
return ProfileExtra(
|
||||
uuid = o.getString("uuid"),
|
||||
source = runCatching { ProfileSourceType.valueOf(o.optString("source")) }.getOrDefault(ProfileSourceType.UNKNOWN),
|
||||
sourceValue = o.optString("value").takeIf { !o.isNull("value") && it.isNotEmpty() },
|
||||
addedAt = o.optLong("added"),
|
||||
autoUpdate = o.optBoolean("auto", true),
|
||||
checkedAt = o.optLong("checked"),
|
||||
lastChanges = o.optInt("changes"),
|
||||
backupPorts = if (ports == null) emptyList() else (0 until ports.length()).map { ports.getInt(it) },
|
||||
reserveUuid = o.optString("reserve").takeIf { !o.isNull("reserve") && it.isNotEmpty() },
|
||||
bridgeUuid = o.optString("bridge").takeIf { !o.isNull("bridge") && it.isNotEmpty() },
|
||||
bridgeEnabled = o.optBoolean("bridgeOn"),
|
||||
ikeFragmentation = o.optBoolean("frag", true),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** The profile signs in with a password and none is saved: nobody can start it unattended. */
|
||||
fun org.strongswan.android.data.VpnProfile.needsPassword(): Boolean =
|
||||
vpnType.has(org.strongswan.android.data.VpnType.VpnTypeFeature.USER_PASS) && password.isNullOrEmpty()
|
||||
|
||||
/**
|
||||
* For unattended starts (network rules, scenarios): true when [uuid] signs in
|
||||
* with an unsaved password - the start would only end in «password missing».
|
||||
* Records one event pointing at the server.
|
||||
*/
|
||||
fun blockedByPassword(context: android.content.Context, uuid: String): Boolean
|
||||
{
|
||||
val profile = runCatching {
|
||||
val source = org.strongswan.android.data.VpnProfileSource(context)
|
||||
source.open()
|
||||
try
|
||||
{
|
||||
source.getVpnProfile(java.util.UUID.fromString(uuid))
|
||||
}
|
||||
finally
|
||||
{
|
||||
source.close()
|
||||
}
|
||||
}.getOrNull() ?: return false
|
||||
if (!profile.needsPassword())
|
||||
{
|
||||
return false
|
||||
}
|
||||
org.strongswan.android.logic.DiagLog.i("ProfileExtras", "${profile.name} needs a password, no unattended start")
|
||||
org.strongswan.android.logic.events.Events.autoNeedsPassword(context, uuid, profile.name ?: profile.gateway)
|
||||
return true
|
||||
}
|
||||
+292
@@ -0,0 +1,292 @@
|
||||
/*
|
||||
* Copyright (C) 2026 SHX VPN
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or modify it
|
||||
* under the terms of the GNU General Public License as published by the
|
||||
* Free Software Foundation; either version 2 of the License, or (at your
|
||||
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful, but
|
||||
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
||||
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
|
||||
* for more details.
|
||||
*/
|
||||
|
||||
package org.strongswan.android.logic.profiles
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.util.Base64
|
||||
import androidx.localbroadcastmanager.content.LocalBroadcastManager
|
||||
import androidx.work.Constraints
|
||||
import androidx.work.CoroutineWorker
|
||||
import androidx.work.ExistingPeriodicWorkPolicy
|
||||
import androidx.work.NetworkType
|
||||
import androidx.work.PeriodicWorkRequestBuilder
|
||||
import androidx.work.WorkManager
|
||||
import androidx.work.WorkerParameters
|
||||
import org.json.JSONObject
|
||||
import org.strongswan.android.R
|
||||
import org.strongswan.android.data.VpnProfile
|
||||
import org.strongswan.android.data.VpnProfileSource
|
||||
import org.strongswan.android.logic.DiagLog
|
||||
import org.strongswan.android.logic.TrustedCertificateManager
|
||||
import org.strongswan.android.logic.VpnStateService
|
||||
import org.strongswan.android.logic.events.EventAction
|
||||
import org.strongswan.android.logic.events.EventCategory
|
||||
import org.strongswan.android.logic.events.EventKind
|
||||
import org.strongswan.android.logic.events.EventLog
|
||||
import org.strongswan.android.utils.Constants
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
import java.security.KeyStore
|
||||
import java.security.cert.CertificateFactory
|
||||
import java.security.cert.X509Certificate
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Locale
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/** One changed field (F_ProfileUpdate): label, old and new value as shown. */
|
||||
data class ProfileChange(val label: String, val old: String, val new: String)
|
||||
|
||||
/** The server's copy of a profile and how it differs from the local one. */
|
||||
class ProfileCheck(
|
||||
val uuid: String,
|
||||
val changes: List<ProfileChange>,
|
||||
internal val gateway: String,
|
||||
internal val port: Int?,
|
||||
internal val remoteId: String?,
|
||||
internal val certificate: X509Certificate?,
|
||||
)
|
||||
|
||||
internal enum class ProfileField { ADDRESS, PORT, REMOTE_ID, CA }
|
||||
|
||||
/** A raw difference: CA values are the expiry dates. */
|
||||
internal data class FieldChange(val field: ProfileField, val old: Any?, val new: Any?)
|
||||
|
||||
/** The server-side fields of a downloaded .sswan. */
|
||||
internal data class RemoteProfile(val gateway: String, val port: Int?, val remoteId: String?, val certificate: X509Certificate?)
|
||||
|
||||
internal fun parseRemote(text: String, decode: (String) -> ByteArray): RemoteProfile
|
||||
{
|
||||
val remote = JSONObject(text).getJSONObject("remote")
|
||||
return RemoteProfile(
|
||||
gateway = remote.getString("addr"),
|
||||
port = remote.optInt("port").takeIf { it in 1..65535 },
|
||||
remoteId = remote.optString("id").ifEmpty { null },
|
||||
certificate = remote.optString("cert").ifEmpty { null }?.let {
|
||||
CertificateFactory.getInstance("X.509").generateCertificate(ByteArrayInputStream(decode(it))) as X509Certificate
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/** What changes when the local profile takes the [remote] fields (port 4500 when unset). */
|
||||
internal fun diff(gateway: String?, port: Int?, remoteId: String?, currentCa: X509Certificate?, remote: RemoteProfile): List<FieldChange>
|
||||
{
|
||||
val changes = ArrayList<FieldChange>()
|
||||
if (remote.gateway != gateway)
|
||||
{
|
||||
changes += FieldChange(ProfileField.ADDRESS, gateway, remote.gateway)
|
||||
}
|
||||
if ((remote.port ?: 4500) != (port ?: 4500))
|
||||
{
|
||||
changes += FieldChange(ProfileField.PORT, port ?: 4500, remote.port ?: 4500)
|
||||
}
|
||||
if (remote.remoteId != null && remote.remoteId != remoteId)
|
||||
{
|
||||
changes += FieldChange(ProfileField.REMOTE_ID, remoteId ?: gateway, remote.remoteId)
|
||||
}
|
||||
val cert = remote.certificate
|
||||
if (cert != null && (currentCa == null || !currentCa.encoded.contentEquals(cert.encoded)))
|
||||
{
|
||||
changes += FieldChange(ProfileField.CA, currentCa?.notAfter, cert.notAfter)
|
||||
}
|
||||
return changes
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-reads a profile from its link (F_Profile «Check now», the daily
|
||||
* check) and applies the server-side fields: address, port, remote
|
||||
* identity and CA certificate. The login, DNS, resilience settings,
|
||||
* routing rules and apps stay as they are.
|
||||
*/
|
||||
object ProfileUpdater
|
||||
{
|
||||
private const val TAG = "ProfileUpdater"
|
||||
private const val WORK_NAME = "profile-update-check"
|
||||
|
||||
/** Downloads and compares; throws on network or format errors. */
|
||||
@JvmStatic
|
||||
fun check(context: Context, uuid: String): ProfileCheck
|
||||
{
|
||||
val extra = ProfileExtras.get(context, uuid)
|
||||
val link = extra.sourceValue?.takeIf { extra.source == ProfileSourceType.LINK } ?: throw IllegalStateException("no link")
|
||||
val profile = loadProfile(context, uuid) ?: throw IllegalStateException("no profile")
|
||||
val text = download(link)
|
||||
val remote = parseRemote(text) { Base64.decode(it, Base64.DEFAULT) }
|
||||
val currentCa = profile.certificateAlias?.let { TrustedCertificateManager.getInstance().load().getCACertificateFromAlias(it) }
|
||||
val until = SimpleDateFormat("MM.yyyy", Locale.getDefault())
|
||||
val changes = diff(profile.gateway, profile.port, profile.remoteId, currentCa, remote).map {
|
||||
val label = context.getString(
|
||||
when (it.field)
|
||||
{
|
||||
ProfileField.ADDRESS -> R.string.profile_update_addr
|
||||
ProfileField.PORT -> R.string.profile_update_port
|
||||
ProfileField.REMOTE_ID -> R.string.profile_update_id
|
||||
ProfileField.CA -> R.string.profile_update_ca
|
||||
}
|
||||
)
|
||||
fun show(value: Any?): String = when (value)
|
||||
{
|
||||
null -> "—"
|
||||
is java.util.Date -> context.getString(R.string.profile_update_until, until.format(value))
|
||||
else -> value.toString()
|
||||
}
|
||||
ProfileChange(label, show(it.old), show(it.new))
|
||||
}
|
||||
val gateway = remote.gateway
|
||||
val port = remote.port
|
||||
val remoteId = remote.remoteId
|
||||
val certificate = remote.certificate
|
||||
ProfileExtras.update(context, uuid) { it.copy(checkedAt = System.currentTimeMillis(), lastChanges = changes.size) }
|
||||
return ProfileCheck(uuid, changes, gateway, port, remoteId, certificate)
|
||||
}
|
||||
|
||||
/** Writes the server-side fields and reconnects when the profile is in use. */
|
||||
@JvmStatic
|
||||
fun apply(context: Context, check: ProfileCheck)
|
||||
{
|
||||
val source = VpnProfileSource(context)
|
||||
source.open()
|
||||
try
|
||||
{
|
||||
val profile = source.getVpnProfile(UUID.fromString(check.uuid)) ?: return
|
||||
profile.gateway = check.gateway
|
||||
profile.port = check.port
|
||||
if (check.remoteId != null)
|
||||
{
|
||||
profile.remoteId = check.remoteId
|
||||
}
|
||||
check.certificate?.let { cert ->
|
||||
val store = KeyStore.getInstance("LocalCertificateStore")
|
||||
store.load(null, null)
|
||||
store.setCertificateEntry(null, cert)
|
||||
profile.certificateAlias = store.getCertificateAlias(cert)
|
||||
TrustedCertificateManager.getInstance().reset()
|
||||
}
|
||||
source.updateVpnProfile(profile)
|
||||
}
|
||||
finally
|
||||
{
|
||||
source.close()
|
||||
}
|
||||
ProfileExtras.update(context, check.uuid) { it.copy(lastChanges = 0) }
|
||||
val intent = Intent(Constants.VPN_PROFILES_CHANGED).putExtra(Constants.VPN_PROFILES_SINGLE, check.uuid)
|
||||
LocalBroadcastManager.getInstance(context).sendBroadcast(intent)
|
||||
EventLog.getInstance(context).resolve("profile-update:${check.uuid}")
|
||||
DiagLog.i(TAG, "profile ${check.uuid} updated from its link (${check.changes.size} changes)")
|
||||
/* the running tunnel still uses the old settings */
|
||||
val service = VpnStateService.getLiveInstance()
|
||||
if (service != null && service.profile?.getUUID()?.toString() == check.uuid && service.state != VpnStateService.State.DISABLED)
|
||||
{
|
||||
service.reconnect()
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadProfile(context: Context, uuid: String): VpnProfile?
|
||||
{
|
||||
val source = VpnProfileSource(context)
|
||||
source.open()
|
||||
try
|
||||
{
|
||||
return source.getVpnProfile(UUID.fromString(uuid))
|
||||
}
|
||||
finally
|
||||
{
|
||||
source.close()
|
||||
}
|
||||
}
|
||||
|
||||
private fun download(link: String): String
|
||||
{
|
||||
val connection = URL(link).openConnection() as HttpURLConnection
|
||||
connection.connectTimeout = 15_000
|
||||
connection.readTimeout = 15_000
|
||||
try
|
||||
{
|
||||
if (connection.responseCode !in 200..299)
|
||||
{
|
||||
throw IllegalStateException("HTTP ${connection.responseCode}")
|
||||
}
|
||||
/* a profile is a few KiB; anything huge is not one */
|
||||
return connection.inputStream.use { String(it.readNBytesCompat(512 * 1024), Charsets.UTF_8) }
|
||||
}
|
||||
finally
|
||||
{
|
||||
connection.disconnect()
|
||||
}
|
||||
}
|
||||
|
||||
private fun java.io.InputStream.readNBytesCompat(max: Int): ByteArray
|
||||
{
|
||||
val out = java.io.ByteArrayOutputStream()
|
||||
val buffer = ByteArray(8192)
|
||||
while (out.size() < max)
|
||||
{
|
||||
val n = read(buffer)
|
||||
if (n < 0)
|
||||
{
|
||||
break
|
||||
}
|
||||
out.write(buffer, 0, n)
|
||||
}
|
||||
return out.toByteArray()
|
||||
}
|
||||
|
||||
/** Daily check of the profiles with a link and «Update automatically» on. */
|
||||
@JvmStatic
|
||||
fun schedule(context: Context)
|
||||
{
|
||||
val request = PeriodicWorkRequestBuilder<ProfileUpdateWorker>(24, TimeUnit.HOURS)
|
||||
.setConstraints(Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build())
|
||||
.build()
|
||||
WorkManager.getInstance(context).enqueueUniquePeriodicWork(WORK_NAME, ExistingPeriodicWorkPolicy.KEEP, request)
|
||||
}
|
||||
|
||||
internal fun checkAll(context: Context)
|
||||
{
|
||||
val due = ProfileExtras.flow(context).value.values.filter { it.source == ProfileSourceType.LINK && it.autoUpdate }
|
||||
for (extra in due)
|
||||
{
|
||||
try
|
||||
{
|
||||
val check = check(context, extra.uuid)
|
||||
if (check.changes.isNotEmpty())
|
||||
{
|
||||
val name = loadProfile(context, extra.uuid)?.name ?: extra.uuid
|
||||
EventLog.getInstance(context).recordOnce(
|
||||
EventKind.ASK, EventCategory.CONNECTION, context.getString(R.string.event_profile_update_title, name),
|
||||
context.getString(R.string.event_profile_update_text, check.changes.size), EventAction.PROFILE_UPDATE,
|
||||
"profile-update:${extra.uuid}", extra.uuid,
|
||||
)
|
||||
}
|
||||
}
|
||||
catch (e: Exception)
|
||||
{
|
||||
ProfileExtras.update(context, extra.uuid) { it.copy(checkedAt = System.currentTimeMillis(), lastChanges = -1) }
|
||||
DiagLog.i(TAG, "profile check failed: $e")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class ProfileUpdateWorker(context: Context, params: WorkerParameters) : CoroutineWorker(context, params)
|
||||
{
|
||||
override suspend fun doWork(): Result
|
||||
{
|
||||
ProfileUpdater.checkAll(applicationContext)
|
||||
return Result.success()
|
||||
}
|
||||
}
|
||||
+196
@@ -0,0 +1,196 @@
|
||||
/*
|
||||
* Copyright (C) 2026 SHX VPN
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or modify it
|
||||
* under the terms of the GNU General Public License as published by the
|
||||
* Free Software Foundation; either version 2 of the License, or (at your
|
||||
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful, but
|
||||
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
||||
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
|
||||
* for more details.
|
||||
*/
|
||||
|
||||
package org.strongswan.android.logic.profiles
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Bundle
|
||||
import org.strongswan.android.R
|
||||
import org.strongswan.android.data.VpnProfileDataSource
|
||||
import org.strongswan.android.data.VpnProfileSource
|
||||
import org.strongswan.android.data.VpnType
|
||||
import org.strongswan.android.logic.DiagLog
|
||||
import org.strongswan.android.logic.VpnStateService
|
||||
import org.strongswan.android.logic.events.EventCategory
|
||||
import org.strongswan.android.logic.events.EventKind
|
||||
import org.strongswan.android.logic.events.EventLog
|
||||
import org.strongswan.android.logic.events.Events
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* The resilience settings of F_Profile at work. When the server does not
|
||||
* answer, the backup ports are tried in turn, then the reserve server; only
|
||||
* then the regular retry backoff takes over. While the operator whitelists
|
||||
* are on, a profile with «Bridge for whitelists» switches to its bridge and
|
||||
* comes back when they clear.
|
||||
*
|
||||
* Called on the main thread by [VpnStateService] and the whitelist detector.
|
||||
*/
|
||||
object Resilience
|
||||
{
|
||||
private const val TAG = "Resilience"
|
||||
/** marks the connects started here (they keep the attempt state) */
|
||||
const val KEY_INTERNAL = "org.strongswan.android.resilience.INTERNAL"
|
||||
/** port used instead of the profile's for this connect */
|
||||
const val KEY_PORT_OVERRIDE = "org.strongswan.android.resilience.PORT"
|
||||
|
||||
/* the profile the user asked for and where the attempt stands */
|
||||
private var origin: String? = null
|
||||
private var portIndex = -1
|
||||
private var onReserve = false
|
||||
/* set while switched to the bridge: the profile to come back to */
|
||||
private var bridgeOrigin: String? = null
|
||||
|
||||
/** Every connect request; a user's connect starts a new attempt. */
|
||||
@JvmStatic
|
||||
fun onConnect(uuid: String?, info: Bundle)
|
||||
{
|
||||
if (info.getBoolean(KEY_INTERNAL, false))
|
||||
{
|
||||
return
|
||||
}
|
||||
origin = uuid
|
||||
portIndex = -1
|
||||
onReserve = false
|
||||
bridgeOrigin = null
|
||||
}
|
||||
|
||||
/**
|
||||
* The server of the current attempt does not answer. Returns true when
|
||||
* another attempt was started (the regular retry stays off).
|
||||
*/
|
||||
@JvmStatic
|
||||
fun onUnreachable(service: VpnStateService): Boolean
|
||||
{
|
||||
val current = service.profile ?: return false
|
||||
val start = origin ?: current.getUUID().toString().also { origin = it }
|
||||
val extra = ProfileExtras.get(service, start)
|
||||
if (!onReserve && portIndex + 1 < extra.backupPorts.size)
|
||||
{
|
||||
portIndex++
|
||||
val port = extra.backupPorts[portIndex]
|
||||
DiagLog.i(TAG, "server does not answer, trying backup port $port")
|
||||
connect(service, start, current.password.takeIf { current.getUUID().toString() == start }, port)
|
||||
return true
|
||||
}
|
||||
val reserve = extra.reserveUuid
|
||||
if (!onReserve && reserve != null && reserve != start)
|
||||
{
|
||||
val profile = load(service, reserve) ?: return false
|
||||
if (profile.needsPassword())
|
||||
{ /* the reserve needs a password nobody can type in now: say so once */
|
||||
DiagLog.i(TAG, "reserve ${profile.name} needs a password, not switching")
|
||||
onReserve = true
|
||||
Events.switchNeedsPassword(service, reserve, profile.name ?: profile.gateway, load(service, start)?.name ?: "", bridge = false)
|
||||
return false
|
||||
}
|
||||
onReserve = true
|
||||
DiagLog.i(TAG, "server does not answer, switching to the reserve ${profile.name}")
|
||||
EventLog.getInstance(service).record(
|
||||
EventKind.INFO, EventCategory.CONNECTION,
|
||||
service.getString(R.string.event_reserve_title, profile.name ?: profile.gateway),
|
||||
service.getString(R.string.event_reserve_text, load(service, start)?.name ?: ""), key = "reserve:$start",
|
||||
)
|
||||
connect(service, reserve, null, null)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/** Connected: tell when it took a backup port. */
|
||||
@JvmStatic
|
||||
fun onConnected(service: VpnStateService)
|
||||
{
|
||||
val start = origin ?: return
|
||||
if (!onReserve && portIndex >= 0)
|
||||
{
|
||||
val port = ProfileExtras.get(service, start).backupPorts.getOrNull(portIndex) ?: return
|
||||
EventLog.getInstance(service).record(
|
||||
EventKind.INFO, EventCategory.CONNECTION, service.getString(R.string.event_backup_port_title, port),
|
||||
service.getString(R.string.event_backup_port_text), key = "port:$start",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** The operator whitelists came or went (WhiteListManager). */
|
||||
@JvmStatic
|
||||
fun onWhitelist(context: Context, active: Boolean)
|
||||
{
|
||||
val service = VpnStateService.getLiveInstance() ?: return
|
||||
val current = service.profile?.getUUID()?.toString() ?: return
|
||||
if (active)
|
||||
{
|
||||
val extra = ProfileExtras.get(context, current)
|
||||
val bridge = extra.bridgeUuid?.takeIf { extra.bridgeEnabled && it != current } ?: return
|
||||
if (service.state == VpnStateService.State.DISABLED && service.errorState == VpnStateService.ErrorState.NO_ERROR)
|
||||
{ /* the VPN is off on purpose */
|
||||
return
|
||||
}
|
||||
val profile = load(context, bridge) ?: return
|
||||
if (profile.needsPassword())
|
||||
{
|
||||
DiagLog.i(TAG, "bridge ${profile.name} needs a password, not switching")
|
||||
Events.switchNeedsPassword(context, bridge, profile.name ?: profile.gateway, service.profile?.name ?: "", bridge = true)
|
||||
return
|
||||
}
|
||||
DiagLog.i(TAG, "whitelists on, switching to the bridge ${profile.name}")
|
||||
EventLog.getInstance(context).record(
|
||||
EventKind.INFO, EventCategory.CONNECTION, context.getString(R.string.event_bridge_on_title, profile.name ?: profile.gateway),
|
||||
context.getString(R.string.event_bridge_on_text), key = "bridge",
|
||||
)
|
||||
connect(service, bridge, null, null)
|
||||
bridgeOrigin = current
|
||||
}
|
||||
else
|
||||
{
|
||||
val back = bridgeOrigin ?: return
|
||||
bridgeOrigin = null
|
||||
if (service.state == VpnStateService.State.DISABLED && service.errorState == VpnStateService.ErrorState.NO_ERROR)
|
||||
{
|
||||
return
|
||||
}
|
||||
val profile = load(context, back) ?: return
|
||||
DiagLog.i(TAG, "whitelists off, back to ${profile.name}")
|
||||
EventLog.getInstance(context).resolve("bridge")
|
||||
EventLog.getInstance(context).record(
|
||||
EventKind.OK, EventCategory.CONNECTION, context.getString(R.string.event_bridge_off_title, profile.name ?: profile.gateway),
|
||||
context.getString(R.string.event_bridge_off_text), key = "bridge-off",
|
||||
)
|
||||
connect(service, back, null, null)
|
||||
}
|
||||
}
|
||||
|
||||
private fun connect(service: VpnStateService, uuid: String, password: String?, port: Int?)
|
||||
{
|
||||
val info = Bundle()
|
||||
info.putString(VpnProfileDataSource.KEY_UUID, uuid)
|
||||
password?.let { info.putString(VpnProfileDataSource.KEY_PASSWORD, it) }
|
||||
port?.let { info.putInt(KEY_PORT_OVERRIDE, it) }
|
||||
info.putBoolean(KEY_INTERNAL, true)
|
||||
service.connect(info, true)
|
||||
}
|
||||
|
||||
private fun load(context: Context, uuid: String) = runCatching {
|
||||
val source = VpnProfileSource(context)
|
||||
source.open()
|
||||
try
|
||||
{
|
||||
source.getVpnProfile(UUID.fromString(uuid))
|
||||
}
|
||||
finally
|
||||
{
|
||||
source.close()
|
||||
}
|
||||
}.getOrNull()
|
||||
}
|
||||
+116
@@ -0,0 +1,116 @@
|
||||
/*
|
||||
* Copyright (C) 2026 SHX VPN
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or modify it
|
||||
* under the terms of the GNU General Public License as published by the
|
||||
* Free Software Foundation; either version 2 of the License, or (at your
|
||||
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful, but
|
||||
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
||||
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
|
||||
* for more details.
|
||||
*/
|
||||
|
||||
package org.strongswan.android.logic.protection
|
||||
|
||||
import android.content.Context
|
||||
import androidx.preference.PreferenceManager
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
/** The DNS server of the tunnel (F_Protection «DNS server»). */
|
||||
enum class DnsChoice { PROFILE, CLOUDFLARE, ADGUARD, CUSTOM }
|
||||
|
||||
/**
|
||||
* DNS and blocking (F_Protection). The blocking is done by the resolver
|
||||
* itself, so it follows from the choice: AdGuard DNS blocks ads, trackers
|
||||
* and phishing together, Cloudflare's 1.1.1.2 blocks malware and phishing
|
||||
* only. The servers replace the profile's ones on the next connect.
|
||||
*/
|
||||
data class ProtectionState(
|
||||
val choice: DnsChoice = DnsChoice.PROFILE,
|
||||
/** with CLOUDFLARE: the malware / phishing filtering resolver */
|
||||
val phishingFilter: Boolean = false,
|
||||
val custom: String = "",
|
||||
)
|
||||
{
|
||||
val blocksAds: Boolean get() = choice == DnsChoice.ADGUARD
|
||||
val blocksTrackers: Boolean get() = choice == DnsChoice.ADGUARD
|
||||
val blocksPhishing: Boolean get() = choice == DnsChoice.ADGUARD || (choice == DnsChoice.CLOUDFLARE && phishingFilter)
|
||||
|
||||
/** null: keep the profile's (or the server's) DNS */
|
||||
fun servers(): List<String>? = when (choice)
|
||||
{
|
||||
DnsChoice.PROFILE -> null
|
||||
DnsChoice.ADGUARD -> listOf("94.140.14.14", "94.140.15.15", "2a10:50c0::ad1:ff", "2a10:50c0::ad2:ff")
|
||||
DnsChoice.CLOUDFLARE -> if (phishingFilter) listOf("1.1.1.2", "1.0.0.2", "2606:4700:4700::1112", "2606:4700:4700::1002")
|
||||
else listOf("1.1.1.1", "1.0.0.1", "2606:4700:4700::1111", "2606:4700:4700::1001")
|
||||
DnsChoice.CUSTOM -> parseCustom(custom).takeIf { it.isNotEmpty() }
|
||||
}
|
||||
|
||||
fun withAds(on: Boolean): ProtectionState = when
|
||||
{
|
||||
on -> copy(choice = DnsChoice.ADGUARD)
|
||||
choice == DnsChoice.ADGUARD -> copy(choice = DnsChoice.CLOUDFLARE, phishingFilter = true)
|
||||
else -> this
|
||||
}
|
||||
|
||||
fun withPhishing(on: Boolean): ProtectionState = when
|
||||
{
|
||||
on && choice == DnsChoice.ADGUARD -> this
|
||||
on -> copy(choice = DnsChoice.CLOUDFLARE, phishingFilter = true)
|
||||
choice == DnsChoice.ADGUARD -> copy(choice = DnsChoice.CLOUDFLARE, phishingFilter = false)
|
||||
else -> copy(phishingFilter = false)
|
||||
}
|
||||
|
||||
companion object
|
||||
{
|
||||
/** «1.2.3.4, 2001:db8::1» → the addresses that parse as IPs */
|
||||
fun parseCustom(text: String): List<String> = text.split(',', ' ', ';').map { it.trim() }.filter { it.isNotEmpty() }.filter { isIp(it) }
|
||||
|
||||
private fun isIp(s: String): Boolean =
|
||||
Regex("""^(\d{1,3})(\.\d{1,3}){3}$""").matches(s) && s.split('.').all { it.toInt() in 0..255 } ||
|
||||
(s.contains(':') && Regex("""^[0-9a-fA-F:]+$""").matches(s))
|
||||
}
|
||||
}
|
||||
|
||||
object Protection
|
||||
{
|
||||
private const val PREF_CHOICE = "pref_dns_choice"
|
||||
private const val PREF_PHISHING = "pref_dns_phishing"
|
||||
private const val PREF_CUSTOM = "pref_dns_custom"
|
||||
|
||||
private val _state = MutableStateFlow<ProtectionState?>(null)
|
||||
|
||||
@JvmStatic
|
||||
fun get(context: Context): ProtectionState
|
||||
{
|
||||
_state.value?.let { return it }
|
||||
val prefs = PreferenceManager.getDefaultSharedPreferences(context)
|
||||
val state = ProtectionState(
|
||||
choice = runCatching { DnsChoice.valueOf(prefs.getString(PREF_CHOICE, DnsChoice.PROFILE.name)!!) }.getOrDefault(DnsChoice.PROFILE),
|
||||
phishingFilter = prefs.getBoolean(PREF_PHISHING, false),
|
||||
custom = prefs.getString(PREF_CUSTOM, "") ?: "",
|
||||
)
|
||||
_state.value = state
|
||||
return state
|
||||
}
|
||||
|
||||
fun flow(context: Context): StateFlow<ProtectionState?>
|
||||
{
|
||||
get(context)
|
||||
return _state
|
||||
}
|
||||
|
||||
fun set(context: Context, state: ProtectionState)
|
||||
{
|
||||
PreferenceManager.getDefaultSharedPreferences(context).edit()
|
||||
.putString(PREF_CHOICE, state.choice.name).putBoolean(PREF_PHISHING, state.phishingFilter).putString(PREF_CUSTOM, state.custom).apply()
|
||||
_state.value = state
|
||||
}
|
||||
|
||||
/** For CharonVpnService: the servers replacing the profile's ones, or null. */
|
||||
@JvmStatic
|
||||
fun dnsServers(context: Context): List<String>? = get(context).servers()
|
||||
}
|
||||
+114
@@ -0,0 +1,114 @@
|
||||
/*
|
||||
* Copyright (C) 2026 SHX VPN
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or modify it
|
||||
* under the terms of the GNU General Public License as published by the
|
||||
* Free Software Foundation; either version 2 of the License, or (at your
|
||||
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful, but
|
||||
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
||||
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
|
||||
* for more details.
|
||||
*/
|
||||
|
||||
package org.strongswan.android.logic.routing
|
||||
|
||||
import android.app.AppOpsManager
|
||||
import android.app.usage.NetworkStats
|
||||
import android.app.usage.NetworkStatsManager
|
||||
import android.content.Context
|
||||
import android.net.ConnectivityManager
|
||||
import android.os.Build
|
||||
import android.os.Process
|
||||
import android.util.Log
|
||||
|
||||
/**
|
||||
* Per-app network traffic from the system statistics ("Usage access"), for
|
||||
* the «Recent apps» list. Android attributes tunneled traffic back to the
|
||||
* app that produced it, so the totals cover both the VPN and direct paths.
|
||||
* Only byte counts per app are read - never destinations or content.
|
||||
*/
|
||||
object AppTraffic
|
||||
{
|
||||
private const val TAG = "AppTraffic"
|
||||
/* the first uid of regular apps; lower ones are system services */
|
||||
private const val FIRST_APP_UID = 10000
|
||||
|
||||
fun hasAccess(context: Context): Boolean
|
||||
{
|
||||
val ops = context.getSystemService(Context.APP_OPS_SERVICE) as? AppOpsManager ?: return false
|
||||
val mode = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q)
|
||||
{
|
||||
ops.unsafeCheckOpNoThrow(AppOpsManager.OPSTR_GET_USAGE_STATS, Process.myUid(), context.packageName)
|
||||
}
|
||||
else
|
||||
{
|
||||
@Suppress("DEPRECATION")
|
||||
ops.checkOpNoThrow(AppOpsManager.OPSTR_GET_USAGE_STATS, Process.myUid(), context.packageName)
|
||||
}
|
||||
return mode == AppOpsManager.MODE_ALLOWED
|
||||
}
|
||||
|
||||
/**
|
||||
* Bytes per package since [sinceMs] (wall clock), Wi-Fi and mobile
|
||||
* together; empty without usage access. The system keeps the counters
|
||||
* in buckets of up to two hours, so the window is approximate.
|
||||
*/
|
||||
fun since(context: Context, sinceMs: Long): Map<String, Long>
|
||||
{
|
||||
if (!hasAccess(context))
|
||||
{
|
||||
return emptyMap()
|
||||
}
|
||||
val manager = context.getSystemService(Context.NETWORK_STATS_SERVICE) as? NetworkStatsManager ?: return emptyMap()
|
||||
val now = System.currentTimeMillis()
|
||||
val perUid = HashMap<Int, Long>()
|
||||
@Suppress("DEPRECATION")
|
||||
for (type in intArrayOf(ConnectivityManager.TYPE_WIFI, ConnectivityManager.TYPE_MOBILE))
|
||||
{
|
||||
try
|
||||
{
|
||||
manager.querySummary(type, null, sinceMs, now).use { stats -> sum(stats, perUid) }
|
||||
}
|
||||
catch (e: Exception)
|
||||
{ /* no such network history or no access */
|
||||
Log.d(TAG, "querySummary($type) failed: $e")
|
||||
}
|
||||
}
|
||||
val pm = context.packageManager
|
||||
val result = HashMap<String, Long>()
|
||||
for ((uid, bytes) in perUid)
|
||||
{
|
||||
if (uid < FIRST_APP_UID || bytes <= 0)
|
||||
{
|
||||
continue
|
||||
}
|
||||
val pkg = pm.getPackagesForUid(uid)?.firstOrNull() ?: continue
|
||||
if (pkg != context.packageName)
|
||||
{
|
||||
result[pkg] = (result[pkg] ?: 0L) + bytes
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private fun sum(stats: NetworkStats, into: HashMap<Int, Long>)
|
||||
{
|
||||
val bucket = NetworkStats.Bucket()
|
||||
while (stats.hasNextBucket())
|
||||
{
|
||||
stats.getNextBucket(bucket)
|
||||
into[bucket.uid] = (into[bucket.uid] ?: 0L) + bucket.rxBytes + bucket.txBytes
|
||||
}
|
||||
}
|
||||
|
||||
private inline fun <R> NetworkStats.use(block: (NetworkStats) -> R): R = try
|
||||
{
|
||||
block(this)
|
||||
}
|
||||
finally
|
||||
{
|
||||
close()
|
||||
}
|
||||
}
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
/*
|
||||
* Copyright (C) 2026 SHX VPN
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or modify it
|
||||
* under the terms of the GNU General Public License as published by the
|
||||
* Free Software Foundation; either version 2 of the License, or (at your
|
||||
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful, but
|
||||
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
||||
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
|
||||
* for more details.
|
||||
*/
|
||||
|
||||
package org.strongswan.android.logic.routing
|
||||
|
||||
/**
|
||||
* User-defined package name patterns ("package templates" in the UI):
|
||||
* - "ru.*" - package names starting with "ru."
|
||||
* - "*yandex*" - package names containing "yandex" anywhere
|
||||
* - "*.beta" - package names ending with ".beta"
|
||||
* - "ru.ozon.app" - without a star: prefix match, same as "ru.ozon.app*"
|
||||
* Matching is case-insensitive.
|
||||
*/
|
||||
object PackagePatterns
|
||||
{
|
||||
private val ALLOWED = Regex("[a-z0-9._*-]+")
|
||||
private val STARS = Regex("\\*+")
|
||||
|
||||
/** Canonical form of user input, or null if it is empty, too broad or has invalid characters. */
|
||||
fun normalize(input: String): String?
|
||||
{
|
||||
val text = input.trim().lowercase()
|
||||
if (text.isEmpty() || !ALLOWED.matches(text))
|
||||
{
|
||||
return null
|
||||
}
|
||||
val collapsed = text.replace(STARS, "*")
|
||||
if (collapsed.replace("*", "").isEmpty())
|
||||
{
|
||||
return null
|
||||
}
|
||||
return if (collapsed.contains('*')) collapsed else "$collapsed*"
|
||||
}
|
||||
|
||||
fun matches(pattern: String, pkg: String): Boolean
|
||||
{
|
||||
val core = pattern.trim('*').lowercase()
|
||||
if (core.isEmpty())
|
||||
{
|
||||
return false
|
||||
}
|
||||
val name = pkg.lowercase()
|
||||
val starts = pattern.startsWith("*")
|
||||
val ends = pattern.endsWith("*")
|
||||
return when
|
||||
{
|
||||
starts && ends -> name.contains(core)
|
||||
ends -> name.startsWith(core)
|
||||
starts -> name.endsWith(core)
|
||||
else -> name == core
|
||||
}
|
||||
}
|
||||
|
||||
/** Packages (of [installed]) matched by any of the patterns. */
|
||||
fun matchAll(patterns: Set<String>, installed: Set<String>): Set<String> =
|
||||
if (patterns.isEmpty())
|
||||
{
|
||||
emptySet()
|
||||
}
|
||||
else
|
||||
{
|
||||
installed.filterTo(HashSet()) { pkg -> patterns.any { matches(it, pkg) } }
|
||||
}
|
||||
|
||||
fun fromPrefix(prefix: String): String = "$prefix*"
|
||||
|
||||
fun fromKeyword(keyword: String): String = "*${keyword.lowercase()}*"
|
||||
}
|
||||
+273
@@ -0,0 +1,273 @@
|
||||
/*
|
||||
* Copyright (C) 2026 SHX VPN
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or modify it
|
||||
* under the terms of the GNU General Public License as published by the
|
||||
* Free Software Foundation; either version 2 of the License, or (at your
|
||||
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful, but
|
||||
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
||||
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
|
||||
* for more details.
|
||||
*/
|
||||
|
||||
package org.strongswan.android.logic.routing
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Network
|
||||
import android.net.NetworkCapabilities
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.strongswan.android.utils.IPRange
|
||||
import java.net.InetAddress
|
||||
|
||||
/**
|
||||
* Checks how a domain is routed for regular applications: which addresses
|
||||
* the tunnel's DNS and the direct network's DNS return for it, and whether
|
||||
* each address is tunneled or bypassed under the currently applied RU
|
||||
* exclusion set. This is a diagnostic view - it reflects the configuration
|
||||
* the VPN has installed, not a live traffic probe.
|
||||
*/
|
||||
object RouteCheck
|
||||
{
|
||||
/** Where an address goes under the currently applied exclusion set. */
|
||||
enum class RouteKind { TUNNEL, DIRECT_RU, TUNNEL_DNS }
|
||||
|
||||
data class IpRoute(val ip: String, val kind: RouteKind, val resolvers: Set<String>)
|
||||
|
||||
data class Report(
|
||||
val domain: String,
|
||||
val vpnNetworkFound: Boolean,
|
||||
val directNetworkFound: Boolean,
|
||||
val vpnDnsIps: List<String>,
|
||||
val directDnsIps: List<String>,
|
||||
val routes: List<IpRoute>,
|
||||
)
|
||||
|
||||
/**
|
||||
* Resolves the domain through the tunnel's DNS (the VPN network's
|
||||
* resolvers) and through the direct network's DNS, then classifies every
|
||||
* unique address against the currently applied exclusions.
|
||||
*/
|
||||
suspend fun check(
|
||||
context: Context,
|
||||
domain: String,
|
||||
exclusionsV4: List<IPRange>,
|
||||
exclusionsV6: List<IPRange>,
|
||||
dnsPunchBlocks: Set<String>,
|
||||
): Report = withContext(Dispatchers.IO)
|
||||
{
|
||||
val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? android.net.ConnectivityManager
|
||||
?: return@withContext Report(
|
||||
domain = domain,
|
||||
vpnNetworkFound = false,
|
||||
directNetworkFound = false,
|
||||
vpnDnsIps = emptyList(),
|
||||
directDnsIps = emptyList(),
|
||||
routes = emptyList(),
|
||||
)
|
||||
val networks = cm.allNetworks.toList()
|
||||
val vpn = networks.firstOrNull {
|
||||
cm.getNetworkCapabilities(it)?.hasTransport(NetworkCapabilities.TRANSPORT_VPN) == true
|
||||
}
|
||||
val direct = networks.firstOrNull {
|
||||
val caps = cm.getNetworkCapabilities(it) ?: return@firstOrNull false
|
||||
!caps.hasTransport(NetworkCapabilities.TRANSPORT_VPN) &&
|
||||
caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) &&
|
||||
(caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) ||
|
||||
caps.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR))
|
||||
}
|
||||
|
||||
val vpnDnsIps = resolve(vpn, domain)
|
||||
val directDnsIps = resolve(direct, domain)
|
||||
val routes = (vpnDnsIps + directDnsIps).distinct().map { ip ->
|
||||
val address = InetAddress.getByName(ip)
|
||||
IpRoute(
|
||||
ip = ip,
|
||||
kind = classify(address, exclusionsV4, exclusionsV6, dnsPunchBlocks),
|
||||
resolvers = buildSet {
|
||||
if (ip in vpnDnsIps) add("vpn")
|
||||
if (ip in directDnsIps) add("direct")
|
||||
},
|
||||
)
|
||||
}
|
||||
Report(
|
||||
domain = domain,
|
||||
vpnNetworkFound = vpn != null,
|
||||
directNetworkFound = direct != null,
|
||||
vpnDnsIps = vpnDnsIps,
|
||||
directDnsIps = directDnsIps,
|
||||
routes = routes,
|
||||
)
|
||||
}
|
||||
|
||||
/** Why an address takes its route, in the order the engine decides. */
|
||||
enum class Reason
|
||||
{
|
||||
/** a resolver of the tunnel or strict private DNS: always through the tunnel */
|
||||
DNS,
|
||||
/** a user rule (address or site) sends it through the VPN */
|
||||
RULE_TUNNEL,
|
||||
/** a user rule (address or site) sends it direct */
|
||||
RULE_DIRECT,
|
||||
/** it is in the RU address base */
|
||||
RU_LIST,
|
||||
/** nothing matched: the default route is the tunnel */
|
||||
DEFAULT,
|
||||
}
|
||||
|
||||
data class Verdict(val direct: Boolean, val reason: Reason)
|
||||
|
||||
/**
|
||||
* Full verdict for an address: like [classify], plus the user rules,
|
||||
* which the engine applies when [rulesActive] (userspace routing):
|
||||
* "through the VPN" wins over "direct", both win over the RU base.
|
||||
*/
|
||||
@JvmStatic
|
||||
fun verdict(
|
||||
address: InetAddress,
|
||||
exclusionsV4: List<IPRange>,
|
||||
exclusionsV6: List<IPRange>,
|
||||
dnsPunchBlocks: Set<String>,
|
||||
forceRanges: List<IPRange>,
|
||||
directRanges: List<IPRange>,
|
||||
rulesActive: Boolean,
|
||||
): Verdict
|
||||
{
|
||||
if (insidePunchBlock(dnsPunchBlocks, address))
|
||||
{
|
||||
return Verdict(direct = false, reason = Reason.DNS)
|
||||
}
|
||||
if (rulesActive && containsAddress(forceRanges, address))
|
||||
{
|
||||
return Verdict(direct = false, reason = Reason.RULE_TUNNEL)
|
||||
}
|
||||
if (rulesActive && containsAddress(directRanges, address))
|
||||
{
|
||||
return Verdict(direct = true, reason = Reason.RULE_DIRECT)
|
||||
}
|
||||
val exclusions = if (address is java.net.Inet4Address) exclusionsV4 else exclusionsV6
|
||||
if (containsAddress(exclusions, address))
|
||||
{
|
||||
return Verdict(direct = true, reason = Reason.RU_LIST)
|
||||
}
|
||||
return Verdict(direct = false, reason = Reason.DEFAULT)
|
||||
}
|
||||
|
||||
/** Parses CIDR strings, skipping invalid ones. */
|
||||
@JvmStatic
|
||||
fun ranges(cidrs: List<String>): List<IPRange> = cidrs.mapNotNull { runCatching { IPRange(it) }.getOrNull() }
|
||||
|
||||
/** Route verdict for one address under the applied exclusion set. */
|
||||
@JvmStatic
|
||||
fun classify(
|
||||
address: InetAddress,
|
||||
exclusionsV4: List<IPRange>,
|
||||
exclusionsV6: List<IPRange>,
|
||||
dnsPunchBlocks: Set<String>,
|
||||
): RouteKind
|
||||
{
|
||||
if (insidePunchBlock(dnsPunchBlocks, address))
|
||||
{
|
||||
return RouteKind.TUNNEL_DNS
|
||||
}
|
||||
val exclusions = if (address is java.net.Inet4Address) exclusionsV4 else exclusionsV6
|
||||
if (containsAddress(exclusions, address))
|
||||
{
|
||||
return RouteKind.DIRECT_RU
|
||||
}
|
||||
return RouteKind.TUNNEL
|
||||
}
|
||||
|
||||
/** Whether the address falls into one of the DNS punch blocks ("a.b.c.d/prefix"). */
|
||||
private fun insidePunchBlock(blocks: Set<String>, address: InetAddress): Boolean
|
||||
{
|
||||
if (blocks.isEmpty())
|
||||
{
|
||||
return false
|
||||
}
|
||||
val value = address.address
|
||||
for (block in blocks)
|
||||
{
|
||||
val slash = block.lastIndexOf('/')
|
||||
if (slash <= 0)
|
||||
{
|
||||
continue
|
||||
}
|
||||
val prefix = block.substring(slash + 1).toIntOrNull() ?: continue
|
||||
val base = runCatching { InetAddress.getByName(block.substring(0, slash)).address }.getOrNull() ?: continue
|
||||
if (base.size != value.size || prefix > value.size * 8)
|
||||
{
|
||||
continue
|
||||
}
|
||||
var equal = true
|
||||
for (i in value.indices)
|
||||
{
|
||||
val keep = (prefix - i * 8).coerceIn(0, 8)
|
||||
val mask = if (keep == 8) 0xff else ((0xff shl (8 - keep)) and 0xff)
|
||||
if ((base[i].toInt() and mask) != (value[i].toInt() and mask))
|
||||
{
|
||||
equal = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if (equal)
|
||||
{
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/** Whether any of the ranges (inclusive bounds) contains the address. */
|
||||
@JvmStatic
|
||||
fun containsAddress(ranges: List<IPRange>, address: InetAddress): Boolean
|
||||
{
|
||||
val value = address.address
|
||||
for (range in ranges)
|
||||
{
|
||||
val from = range.from.address
|
||||
val to = range.to.address
|
||||
if (from.size != value.size || to.size != value.size)
|
||||
{
|
||||
continue
|
||||
}
|
||||
if (lexCompare(from, value) <= 0 && lexCompare(value, to) <= 0)
|
||||
{
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/** Unsigned lexicographic byte comparison of two addresses. */
|
||||
private fun lexCompare(a: ByteArray, b: ByteArray): Int
|
||||
{
|
||||
for (i in a.indices)
|
||||
{
|
||||
val diff = (a[i].toInt() and 0xff) - (b[i].toInt() and 0xff)
|
||||
if (diff != 0)
|
||||
{
|
||||
return diff
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
private fun resolve(network: Network?, domain: String): List<String>
|
||||
{
|
||||
if (network == null)
|
||||
{
|
||||
return emptyList()
|
||||
}
|
||||
return try
|
||||
{
|
||||
network.getAllByName(domain).mapNotNull { it.hostAddress }.filter { it.isNotBlank() }
|
||||
}
|
||||
catch (_: Exception)
|
||||
{
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
}
|
||||
+972
@@ -0,0 +1,972 @@
|
||||
/*
|
||||
* Copyright (C) 2026 SHX VPN
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or modify it
|
||||
* under the terms of the GNU General Public License as published by the
|
||||
* Free Software Foundation; either version 2 of the License, or (at your
|
||||
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful, but
|
||||
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
||||
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
|
||||
* for more details.
|
||||
*/
|
||||
|
||||
package org.strongswan.android.logic.routing
|
||||
|
||||
import android.app.Service
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.content.ServiceConnection
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Handler
|
||||
import android.os.IBinder
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONException
|
||||
import org.json.JSONObject
|
||||
import org.strongswan.android.data.VpnProfile.SelectedAppsHandling
|
||||
import org.strongswan.android.data.VpnProfileSqlDataSource
|
||||
import org.strongswan.android.logic.VpnStateService
|
||||
import org.strongswan.android.logic.routing.RoutingPresets.matches
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* A named set of applications that bypassed the VPN tunnel (pre-2.16 model).
|
||||
* Only used to migrate old settings, see [RoutingMigration.migrateRuleSets].
|
||||
*/
|
||||
data class RuleSet(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val enabled: Boolean = true,
|
||||
/** Concrete package names. */
|
||||
val packages: Set<String> = emptySet(),
|
||||
/** Package name prefixes, expanded against installed apps at connect time. */
|
||||
val prefixes: Set<String> = emptySet(),
|
||||
/** Substrings matched anywhere in the package name (case-insensitive). */
|
||||
val keywords: Set<String> = emptySet(),
|
||||
)
|
||||
|
||||
/** A network prefix that must always go through the tunnel (userspace mode). */
|
||||
data class ForceTunnelPrefix(
|
||||
val id: String,
|
||||
/** Validated CIDR or single address, e.g. "130.49.213.176/32". */
|
||||
val prefix: String,
|
||||
val enabled: Boolean = true,
|
||||
)
|
||||
|
||||
/**
|
||||
* The one global answer to "what goes around the VPN" for apps:
|
||||
* [effectiveExcludes] = ([directPackages] + apps matched by [enabledCategories]
|
||||
* and [packagePatterns]) - [tunnelOverrides].
|
||||
*/
|
||||
data class RoutingConfig(
|
||||
val enabled: Boolean = true,
|
||||
/** Apps the user switched to "direct" one by one. */
|
||||
val directPackages: Set<String> = emptySet(),
|
||||
/** Apps switched back to "via VPN" although a category or pattern covers them. */
|
||||
val tunnelOverrides: Set<String> = emptySet(),
|
||||
/** Enabled built-in categories ([RoutingPresets] ids). */
|
||||
val enabledCategories: Set<String> = emptySet(),
|
||||
/** User package patterns, see [PackagePatterns]. */
|
||||
val packagePatterns: Set<String> = emptySet(),
|
||||
/** Patterns the user switched off without deleting them. */
|
||||
val disabledPatterns: Set<String> = emptySet(),
|
||||
val forceTunnelPrefixes: List<ForceTunnelPrefix> = emptyList(),
|
||||
/** User addresses that always go direct (same shape as the force-tunnel entries). */
|
||||
val directPrefixes: List<ForceTunnelPrefix> = emptyList(),
|
||||
/** Per-site rules; the engine routes by address, so sites are resolved, see [SiteRule]. */
|
||||
val siteRules: List<SiteRule> = emptyList(),
|
||||
val version: Int = RoutingManager.CONFIG_VERSION,
|
||||
)
|
||||
|
||||
/**
|
||||
* "This site always goes direct / through the VPN". The userspace router
|
||||
* decides per destination address, so the rule applies to the addresses the
|
||||
* domain (and its www. name) resolves to; they are refreshed when the
|
||||
* tunnel comes up and every [RoutingManager.SITE_REFRESH_MS] while it is up.
|
||||
*/
|
||||
data class SiteRule(
|
||||
val id: String,
|
||||
val domain: String,
|
||||
val direct: Boolean,
|
||||
val enabled: Boolean = true,
|
||||
)
|
||||
|
||||
/** Why a package is (not) direct, for the per-app reason line. */
|
||||
data class TemplateMatch(
|
||||
/** Built-in category id (may be [RoutingPresets.NAMESPACE]) or null. */
|
||||
val category: String? = null,
|
||||
/** User pattern or null. */
|
||||
val pattern: String? = null,
|
||||
)
|
||||
|
||||
data class RoutingState(
|
||||
val config: RoutingConfig = RoutingConfig(),
|
||||
/** Number of profiles whose "only selected apps" list was converted at migration (one-time notice). */
|
||||
val migratedOnlyProfiles: Int = 0,
|
||||
)
|
||||
|
||||
/**
|
||||
* Global per-app routing: one list of applications that must NOT use the VPN
|
||||
* tunnel (they go direct). [CharonVpnService.BuilderCache] merges the
|
||||
* effective package list into the TUN builder on every (re)connect.
|
||||
*
|
||||
* When the list changes while the VPN is connected the connection is
|
||||
* restarted automatically (debounced), because per-app routing only applies
|
||||
* when the TUN interface is created.
|
||||
*
|
||||
* The constructor migrates pre-2.16 settings (rule sets, per-profile app
|
||||
* lists) once, so it has to run before the first VPN start - it does, from
|
||||
* StrongSwanApplication.onCreate.
|
||||
*/
|
||||
class RoutingManager private constructor(private val context: Context)
|
||||
{
|
||||
private val handler = Handler(Looper.getMainLooper())
|
||||
private val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
|
||||
/* read from the charon and IO threads in computeExcludedPackages() */
|
||||
@Volatile
|
||||
private var config = RoutingConfig()
|
||||
private var service: VpnStateService? = null
|
||||
private var migratedOnlyProfiles = 0
|
||||
|
||||
/* package cache: expanded patterns against installed apps, keyed by config version */
|
||||
@Volatile
|
||||
private var configVersion = 0L
|
||||
|
||||
@Volatile
|
||||
private var packageCache: Pair<Long, Set<String>>? = null
|
||||
|
||||
private val _state = MutableStateFlow(RoutingState())
|
||||
val state: StateFlow<RoutingState> = _state
|
||||
|
||||
/* resolved addresses of the site rules, domain -> CIDRs; read by the push on any thread */
|
||||
@Volatile
|
||||
private var siteAddresses: Map<String, List<String>> = emptyMap()
|
||||
private val _siteAddresses = MutableStateFlow<Map<String, List<String>>>(emptyMap())
|
||||
/** Resolved addresses per site rule domain, for the Rules screen. */
|
||||
val resolvedSites: StateFlow<Map<String, List<String>>> = _siteAddresses
|
||||
private var siteRefreshJob: Job? = null
|
||||
|
||||
/* site rules are resolved again whenever the tunnel comes up */
|
||||
private var lastState: VpnStateService.State? = null
|
||||
private val stateListener = VpnStateService.VpnStateListener {
|
||||
val state = service?.state
|
||||
if (state == VpnStateService.State.CONNECTED && lastState != VpnStateService.State.CONNECTED && config.siteRules.isNotEmpty())
|
||||
{
|
||||
refreshSites()
|
||||
}
|
||||
lastState = state
|
||||
}
|
||||
|
||||
private val connection = object : ServiceConnection
|
||||
{
|
||||
override fun onServiceConnected(name: ComponentName?, binder: IBinder?)
|
||||
{
|
||||
service = (binder as VpnStateService.LocalBinder).service
|
||||
service?.registerListener(stateListener)
|
||||
stateListener.stateChanged()
|
||||
}
|
||||
|
||||
override fun onServiceDisconnected(name: ComponentName?)
|
||||
{
|
||||
service = null
|
||||
}
|
||||
}
|
||||
|
||||
private val reconnectRunnable = Runnable {
|
||||
val svc = service ?: return@Runnable
|
||||
if (svc.state == VpnStateService.State.CONNECTED)
|
||||
{
|
||||
Log.i(TAG, "Routing rules changed, reconnecting to apply them")
|
||||
svc.reconnectPreservingLog()
|
||||
}
|
||||
}
|
||||
|
||||
/* new/removed apps change what categories and patterns match */
|
||||
private val packagesReceiver = object : BroadcastReceiver()
|
||||
{
|
||||
override fun onReceive(context: Context?, intent: Intent?)
|
||||
{
|
||||
if (intent?.action == Intent.ACTION_PACKAGE_ADDED && !intent.getBooleanExtra(Intent.EXTRA_REPLACING, false))
|
||||
{
|
||||
intent.data?.schemeSpecificPart?.let { suggestDirect(it) }
|
||||
}
|
||||
invalidateCache()
|
||||
/* a full PackageManager scan: never on the main thread */
|
||||
scope.launch(Dispatchers.Default) { computeExcludedPackages() }
|
||||
}
|
||||
}
|
||||
|
||||
init
|
||||
{
|
||||
config = loadConfig()
|
||||
migrateProfiles()
|
||||
migratedOnlyProfiles = prefs.getInt(KEY_NOTICE_ONLY, 0)
|
||||
publish()
|
||||
}
|
||||
|
||||
fun start()
|
||||
{
|
||||
context.bindService(Intent(context, VpnStateService::class.java), connection, Service.BIND_AUTO_CREATE)
|
||||
val filter = IntentFilter().apply {
|
||||
addAction(Intent.ACTION_PACKAGE_ADDED)
|
||||
addAction(Intent.ACTION_PACKAGE_REMOVED)
|
||||
addDataScheme("package")
|
||||
}
|
||||
context.registerReceiver(packagesReceiver, filter)
|
||||
/* resolve the site rules once so the Rules screen shows their addresses */
|
||||
refreshSites()
|
||||
}
|
||||
|
||||
fun updateConfig(transform: (RoutingConfig) -> RoutingConfig)
|
||||
{
|
||||
val previous = config
|
||||
val updated = transform(previous)
|
||||
if (updated == previous)
|
||||
{ /* e.g. a chip tapped that is already in that state: no reconnect */
|
||||
return
|
||||
}
|
||||
config = updated
|
||||
saveConfig(updated)
|
||||
invalidateCache()
|
||||
publish()
|
||||
if (updated.enabled)
|
||||
{ /* warm the package cache (off the main thread) so the connect path stays fast */
|
||||
scope.launch(Dispatchers.Default) { computeExcludedPackages() }
|
||||
}
|
||||
if (updated.enabled || previous.enabled)
|
||||
{ /* also when routing was just switched off: the excluded apps keep
|
||||
* bypassing the tunnel until it is re-established */
|
||||
scheduleReconnect()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A new app that a built-in RU category would cover, while that category
|
||||
* is off and the app is not direct: ask in «Events» whether it should be.
|
||||
*/
|
||||
private fun suggestDirect(pkg: String)
|
||||
{
|
||||
val current = config
|
||||
if (!current.enabled || pkg == context.packageName || pkg in current.directPackages || pkg in current.tunnelOverrides)
|
||||
{
|
||||
return
|
||||
}
|
||||
val category = RoutingPresets.CATEGORIES.firstOrNull { RoutingPresets.matchesCategory(it, pkg) } ?: return
|
||||
if (category in current.enabledCategories || templateMatch(pkg, current) != null)
|
||||
{
|
||||
return
|
||||
}
|
||||
val pm = context.packageManager
|
||||
val label = try
|
||||
{
|
||||
pm.getApplicationInfo(pkg, 0).loadLabel(pm).toString()
|
||||
}
|
||||
catch (_: PackageManager.NameNotFoundException)
|
||||
{
|
||||
return
|
||||
}
|
||||
org.strongswan.android.logic.events.Events.newRuApp(context, pkg, label)
|
||||
}
|
||||
|
||||
/* ------------------------- UI-facing helpers ------------------------- */
|
||||
|
||||
fun setGlobalEnabled(enabled: Boolean)
|
||||
{
|
||||
updateConfig { it.copy(enabled = enabled) }
|
||||
}
|
||||
|
||||
/**
|
||||
* The per-app switch: "direct" adds the app (or drops its tunnel
|
||||
* override when a category/pattern already covers it); "via VPN" removes
|
||||
* it, keeping a personal override when a category/pattern covers it.
|
||||
*/
|
||||
fun setAppDirect(pkg: String, direct: Boolean)
|
||||
{
|
||||
updateConfig { config ->
|
||||
val covered = templateMatch(pkg, config) != null
|
||||
if (direct)
|
||||
{
|
||||
config.copy(
|
||||
enabled = true,
|
||||
tunnelOverrides = config.tunnelOverrides - pkg,
|
||||
directPackages = if (covered) config.directPackages else config.directPackages + pkg,
|
||||
)
|
||||
}
|
||||
else
|
||||
{
|
||||
config.copy(
|
||||
directPackages = config.directPackages - pkg,
|
||||
tunnelOverrides = if (covered) config.tunnelOverrides + pkg else config.tunnelOverrides,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Switches a built-in category on or off. Switching on also clears personal "via VPN" exceptions inside it. */
|
||||
fun setCategoryEnabled(category: String, enabled: Boolean)
|
||||
{
|
||||
updateConfig { config ->
|
||||
if (enabled)
|
||||
{
|
||||
config.copy(
|
||||
enabled = true,
|
||||
enabledCategories = config.enabledCategories + category,
|
||||
tunnelOverrides = config.tunnelOverrides.filterNot { RoutingPresets.matchesCategory(category, it) }.toSet(),
|
||||
)
|
||||
}
|
||||
else
|
||||
{
|
||||
config.copy(enabledCategories = config.enabledCategories - category)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Quick action: every RF category plus the ru.* namespace ("all RF apps"). */
|
||||
fun enableAllRfApps()
|
||||
{
|
||||
updateConfig { config ->
|
||||
config.copy(
|
||||
enabled = true,
|
||||
enabledCategories = config.enabledCategories + RoutingPresets.ALL_CATEGORIES,
|
||||
tunnelOverrides = config.tunnelOverrides.filterNot { pkg ->
|
||||
RoutingPresets.ALL_CATEGORIES.any { RoutingPresets.matchesCategory(it, pkg) }
|
||||
}.toSet(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Adds a package pattern; false if the input is invalid. */
|
||||
fun addPattern(input: String): Boolean
|
||||
{
|
||||
val pattern = PackagePatterns.normalize(input) ?: return false
|
||||
updateConfig { it.copy(packagePatterns = it.packagePatterns + pattern) }
|
||||
return true
|
||||
}
|
||||
|
||||
fun removePattern(pattern: String)
|
||||
{
|
||||
updateConfig { it.copy(packagePatterns = it.packagePatterns - pattern, disabledPatterns = it.disabledPatterns - pattern) }
|
||||
}
|
||||
|
||||
/** Switches a package pattern off (kept in the list) or back on. */
|
||||
fun setPatternEnabled(pattern: String, enabled: Boolean)
|
||||
{
|
||||
updateConfig { it.copy(disabledPatterns = if (enabled) it.disabledPatterns - pattern else it.disabledPatterns + pattern) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Imports the split-tunneling apps of a profile file (JSON import): the
|
||||
* per-profile setting no longer exists, so the apps join the global
|
||||
* direct list ("excluded-apps" as is, "apps" as every other app).
|
||||
*/
|
||||
fun importProfileApps(handling: SelectedAppsHandling, selected: Set<String>)
|
||||
{
|
||||
val added = RoutingMigration.profileAppsToDirect(
|
||||
handling, selected,
|
||||
if (handling == SelectedAppsHandling.SELECTED_APPS_ONLY) loadInstalledPackages() else emptySet(),
|
||||
context.packageName)
|
||||
if (added.isNotEmpty())
|
||||
{
|
||||
updateConfig { it.copy(enabled = true, directPackages = it.directPackages + added, tunnelOverrides = it.tunnelOverrides - added) }
|
||||
}
|
||||
}
|
||||
|
||||
/** The one-time "your 'only selected apps' profile was converted" notice has been shown. */
|
||||
fun consumeMigrationNotice()
|
||||
{
|
||||
if (migratedOnlyProfiles != 0)
|
||||
{
|
||||
migratedOnlyProfiles = 0
|
||||
prefs.edit().putInt(KEY_NOTICE_ONLY, 0).apply()
|
||||
publish()
|
||||
}
|
||||
}
|
||||
|
||||
/* --------------------- force-tunnel prefixes --------------------- */
|
||||
|
||||
/**
|
||||
* Adds a force-tunnel prefix (CIDR or bare address). Returns null on an
|
||||
* invalid prefix, otherwise the new entry's id.
|
||||
*/
|
||||
fun addForcePrefix(cidr: String): String?
|
||||
{
|
||||
val normalized = normalizePrefix(cidr) ?: return null
|
||||
updateConfigNoReconnect { config ->
|
||||
config.copy(forceTunnelPrefixes = config.forceTunnelPrefixes + ForceTunnelPrefix(
|
||||
id = UUID.randomUUID().toString(), prefix = normalized))
|
||||
}
|
||||
return config.forceTunnelPrefixes.lastOrNull { it.prefix == normalized }?.id
|
||||
}
|
||||
|
||||
/** Replaces the prefix of an existing entry; false if the new one is invalid. */
|
||||
fun updateForcePrefix(id: String, cidr: String): Boolean
|
||||
{
|
||||
val normalized = normalizePrefix(cidr) ?: return false
|
||||
updateConfigNoReconnect { config ->
|
||||
config.copy(forceTunnelPrefixes = config.forceTunnelPrefixes.map {
|
||||
if (it.id == id) it.copy(prefix = normalized) else it })
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
fun deleteForcePrefix(id: String)
|
||||
{
|
||||
updateConfigNoReconnect { config ->
|
||||
config.copy(forceTunnelPrefixes = config.forceTunnelPrefixes.filter { it.id != id })
|
||||
}
|
||||
}
|
||||
|
||||
fun setForcePrefixEnabled(id: String, enabled: Boolean)
|
||||
{
|
||||
updateConfigNoReconnect { config ->
|
||||
config.copy(forceTunnelPrefixes = config.forceTunnelPrefixes.map {
|
||||
if (it.id == id) it.copy(enabled = enabled) else it })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Enabled force-tunnel prefixes as validated CIDR strings (userspace
|
||||
* routing feeds them into the native force set), including the
|
||||
* addresses of "through the VPN" site rules.
|
||||
*/
|
||||
fun forceTunnelCidrs(): List<String> =
|
||||
config.forceTunnelPrefixes.filter { it.enabled }.map { it.prefix } + siteCidrs(direct = false)
|
||||
|
||||
/** Enabled direct addresses plus the addresses of "direct" site rules. */
|
||||
fun directCidrs(): List<String> =
|
||||
config.directPrefixes.filter { it.enabled }.map { it.prefix } + siteCidrs(direct = true)
|
||||
|
||||
private fun siteCidrs(direct: Boolean): List<String>
|
||||
{
|
||||
val resolved = siteAddresses
|
||||
return config.siteRules.filter { it.enabled && it.direct == direct }.flatMap { resolved[it.domain].orEmpty() }
|
||||
}
|
||||
|
||||
/* ------------------------- direct addresses ------------------------- */
|
||||
|
||||
/** Adds a direct address/network; null if invalid. */
|
||||
fun addDirectPrefix(cidr: String): String?
|
||||
{
|
||||
val normalized = normalizePrefix(cidr) ?: return null
|
||||
val id = UUID.randomUUID().toString()
|
||||
updateConfigNoReconnect { config ->
|
||||
config.copy(directPrefixes = config.directPrefixes + ForceTunnelPrefix(id = id, prefix = normalized))
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
fun deleteDirectPrefix(id: String)
|
||||
{
|
||||
updateConfigNoReconnect { config -> config.copy(directPrefixes = config.directPrefixes.filter { it.id != id }) }
|
||||
}
|
||||
|
||||
fun setDirectPrefixEnabled(id: String, enabled: Boolean)
|
||||
{
|
||||
updateConfigNoReconnect { config ->
|
||||
config.copy(directPrefixes = config.directPrefixes.map { if (it.id == id) it.copy(enabled = enabled) else it })
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------- site rules ------------------------- */
|
||||
|
||||
/** Adds (or replaces) the rule for [input]'s domain; null if it is not a domain. */
|
||||
fun addSiteRule(input: String, direct: Boolean): String?
|
||||
{
|
||||
val domain = normalizeDomain(input) ?: return null
|
||||
val id = UUID.randomUUID().toString()
|
||||
updateConfigNoReconnect { config ->
|
||||
config.copy(siteRules = config.siteRules.filter { it.domain != domain } + SiteRule(id, domain, direct))
|
||||
}
|
||||
refreshSites()
|
||||
return id
|
||||
}
|
||||
|
||||
fun deleteSiteRule(id: String)
|
||||
{
|
||||
updateConfigNoReconnect { config -> config.copy(siteRules = config.siteRules.filter { it.id != id }) }
|
||||
}
|
||||
|
||||
fun setSiteRuleEnabled(id: String, enabled: Boolean)
|
||||
{
|
||||
updateConfigNoReconnect { config ->
|
||||
config.copy(siteRules = config.siteRules.map { if (it.id == id) it.copy(enabled = enabled) else it })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the site rules again and pushes the result to the engine.
|
||||
* Called when a rule is added and when the tunnel comes up; repeats every
|
||||
* [SITE_REFRESH_MS] while there are rules and the VPN is connected.
|
||||
*/
|
||||
fun refreshSites()
|
||||
{
|
||||
siteRefreshJob?.cancel()
|
||||
if (config.siteRules.isEmpty())
|
||||
{
|
||||
siteAddresses = emptyMap()
|
||||
_siteAddresses.value = emptyMap()
|
||||
return
|
||||
}
|
||||
siteRefreshJob = scope.launch {
|
||||
while (true)
|
||||
{
|
||||
val domains = config.siteRules.map { it.domain }.distinct()
|
||||
val previous = siteAddresses
|
||||
val resolved = withContext(Dispatchers.IO) { domains.associateWith { resolveSite(it) } }
|
||||
/* keep the previous answer for a domain that failed to resolve this time */
|
||||
val merged = resolved.mapValues { (domain, cidrs) -> cidrs.ifEmpty { previous[domain].orEmpty() } }
|
||||
if (merged != previous)
|
||||
{
|
||||
siteAddresses = merged
|
||||
_siteAddresses.value = merged
|
||||
pushUserspaceRouting()
|
||||
}
|
||||
if (service?.state != VpnStateService.State.CONNECTED)
|
||||
{
|
||||
break
|
||||
}
|
||||
delay(SITE_REFRESH_MS)
|
||||
/* nobody looks at the screen: resolving only wakes the radio and
|
||||
* CPU, so wait for it to come on (re-checked every few minutes),
|
||||
* but never let the answers get older than the cap */
|
||||
var waited = SITE_REFRESH_MS
|
||||
while (waited < SITE_REFRESH_SCREEN_OFF_MAX_MS && !isScreenOn())
|
||||
{
|
||||
delay(SITE_SCREEN_RECHECK_MS)
|
||||
waited += SITE_SCREEN_RECHECK_MS
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun isScreenOn(): Boolean =
|
||||
(context.getSystemService(Context.POWER_SERVICE) as? android.os.PowerManager)?.isInteractive ?: true
|
||||
|
||||
/**
|
||||
* Like [updateConfig], but force prefixes apply on the fly through the
|
||||
* atomic set swap of the userspace router - no reconnect needed.
|
||||
*/
|
||||
private fun updateConfigNoReconnect(transform: (RoutingConfig) -> RoutingConfig)
|
||||
{
|
||||
config = transform(config)
|
||||
saveConfig(config)
|
||||
publish()
|
||||
pushUserspaceRouting()
|
||||
}
|
||||
|
||||
private fun pushUserspaceRouting()
|
||||
{
|
||||
try
|
||||
{
|
||||
org.strongswan.android.logic.CharonVpnService.pushUserspaceRoutingData(context)
|
||||
}
|
||||
catch (e: Throwable)
|
||||
{
|
||||
Log.w(TAG, "Failed to push userspace routing data", e)
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------- connect-time merge ------------------------- */
|
||||
|
||||
/**
|
||||
* Packages that must bypass the tunnel right now. Empty when routing is
|
||||
* disabled. Safe from any thread; cached per config version (the cache is
|
||||
* computed on first use, e.g. on the charon thread while the TUN is built).
|
||||
*/
|
||||
fun computeExcludedPackages(): Set<String>
|
||||
{
|
||||
/* version before config: updateConfig() writes config first, so this
|
||||
* order can at worst cache a newer config under an older version
|
||||
* (recomputed next time), never an old config under the new one */
|
||||
val version = configVersion
|
||||
val current = config
|
||||
if (!current.enabled)
|
||||
{
|
||||
return emptySet()
|
||||
}
|
||||
packageCache?.let { (cachedVersion, packages) ->
|
||||
if (cachedVersion == version)
|
||||
{
|
||||
return packages
|
||||
}
|
||||
}
|
||||
synchronized(this)
|
||||
{
|
||||
packageCache?.let { (cachedVersion, packages) ->
|
||||
if (cachedVersion == version)
|
||||
{
|
||||
return packages
|
||||
}
|
||||
}
|
||||
val packages = effectiveExcludes(current, loadInstalledPackages())
|
||||
packageCache = version to packages
|
||||
return packages
|
||||
}
|
||||
}
|
||||
|
||||
private fun invalidateCache()
|
||||
{
|
||||
configVersion++
|
||||
packageCache = null
|
||||
}
|
||||
|
||||
private fun loadInstalledPackages(): Set<String> = loadInternetPackages(context.packageManager)
|
||||
|
||||
private fun publish()
|
||||
{
|
||||
_state.value = RoutingState(config = config, migratedOnlyProfiles = migratedOnlyProfiles)
|
||||
}
|
||||
|
||||
private fun scheduleReconnect()
|
||||
{
|
||||
handler.removeCallbacks(reconnectRunnable)
|
||||
handler.postDelayed(reconnectRunnable, RECONNECT_DEBOUNCE_MS)
|
||||
}
|
||||
|
||||
/* ------------------------- persistence + migration ------------------------- */
|
||||
|
||||
private fun loadConfig(): RoutingConfig
|
||||
{
|
||||
val json = prefs.getString(KEY_CONFIG, null) ?: return RoutingConfig()
|
||||
return try
|
||||
{
|
||||
val obj = JSONObject(json)
|
||||
if (obj.optInt("version", 1) >= CONFIG_VERSION)
|
||||
{
|
||||
parseConfig(obj)
|
||||
}
|
||||
else
|
||||
{
|
||||
migrateLegacyConfig(json, obj)
|
||||
}
|
||||
}
|
||||
catch (_: JSONException)
|
||||
{
|
||||
RoutingConfig()
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseConfig(obj: JSONObject) = RoutingConfig(
|
||||
enabled = obj.optBoolean("enabled", true),
|
||||
directPackages = obj.optJSONArray("directPackages").toStringSet(),
|
||||
tunnelOverrides = obj.optJSONArray("tunnelOverrides").toStringSet(),
|
||||
enabledCategories = obj.optJSONArray("enabledCategories").toStringSet(),
|
||||
packagePatterns = obj.optJSONArray("packagePatterns").toStringSet(),
|
||||
disabledPatterns = obj.optJSONArray("disabledPatterns").toStringSet(),
|
||||
forceTunnelPrefixes = parseForcePrefixes(obj),
|
||||
directPrefixes = parseForcePrefixes(obj, "directPrefixes"),
|
||||
siteRules = obj.optJSONArray("siteRules")?.let { arr ->
|
||||
(0 until arr.length()).mapNotNull { i ->
|
||||
arr.optJSONObject(i)?.let { o ->
|
||||
SiteRule(
|
||||
id = o.optString("id"),
|
||||
domain = o.optString("domain"),
|
||||
direct = o.optBoolean("direct"),
|
||||
enabled = o.optBoolean("enabled", true),
|
||||
).takeIf { it.domain.isNotBlank() }
|
||||
}
|
||||
}
|
||||
} ?: emptyList(),
|
||||
version = CONFIG_VERSION,
|
||||
)
|
||||
|
||||
private fun parseForcePrefixes(obj: JSONObject, key: String = "forceTunnelPrefixes"): List<ForceTunnelPrefix> =
|
||||
obj.optJSONArray(key)?.let { arr ->
|
||||
(0 until arr.length()).mapNotNull { i ->
|
||||
arr.optJSONObject(i)?.let { o ->
|
||||
ForceTunnelPrefix(
|
||||
id = o.optString("id"),
|
||||
prefix = o.optString("prefix"),
|
||||
enabled = o.optBoolean("enabled", true),
|
||||
)
|
||||
}
|
||||
}
|
||||
} ?: emptyList()
|
||||
|
||||
/** Rule sets (version 1) -> direct apps + package patterns; the old JSON is kept in a backup key. */
|
||||
private fun migrateLegacyConfig(json: String, obj: JSONObject): RoutingConfig
|
||||
{
|
||||
if (!prefs.contains(KEY_CONFIG_BACKUP))
|
||||
{
|
||||
prefs.edit().putString(KEY_CONFIG_BACKUP, json).commit()
|
||||
}
|
||||
val ruleSets = obj.optJSONArray("ruleSets")?.let { arr ->
|
||||
(0 until arr.length()).mapNotNull { i ->
|
||||
arr.optJSONObject(i)?.let { o ->
|
||||
RuleSet(
|
||||
id = o.optString("id"),
|
||||
name = o.optString("name"),
|
||||
enabled = o.optBoolean("enabled", true),
|
||||
packages = o.optJSONArray("packages").toStringSet(),
|
||||
prefixes = o.optJSONArray("prefixes").toStringSet(),
|
||||
keywords = o.optJSONArray("keywords").toStringSet(),
|
||||
)
|
||||
}
|
||||
}
|
||||
} ?: emptyList()
|
||||
val migrated = RoutingMigration.migrateRuleSets(ruleSets)
|
||||
val result = RoutingConfig(
|
||||
enabled = obj.optBoolean("enabled", true),
|
||||
directPackages = migrated.directPackages,
|
||||
enabledCategories = migrated.categories,
|
||||
packagePatterns = migrated.patterns,
|
||||
forceTunnelPrefixes = parseForcePrefixes(obj),
|
||||
)
|
||||
Log.i(TAG, "Migrated ${ruleSets.size} rule sets to ${result.directPackages.size} apps, " +
|
||||
"${result.packagePatterns.size} patterns, ${result.enabledCategories.size} categories")
|
||||
saveConfig(result, commit = true)
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-profile app handling is gone: EXCLUDE lists become direct apps, an
|
||||
* ONLY list becomes "every other installed internet app" (snapshot), and
|
||||
* the profile is reset to DISABLE. Runs once (flag in prefs); a failure
|
||||
* leaves the flag unset so the next start retries.
|
||||
*/
|
||||
private fun migrateProfiles()
|
||||
{
|
||||
if (prefs.getBoolean(KEY_PROFILES_MIGRATED, false))
|
||||
{
|
||||
return
|
||||
}
|
||||
try
|
||||
{
|
||||
val source = VpnProfileSqlDataSource()
|
||||
source.open()
|
||||
val direct = HashSet<String>()
|
||||
var onlyConverted = 0
|
||||
try
|
||||
{
|
||||
val installed by lazy { loadInstalledPackages() }
|
||||
for (profile in source.allVpnProfiles)
|
||||
{
|
||||
val handling = profile.selectedAppsHandling
|
||||
if (handling == SelectedAppsHandling.SELECTED_APPS_DISABLE)
|
||||
{
|
||||
continue
|
||||
}
|
||||
val added = RoutingMigration.profileAppsToDirect(
|
||||
handling, profile.selectedAppsSet, if (handling == SelectedAppsHandling.SELECTED_APPS_ONLY) installed else emptySet(),
|
||||
context.packageName)
|
||||
direct.addAll(added)
|
||||
if (handling == SelectedAppsHandling.SELECTED_APPS_ONLY && added.isNotEmpty())
|
||||
{
|
||||
onlyConverted++
|
||||
}
|
||||
profile.setSelectedAppsHandling(SelectedAppsHandling.SELECTED_APPS_DISABLE)
|
||||
source.updateVpnProfile(profile)
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
source.close()
|
||||
}
|
||||
if (direct.isNotEmpty())
|
||||
{ /* the old per-profile excludes applied regardless of the global switch */
|
||||
config = config.copy(enabled = true, directPackages = config.directPackages + direct,
|
||||
tunnelOverrides = config.tunnelOverrides - direct)
|
||||
saveConfig(config, commit = true)
|
||||
}
|
||||
prefs.edit()
|
||||
.putBoolean(KEY_PROFILES_MIGRATED, true)
|
||||
.putInt(KEY_NOTICE_ONLY, onlyConverted)
|
||||
.commit()
|
||||
Log.i(TAG, "Migrated profile apps: ${direct.size} direct apps, $onlyConverted 'only' profiles converted")
|
||||
}
|
||||
catch (e: Throwable)
|
||||
{
|
||||
Log.w(TAG, "Profile app migration failed, will retry on next start", e)
|
||||
}
|
||||
}
|
||||
|
||||
private fun saveConfig(config: RoutingConfig, commit: Boolean = false)
|
||||
{
|
||||
val obj = JSONObject()
|
||||
obj.put("version", CONFIG_VERSION)
|
||||
obj.put("enabled", config.enabled)
|
||||
obj.put("directPackages", JSONArray(config.directPackages.sorted()))
|
||||
obj.put("tunnelOverrides", JSONArray(config.tunnelOverrides.sorted()))
|
||||
obj.put("enabledCategories", JSONArray(config.enabledCategories.sorted()))
|
||||
obj.put("packagePatterns", JSONArray(config.packagePatterns.sorted()))
|
||||
obj.put("disabledPatterns", JSONArray(config.disabledPatterns.sorted()))
|
||||
val forcePrefixes = JSONArray()
|
||||
for (entry in config.forceTunnelPrefixes)
|
||||
{
|
||||
val o = JSONObject()
|
||||
o.put("id", entry.id)
|
||||
o.put("prefix", entry.prefix)
|
||||
o.put("enabled", entry.enabled)
|
||||
forcePrefixes.put(o)
|
||||
}
|
||||
obj.put("forceTunnelPrefixes", forcePrefixes)
|
||||
val directPrefixes = JSONArray()
|
||||
for (entry in config.directPrefixes)
|
||||
{
|
||||
directPrefixes.put(JSONObject().put("id", entry.id).put("prefix", entry.prefix).put("enabled", entry.enabled))
|
||||
}
|
||||
obj.put("directPrefixes", directPrefixes)
|
||||
val sites = JSONArray()
|
||||
for (rule in config.siteRules)
|
||||
{
|
||||
sites.put(JSONObject().put("id", rule.id).put("domain", rule.domain).put("direct", rule.direct).put("enabled", rule.enabled))
|
||||
}
|
||||
obj.put("siteRules", sites)
|
||||
val editor = prefs.edit().putString(KEY_CONFIG, obj.toString())
|
||||
if (commit) editor.commit() else editor.apply()
|
||||
}
|
||||
|
||||
private fun JSONArray?.toStringSet(): Set<String> =
|
||||
this?.let { arr -> (0 until arr.length()).mapNotNull { i -> arr.optString(i).takeIf { it.isNotBlank() } }.toSet() } ?: emptySet()
|
||||
|
||||
companion object
|
||||
{
|
||||
private const val TAG = "RoutingManager"
|
||||
private const val PREFS_NAME = "one.shx.strongswan.ext.ROUTING"
|
||||
private const val KEY_CONFIG = "config"
|
||||
private const val KEY_CONFIG_BACKUP = "config_v1_backup"
|
||||
private const val KEY_PROFILES_MIGRATED = "profiles_migrated_v2"
|
||||
private const val KEY_NOTICE_ONLY = "notice_only_profiles"
|
||||
private const val RECONNECT_DEBOUNCE_MS = 2000L
|
||||
const val SITE_REFRESH_MS = 30L * 60 * 1000
|
||||
/* with the screen off the refresh waits for it, up to this age */
|
||||
private const val SITE_REFRESH_SCREEN_OFF_MAX_MS = 3L * 60 * 60 * 1000
|
||||
private const val SITE_SCREEN_RECHECK_MS = 5L * 60 * 1000
|
||||
|
||||
/** Config schema: 1 = rule sets, 2 = direct apps + categories + patterns. */
|
||||
const val CONFIG_VERSION = 2
|
||||
|
||||
/** Validates and canonicalizes a CIDR/address; null if invalid. */
|
||||
fun normalizePrefix(cidr: String): String? = try
|
||||
{
|
||||
org.strongswan.android.utils.IPRange(cidr.trim()).toString()
|
||||
}
|
||||
catch (_: Exception)
|
||||
{
|
||||
null
|
||||
}
|
||||
|
||||
/**
|
||||
* "https://www.Example.com/path" -> "example.com"; null if the input
|
||||
* is not a plausible host name (an address belongs in an address rule).
|
||||
*/
|
||||
fun normalizeDomain(input: String): String?
|
||||
{
|
||||
var host = input.trim().lowercase()
|
||||
host = host.substringAfter("://").substringBefore('/').substringBefore('?').substringBefore(':')
|
||||
host = host.removePrefix("*.").removePrefix("www.").trimEnd('.')
|
||||
val label = Regex("[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?")
|
||||
val labels = host.split('.')
|
||||
if (labels.size < 2 || labels.any { !label.matches(it) } || labels.last().all { it.isDigit() })
|
||||
{
|
||||
return null
|
||||
}
|
||||
return host
|
||||
}
|
||||
|
||||
/** The addresses [domain] and www.[domain] resolve to, as host CIDRs. */
|
||||
private fun resolveSite(domain: String): List<String> =
|
||||
listOf(domain, "www.$domain").flatMap { name ->
|
||||
try
|
||||
{
|
||||
java.net.InetAddress.getAllByName(name).toList()
|
||||
}
|
||||
catch (_: Exception)
|
||||
{
|
||||
emptyList()
|
||||
}
|
||||
}.map { it.hostAddress + if (it is java.net.Inet6Address) "/128" else "/32" }.distinct()
|
||||
|
||||
/** All installed apps that hold the INTERNET permission. */
|
||||
fun loadInternetPackages(pm: PackageManager): Set<String> = try
|
||||
{
|
||||
pm.getInstalledApplications(0)
|
||||
.asSequence()
|
||||
.filter { pm.checkPermission(android.Manifest.permission.INTERNET, it.packageName) == PackageManager.PERMISSION_GRANTED }
|
||||
.map { it.packageName }
|
||||
.toSet()
|
||||
}
|
||||
catch (e: Exception)
|
||||
{
|
||||
Log.w(TAG, "Failed to list installed packages", e)
|
||||
emptySet()
|
||||
}
|
||||
|
||||
/** Built-in categories that are enabled in [config] and match [pkg], visible ones first. */
|
||||
private fun matchingCategory(pkg: String, config: RoutingConfig): String?
|
||||
{
|
||||
val visible = RoutingPresets.CATEGORIES.firstOrNull {
|
||||
it in config.enabledCategories && RoutingPresets.matchesCategory(it, pkg)
|
||||
}
|
||||
if (visible != null)
|
||||
{
|
||||
return visible
|
||||
}
|
||||
return RoutingPresets.NAMESPACE.takeIf {
|
||||
it in config.enabledCategories && RoutingPresets.matchesCategory(it, pkg)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Why a category or pattern covers [pkg] (ignoring personal overrides
|
||||
* and the explicit list), or null if none does.
|
||||
*/
|
||||
fun templateMatch(pkg: String, config: RoutingConfig): TemplateMatch?
|
||||
{
|
||||
val pattern = (config.packagePatterns - config.disabledPatterns).firstOrNull { PackagePatterns.matches(it, pkg) }
|
||||
val category = matchingCategory(pkg, config)
|
||||
return if (category == null && pattern == null) null else TemplateMatch(category, pattern)
|
||||
}
|
||||
|
||||
/**
|
||||
* All packages that bypass the tunnel: explicit direct apps plus the
|
||||
* installed apps matched by enabled categories and package patterns,
|
||||
* minus personal "via VPN" overrides. Explicit apps that are not
|
||||
* installed are dropped (unless the installed list is unavailable).
|
||||
*/
|
||||
fun effectiveExcludes(config: RoutingConfig, installed: Set<String>): Set<String>
|
||||
{
|
||||
val result = HashSet<String>()
|
||||
result.addAll(if (installed.isEmpty()) config.directPackages else config.directPackages.filter { it in installed })
|
||||
result.addAll(PackagePatterns.matchAll(config.packagePatterns - config.disabledPatterns, installed))
|
||||
for (category in config.enabledCategories)
|
||||
{
|
||||
val presets = RoutingPresets.presetsOf(category)
|
||||
for (pkg in installed)
|
||||
{
|
||||
if (presets.any { it.matches(pkg) })
|
||||
{
|
||||
result.add(pkg)
|
||||
}
|
||||
}
|
||||
}
|
||||
result.removeAll(config.tunnelOverrides)
|
||||
return result
|
||||
}
|
||||
|
||||
@Volatile
|
||||
private var instance: RoutingManager? = null
|
||||
|
||||
@JvmStatic
|
||||
fun getInstance(context: Context): RoutingManager =
|
||||
instance ?: synchronized(this) {
|
||||
instance ?: RoutingManager(context.applicationContext).also { instance = it }
|
||||
}
|
||||
}
|
||||
}
|
||||
+99
@@ -0,0 +1,99 @@
|
||||
/*
|
||||
* Copyright (C) 2026 SHX VPN
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or modify it
|
||||
* under the terms of the GNU General Public License as published by the
|
||||
* Free Software Foundation; either version 2 of the License, or (at your
|
||||
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful, but
|
||||
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
||||
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
|
||||
* for more details.
|
||||
*/
|
||||
|
||||
package org.strongswan.android.logic.routing
|
||||
|
||||
import org.strongswan.android.data.VpnProfile.SelectedAppsHandling
|
||||
|
||||
/**
|
||||
* Pure conversion rules from the pre-2.16 settings (named rule sets and
|
||||
* per-profile split-tunneling apps) to the single global direct-apps list.
|
||||
* No Android dependencies, so they are unit tested; [RoutingManager] applies
|
||||
* them once at startup.
|
||||
*/
|
||||
object RoutingMigration
|
||||
{
|
||||
data class RuleSetResult(
|
||||
val directPackages: Set<String>,
|
||||
val patterns: Set<String>,
|
||||
val categories: Set<String>,
|
||||
)
|
||||
|
||||
/**
|
||||
* Enabled rule sets become explicit packages plus package patterns. A
|
||||
* built-in category whose every preset is fully present among the rule
|
||||
* sets (what the old "all RF apps" action produced) turns into the
|
||||
* category itself - same coverage, but shown as one switch. Disabled rule
|
||||
* sets are dropped (the old JSON is kept in a backup key).
|
||||
*/
|
||||
fun migrateRuleSets(ruleSets: List<RuleSet>): RuleSetResult
|
||||
{
|
||||
val active = ruleSets.filter { it.enabled }
|
||||
val direct = HashSet<String>()
|
||||
val patterns = LinkedHashSet<String>()
|
||||
for (ruleSet in active)
|
||||
{
|
||||
direct.addAll(ruleSet.packages)
|
||||
ruleSet.prefixes.forEach { patterns.add(PackagePatterns.fromPrefix(it)) }
|
||||
ruleSet.keywords.forEach { patterns.add(PackagePatterns.fromKeyword(it)) }
|
||||
}
|
||||
val categories = LinkedHashSet<String>()
|
||||
for (category in RoutingPresets.ALL_CATEGORIES)
|
||||
{
|
||||
val presets = RoutingPresets.presetsOf(category)
|
||||
if (presets.isNotEmpty() && presets.all { preset -> active.any { it.covers(preset) } })
|
||||
{
|
||||
categories.add(category)
|
||||
for (preset in presets)
|
||||
{
|
||||
preset.prefixes.forEach { patterns.remove(PackagePatterns.fromPrefix(it)) }
|
||||
preset.keywords.forEach { patterns.remove(PackagePatterns.fromKeyword(it)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
return RuleSetResult(direct, patterns, categories)
|
||||
}
|
||||
|
||||
private fun RuleSet.covers(preset: RoutingPresets.Preset): Boolean
|
||||
{
|
||||
if (preset.prefixes.isEmpty() && preset.keywords.isEmpty())
|
||||
{
|
||||
return false
|
||||
}
|
||||
val lowerKeywords = keywords.map { it.lowercase() }.toSet()
|
||||
return prefixes.containsAll(preset.prefixes) && lowerKeywords.containsAll(preset.keywords.map { it.lowercase() })
|
||||
}
|
||||
|
||||
/**
|
||||
* Packages a profile's old per-app setting sends direct (outside the VPN):
|
||||
* - EXCLUDE: the listed apps;
|
||||
* - ONLY: "all installed internet apps except the listed ones" snapshot
|
||||
* (an empty ONLY list never took effect in the old code, so it yields
|
||||
* nothing);
|
||||
* - DISABLE: nothing.
|
||||
* The app's own package is never listed (it is always excluded anyway).
|
||||
*/
|
||||
fun profileAppsToDirect(
|
||||
handling: SelectedAppsHandling,
|
||||
selected: Set<String>,
|
||||
installed: Set<String>,
|
||||
ownPackage: String,
|
||||
): Set<String> = when (handling)
|
||||
{
|
||||
SelectedAppsHandling.SELECTED_APPS_EXCLUDE -> selected - ownPackage
|
||||
SelectedAppsHandling.SELECTED_APPS_ONLY ->
|
||||
if (selected.isEmpty()) emptySet() else installed - selected - ownPackage
|
||||
else -> emptySet()
|
||||
}
|
||||
}
|
||||
+138
@@ -0,0 +1,138 @@
|
||||
/*
|
||||
* Copyright (C) 2026 SHX VPN
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or modify it
|
||||
* under the terms of the GNU General Public License as published by the
|
||||
* Free Software Foundation; either version 2 of the License, or (at your
|
||||
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful, but
|
||||
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
||||
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
|
||||
* for more details.
|
||||
*/
|
||||
|
||||
package org.strongswan.android.logic.routing
|
||||
|
||||
/**
|
||||
* Built-in app templates, grouped into categories the user can switch on in
|
||||
* one tap ("Banks", "Marketplaces", ...).
|
||||
* - Prefixes match package names, so "ru." catches every package in the ru.*
|
||||
* reverse-domain namespace.
|
||||
* - Keywords match anywhere in the package name (case-insensitive), so
|
||||
* "yandex" also catches e.g. com.yandex.browser.
|
||||
*
|
||||
* Deliberately short or ambiguous strings (yota, cian, mts, ivi, mkb) are
|
||||
* used as prefixes only, to avoid false positives.
|
||||
*/
|
||||
object RoutingPresets
|
||||
{
|
||||
/* category ids; the namespace-wide preset lives in the hidden NAMESPACE
|
||||
* category that only "all RF apps" switches on */
|
||||
const val BANKS = "banks"
|
||||
const val GOV = "gov"
|
||||
const val SHOP = "shop"
|
||||
const val SOCIAL = "social"
|
||||
const val TRAVEL = "travel"
|
||||
const val SERVICES = "services"
|
||||
const val NAMESPACE = "ru_namespace"
|
||||
|
||||
/** User-visible categories in display order (the hidden [NAMESPACE] one is excluded). */
|
||||
val CATEGORIES: List<String> = listOf(BANKS, GOV, SHOP, SOCIAL, TRAVEL, SERVICES)
|
||||
|
||||
/** Everything "all RF apps" switches on. */
|
||||
val ALL_CATEGORIES: Set<String> = (CATEGORIES + NAMESPACE).toSet()
|
||||
|
||||
data class Preset(
|
||||
val name: String,
|
||||
val category: String,
|
||||
val prefixes: List<String> = emptyList(),
|
||||
val keywords: List<String> = emptyList(),
|
||||
)
|
||||
|
||||
/** Whether a package belongs to the preset (prefix, or keyword anywhere, case-insensitive). */
|
||||
fun Preset.matches(pkg: String): Boolean
|
||||
{
|
||||
if (prefixes.any { pkg.startsWith(it) })
|
||||
{
|
||||
return true
|
||||
}
|
||||
val lower = pkg.lowercase()
|
||||
return keywords.any { lower.contains(it.lowercase()) }
|
||||
}
|
||||
|
||||
/** Presets of one category, in declaration order. */
|
||||
fun presetsOf(category: String): List<Preset> = RF_BUNDLE.filter { it.category == category }
|
||||
|
||||
/** Whether the package matches any preset of [category] (the namespace one included). */
|
||||
fun matchesCategory(category: String, pkg: String): Boolean =
|
||||
presetsOf(category).any { it.matches(pkg) }
|
||||
|
||||
/** First user-visible category the package belongs to, or null. */
|
||||
fun categoryOf(pkg: String): String? =
|
||||
CATEGORIES.firstOrNull { matchesCategory(it, pkg) }
|
||||
|
||||
val RF_BUNDLE: List<Preset> = listOf(
|
||||
/* namespace-wide */
|
||||
Preset("Все RU.*", NAMESPACE, prefixes = listOf("ru.")),
|
||||
/* ecosystems */
|
||||
Preset("Яндекс", SOCIAL, prefixes = listOf("ru.yandex."), keywords = listOf("yandex")),
|
||||
Preset("VK", SOCIAL, prefixes = listOf("com.vkontakte", "ru.vk", "com.vk"), keywords = listOf("vkontakte")),
|
||||
Preset("Одноклассники", SOCIAL, prefixes = listOf("ru.ok.")),
|
||||
Preset("Дзен", SOCIAL, keywords = listOf("dzen")),
|
||||
Preset("Rutube", SOCIAL, keywords = listOf("rutube")),
|
||||
Preset("Звук", SOCIAL, keywords = listOf("zvuk", "zvooq")),
|
||||
Preset("Иви", SOCIAL, prefixes = listOf("ru.ivi")),
|
||||
/* banks & finance */
|
||||
Preset("Сбер", BANKS, keywords = listOf("sber")),
|
||||
Preset("Т-Банк", BANKS, keywords = listOf("tinkoff", "tbank")),
|
||||
Preset("ВТБ", BANKS, keywords = listOf("vtb")),
|
||||
Preset("Альфа-Банк", BANKS, keywords = listOf("alfabank", "alpha-bank")),
|
||||
Preset("Газпромбанк", BANKS, keywords = listOf("gazprombank")),
|
||||
Preset("Совкомбанк", BANKS, keywords = listOf("sovcombank")),
|
||||
Preset("Почта Банк", BANKS, keywords = listOf("pochtabank")),
|
||||
Preset("РСХБ", BANKS, keywords = listOf("rshb", "rosselkhozbank")),
|
||||
Preset("ОТП-Банк", BANKS, prefixes = listOf("ru.otpbank"), keywords = listOf("otpbank")),
|
||||
Preset("МКБ", BANKS, prefixes = listOf("ru.mkb")),
|
||||
Preset("Уралсиб", BANKS, keywords = listOf("uralsib")),
|
||||
Preset("ЮMoney", BANKS, keywords = listOf("yoomoney")),
|
||||
Preset("Золотая Корона", BANKS, keywords = listOf("koronapay")),
|
||||
/* government */
|
||||
Preset("Госуслуги", GOV, prefixes = listOf("ru.rostel"), keywords = listOf("gosuslugi")),
|
||||
Preset("Мой налог", GOV, keywords = listOf("nalog")),
|
||||
/* retail & marketplaces */
|
||||
Preset("Ozon", SHOP, keywords = listOf("ozon")),
|
||||
Preset("Wildberries", SHOP, keywords = listOf("wildberries")),
|
||||
Preset("Мегамаркет", SHOP, keywords = listOf("megamarket")),
|
||||
Preset("Пятёрочка", SHOP, keywords = listOf("pyaterochka")),
|
||||
Preset("Перекрёсток", SHOP, keywords = listOf("perekrestok")),
|
||||
Preset("Магнит", SHOP, keywords = listOf("magnit")),
|
||||
Preset("Лента", SHOP, keywords = listOf("lenta")),
|
||||
Preset("М.Видео", SHOP, keywords = listOf("mvideo")),
|
||||
Preset("Спортмастер", SHOP, keywords = listOf("sportmaster")),
|
||||
Preset("Лемана ПРО", SHOP, keywords = listOf("leroymerlin", "lemana")),
|
||||
Preset("Самокат", SHOP, keywords = listOf("samokat")),
|
||||
Preset("ВкусВилл", SHOP, keywords = listOf("vkusvill")),
|
||||
/* telecom */
|
||||
Preset("МТС", SOCIAL, prefixes = listOf("ru.mts", "com.mts")),
|
||||
Preset("Мегафон", SOCIAL, keywords = listOf("megafon")),
|
||||
Preset("Билайн", SOCIAL, keywords = listOf("beeline")),
|
||||
Preset("T2/Tele2", SOCIAL, prefixes = listOf("ru.t2"), keywords = listOf("tele2")),
|
||||
Preset("Yota", SOCIAL, prefixes = listOf("ru.yota")),
|
||||
/* transport & travel */
|
||||
Preset("РЖД", TRAVEL, keywords = listOf("rzd")),
|
||||
Preset("Аэрофлот", TRAVEL, keywords = listOf("aeroflot")),
|
||||
Preset("Туту.ру", TRAVEL, keywords = listOf("tutu.ru")),
|
||||
Preset("Aviasales", TRAVEL, keywords = listOf("aviasales")),
|
||||
Preset("Ситимобил", TRAVEL, keywords = listOf("sitimobil")),
|
||||
Preset("Делимобиль", TRAVEL, keywords = listOf("delimobil")),
|
||||
/* services */
|
||||
Preset("2ГИС", SERVICES, keywords = listOf("2gis", "dublgis")),
|
||||
Preset("ЦИАН", SERVICES, prefixes = listOf("ru.cian")),
|
||||
Preset("Дром", SERVICES, keywords = listOf("drom")),
|
||||
Preset("Юла", SERVICES, keywords = listOf("youla")),
|
||||
Preset("HeadHunter", SERVICES, prefixes = listOf("ru.hh"), keywords = listOf("headhunter")),
|
||||
Preset("Почта России", SERVICES, keywords = listOf("pochta", "russianpost")),
|
||||
Preset("СДЭК", SERVICES, keywords = listOf("cdek")),
|
||||
)
|
||||
}
|
||||
+794
@@ -0,0 +1,794 @@
|
||||
/*
|
||||
* Copyright (C) 2026 SHX VPN
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or modify it
|
||||
* under the terms of the GNU General Public License as published by the
|
||||
* Free Software Foundation; either version 2 of the License, or (at your
|
||||
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful, but
|
||||
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
||||
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
|
||||
* for more details.
|
||||
*/
|
||||
|
||||
package org.strongswan.android.logic.routing
|
||||
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.ServiceConnection
|
||||
import android.content.SharedPreferences
|
||||
import android.os.Handler
|
||||
import android.os.IBinder
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
import androidx.preference.PreferenceManager
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.strongswan.android.logic.CharonVpnService
|
||||
import org.strongswan.android.logic.VpnStateService
|
||||
import org.strongswan.android.utils.Constants
|
||||
import org.strongswan.android.utils.IPRange
|
||||
import org.strongswan.android.utils.IPRangeSet
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.Inet4Address
|
||||
import java.net.Inet6Address
|
||||
import java.net.InetAddress
|
||||
import java.net.Proxy
|
||||
import java.net.URL
|
||||
import java.security.MessageDigest
|
||||
|
||||
/** Refresh status of the RU geoip list. */
|
||||
enum class RuIpListStatus
|
||||
{
|
||||
IDLE, UPDATING, UP_TO_DATE, ERROR,
|
||||
}
|
||||
|
||||
data class RuIpListState(
|
||||
val enabled: Boolean = false,
|
||||
val status: RuIpListStatus = RuIpListStatus.IDLE,
|
||||
val lastUpdatedMs: Long = 0L,
|
||||
val ipv4Ranges: Int = 0,
|
||||
val ipv6Ranges: Int = 0,
|
||||
val invalidLines: Int = 0,
|
||||
val mode: String = Constants.RUSPLIT_MODE_SYSTEM,
|
||||
)
|
||||
|
||||
/**
|
||||
* RU geoip list (beta): downloads the list of Russian IP networks from
|
||||
* MetaCubeX/meta-rules-dat so [CharonVpnService.BuilderCache] can keep those
|
||||
* ranges out of the tunnel routes - traffic to RU addresses bypasses the
|
||||
* VPN while everything else goes through the tunnel.
|
||||
*
|
||||
* The list is large (thousands of CIDRs), so it is merged into a sorted,
|
||||
* non-overlapping form once, and then rounded up ([fitExclusionsToComplement])
|
||||
* until the complement - the routes left for the tunnel - is small enough
|
||||
* to install as regular routes on every Android version. excludeRoute() is
|
||||
* deliberately never used: on some devices (One UI was observed) any
|
||||
* excluded route breaks the resolver's DNS-over-TLS transport for every
|
||||
* server, which kills strict private DNS. With plain routes the RU space
|
||||
* is simply absent from the VPN table, and bypassed traffic falls through
|
||||
* to the physical network. When the list or the toggle changes while
|
||||
* connected, the connection is restarted (debounced) because routes only
|
||||
* apply when the TUN interface is created.
|
||||
*/
|
||||
class RuIpListManager private constructor(private val context: Context)
|
||||
{
|
||||
private val handler = Handler(Looper.getMainLooper())
|
||||
private val prefs: SharedPreferences = PreferenceManager.getDefaultSharedPreferences(context)
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
|
||||
private var service: VpnStateService? = null
|
||||
|
||||
/** Sorted, non-overlapping RU ranges; immutable snapshots swapped on update. */
|
||||
@Volatile
|
||||
private var excludedV4: List<IPRange> = emptyList()
|
||||
|
||||
@Volatile
|
||||
private var excludedV6: List<IPRange> = emptyList()
|
||||
|
||||
|
||||
@Volatile
|
||||
private var updating = false
|
||||
|
||||
private val _state = MutableStateFlow(RuIpListState())
|
||||
val state: StateFlow<RuIpListState> = _state
|
||||
|
||||
private val connection = object : ServiceConnection
|
||||
{
|
||||
override fun onServiceConnected(name: ComponentName?, binder: IBinder?)
|
||||
{
|
||||
service = (binder as VpnStateService.LocalBinder).service
|
||||
}
|
||||
|
||||
override fun onServiceDisconnected(name: ComponentName?)
|
||||
{
|
||||
service = null
|
||||
}
|
||||
}
|
||||
|
||||
private val reconnectRunnable = Runnable {
|
||||
val svc = service ?: return@Runnable
|
||||
if (svc.state == VpnStateService.State.CONNECTED)
|
||||
{
|
||||
if (CharonVpnService.isUserspaceRoutingEnabled(context))
|
||||
{ /* mode B: the classification set is swapped atomically in
|
||||
* native code, no reestablish needed */
|
||||
CharonVpnService.pushUserspaceRoutingData(context)
|
||||
Log.i(TAG, "RU IP list changed, userspace routing set hot-swapped")
|
||||
}
|
||||
else
|
||||
{
|
||||
Log.i(TAG, "RU IP list changed, reconnecting to apply it")
|
||||
svc.reconnectPreservingLog()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun start()
|
||||
{
|
||||
context.bindService(Intent(context, VpnStateService::class.java), connection, android.app.Service.BIND_AUTO_CREATE)
|
||||
publish(
|
||||
enabled = prefs.getBoolean(Constants.PREF_RUSPLIT_GEOIP_ENABLED, false),
|
||||
mode = prefs.getString(Constants.PREF_RUSPLIT_MODE, Constants.RUSPLIT_MODE_SYSTEM)
|
||||
?: Constants.RUSPLIT_MODE_SYSTEM,
|
||||
)
|
||||
scope.launch {
|
||||
loadFromDisk()
|
||||
maybeAutoCheck()
|
||||
}
|
||||
}
|
||||
|
||||
fun setEnabled(enabled: Boolean)
|
||||
{
|
||||
prefs.edit().putBoolean(Constants.PREF_RUSPLIT_GEOIP_ENABLED, enabled).apply()
|
||||
publish(enabled = enabled)
|
||||
if (isUserspaceMode())
|
||||
{ /* the OS routes do not depend on the toggle in this mode, the
|
||||
* classification set is hot-swapped in native code instead */
|
||||
CharonVpnService.pushUserspaceRoutingData(context)
|
||||
}
|
||||
else if (enabled || excludedV4.isNotEmpty() || excludedV6.isNotEmpty())
|
||||
{ /* routes only change when the TUN is re-established - also when
|
||||
* the split is switched OFF, or the RU ranges keep bypassing the
|
||||
* tunnel until the next manual reconnect */
|
||||
scheduleReconnect()
|
||||
}
|
||||
}
|
||||
|
||||
/** Sorted non-overlapping RU IPv4 ranges; empty when disabled or not loaded. */
|
||||
fun getExcludedV4(): List<IPRange> = if (_state.value.enabled) excludedV4 else emptyList()
|
||||
|
||||
/** Sorted non-overlapping RU IPv6 ranges; empty when disabled or not loaded. */
|
||||
fun getExcludedV6(): List<IPRange> = if (_state.value.enabled) excludedV6 else emptyList()
|
||||
|
||||
/**
|
||||
* Downloads and applies a fresh list. Called from the UI (manual refresh,
|
||||
* regardless of the toggle) and internally when the automatic check is due.
|
||||
* @return true when a new list was downloaded and applied
|
||||
*/
|
||||
suspend fun updateNow(): Boolean
|
||||
{
|
||||
if (updating)
|
||||
{
|
||||
return false
|
||||
}
|
||||
updating = true
|
||||
publish(status = RuIpListStatus.UPDATING)
|
||||
return try
|
||||
{
|
||||
val (text, sha256) = withContext(Dispatchers.IO) { fetchList() }
|
||||
if (sha256 == prefs.getString(PREF_SHA256, null) &&
|
||||
(excludedV4.isNotEmpty() || excludedV6.isNotEmpty()))
|
||||
{ /* identical list: nothing to parse, save or re-apply - above
|
||||
* all no reconnect that tears down a healthy tunnel daily */
|
||||
val now = System.currentTimeMillis()
|
||||
prefs.edit().putLong(PREF_LAST_UPDATE, now).remove(PREF_LAST_FAIL).apply()
|
||||
publish(status = RuIpListStatus.UP_TO_DATE, lastUpdatedMs = now)
|
||||
Log.i(TAG, "RU geoip list unchanged")
|
||||
return true
|
||||
}
|
||||
val parsed = withContext(Dispatchers.Default) { parse(text) }
|
||||
if (parsed.v4.isEmpty() && parsed.v6.isEmpty())
|
||||
{
|
||||
throw IOException("downloaded list contains no CIDRs")
|
||||
}
|
||||
withContext(Dispatchers.IO) { saveToDisk(text) }
|
||||
excludedV4 = parsed.v4
|
||||
excludedV6 = parsed.v6
|
||||
val now = System.currentTimeMillis()
|
||||
prefs.edit()
|
||||
.putLong(PREF_LAST_UPDATE, now)
|
||||
.putString(PREF_SHA256, sha256)
|
||||
.remove(PREF_LAST_FAIL)
|
||||
.apply()
|
||||
publish(
|
||||
status = RuIpListStatus.UP_TO_DATE,
|
||||
lastUpdatedMs = now,
|
||||
ipv4Ranges = parsed.v4.size,
|
||||
ipv6Ranges = parsed.v6.size,
|
||||
invalidLines = parsed.invalid,
|
||||
)
|
||||
Log.i(TAG, "RU geoip list applied: ${parsed.v4.size} IPv4, ${parsed.v6.size} IPv6 ranges")
|
||||
if (_state.value.enabled)
|
||||
{
|
||||
scheduleReconnect()
|
||||
}
|
||||
true
|
||||
}
|
||||
catch (e: Exception)
|
||||
{
|
||||
Log.w(TAG, "RU geoip list update failed", e)
|
||||
/* remembered so a blocked source is not retried on every cold start */
|
||||
prefs.edit().putLong(PREF_LAST_FAIL, System.currentTimeMillis()).apply()
|
||||
publish(status = RuIpListStatus.ERROR)
|
||||
false
|
||||
}
|
||||
finally
|
||||
{
|
||||
updating = false
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------ internals ------------------------------ */
|
||||
|
||||
private suspend fun loadFromDisk()
|
||||
{
|
||||
val file = listFile()
|
||||
if (file.isFile)
|
||||
{
|
||||
try
|
||||
{
|
||||
/* thousands of CIDRs: read and parse off the main thread (this
|
||||
* runs at every process start) */
|
||||
val parsed = withContext(Dispatchers.Default) { parse(file.readText()) }
|
||||
excludedV4 = parsed.v4
|
||||
excludedV6 = parsed.v6
|
||||
publish(
|
||||
ipv4Ranges = parsed.v4.size,
|
||||
ipv6Ranges = parsed.v6.size,
|
||||
invalidLines = parsed.invalid,
|
||||
)
|
||||
}
|
||||
catch (e: Exception)
|
||||
{
|
||||
Log.w(TAG, "Failed to load the saved RU geoip list", e)
|
||||
}
|
||||
}
|
||||
publish(
|
||||
status = if (_state.value.ipv4Ranges > 0 || _state.value.ipv6Ranges > 0) RuIpListStatus.UP_TO_DATE else RuIpListStatus.IDLE,
|
||||
lastUpdatedMs = prefs.getLong(PREF_LAST_UPDATE, 0L),
|
||||
)
|
||||
}
|
||||
|
||||
private fun maybeAutoCheck()
|
||||
{
|
||||
if (!prefs.getBoolean(Constants.PREF_RUSPLIT_GEOIP_ENABLED, false))
|
||||
{
|
||||
return
|
||||
}
|
||||
if (isAutoCheckDue(System.currentTimeMillis(), prefs.getLong(PREF_LAST_UPDATE, 0L),
|
||||
prefs.getLong(PREF_LAST_FAIL, 0L)))
|
||||
{
|
||||
scope.launch { updateNow() }
|
||||
}
|
||||
}
|
||||
|
||||
/** Fetches the list from the primary source, falling back to the mirror. */
|
||||
private fun fetchList(): Pair<String, String>
|
||||
{
|
||||
var lastError: Exception? = null
|
||||
for (url in listOf(Constants.RUSPLIT_GEOIP_URL, Constants.RUSPLIT_GEOIP_MIRROR_URL))
|
||||
{
|
||||
try
|
||||
{
|
||||
return fetch(url)
|
||||
}
|
||||
catch (e: Exception)
|
||||
{
|
||||
Log.w(TAG, "Fetch failed: $url", e)
|
||||
lastError = e
|
||||
}
|
||||
}
|
||||
throw lastError ?: IOException("no source available")
|
||||
}
|
||||
|
||||
private fun fetch(url: String): Pair<String, String>
|
||||
{
|
||||
val conn = URL(url).openConnection(Proxy.NO_PROXY) as HttpURLConnection
|
||||
conn.connectTimeout = CONNECT_TIMEOUT_MS
|
||||
conn.readTimeout = READ_TIMEOUT_MS
|
||||
conn.setRequestProperty("Connection", "close")
|
||||
conn.setRequestProperty("Cache-Control", "no-cache")
|
||||
try
|
||||
{
|
||||
val code = conn.responseCode
|
||||
if (code != HttpURLConnection.HTTP_OK)
|
||||
{
|
||||
throw IOException("HTTP $code from $url")
|
||||
}
|
||||
val bytes = conn.inputStream.use { it.readBytes() }
|
||||
val text = bytes.toString(Charsets.UTF_8)
|
||||
val digest = MessageDigest.getInstance("SHA-256").digest(bytes)
|
||||
val sha256 = digest.joinToString("") { "%02x".format(it) }
|
||||
if (!text.contains('/'))
|
||||
{ /* guard against an HTML error page served with status 200 */
|
||||
throw IOException("not a CIDR list: $url")
|
||||
}
|
||||
return text to sha256
|
||||
}
|
||||
finally
|
||||
{
|
||||
conn.disconnect()
|
||||
}
|
||||
}
|
||||
|
||||
private fun saveToDisk(text: String)
|
||||
{
|
||||
val dir = File(context.filesDir, DIR)
|
||||
if (!dir.exists() && !dir.mkdirs())
|
||||
{
|
||||
throw IOException("cannot create ${dir.name}")
|
||||
}
|
||||
val target = listFile()
|
||||
val tmp = File(dir, "$FILE_NAME.tmp")
|
||||
try
|
||||
{
|
||||
tmp.writeText(text, Charsets.UTF_8)
|
||||
if (!tmp.renameTo(target))
|
||||
{
|
||||
target.delete()
|
||||
if (!tmp.renameTo(target))
|
||||
{
|
||||
throw IOException("cannot replace ${target.name}")
|
||||
}
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
tmp.delete()
|
||||
}
|
||||
}
|
||||
|
||||
private fun listFile(): File = File(File(context.filesDir, DIR), FILE_NAME)
|
||||
|
||||
private fun scheduleReconnect()
|
||||
{
|
||||
handler.removeCallbacks(reconnectRunnable)
|
||||
handler.postDelayed(reconnectRunnable, RECONNECT_DEBOUNCE_MS)
|
||||
}
|
||||
|
||||
/** Mode changes always alter the OS route scheme, so they force a reconnect. */
|
||||
private val forceReconnectRunnable = Runnable {
|
||||
val svc = service ?: return@Runnable
|
||||
if (svc.state == VpnStateService.State.CONNECTED)
|
||||
{
|
||||
Log.i(TAG, "routing mode changed, reconnecting to apply it")
|
||||
svc.reconnectPreservingLog()
|
||||
}
|
||||
}
|
||||
|
||||
private fun isUserspaceMode(): Boolean =
|
||||
Constants.RUSPLIT_MODE_USERSPACE == _state.value.mode
|
||||
|
||||
/**
|
||||
* Switches how the RU split is routed when the toggle is on:
|
||||
* [Constants.RUSPLIT_MODE_SYSTEM] installs the coarsened complement as
|
||||
* OS routes, [Constants.RUSPLIT_MODE_USERSPACE] classifies per packet
|
||||
* in native code. Requires a reconnect because the OS routes change.
|
||||
*/
|
||||
fun setMode(mode: String)
|
||||
{
|
||||
if (_state.value.mode == mode)
|
||||
{
|
||||
return
|
||||
}
|
||||
prefs.edit().putString(Constants.PREF_RUSPLIT_MODE, mode).apply()
|
||||
publish(mode = mode)
|
||||
if (_state.value.enabled)
|
||||
{
|
||||
handler.removeCallbacks(reconnectRunnable)
|
||||
handler.removeCallbacks(forceReconnectRunnable)
|
||||
handler.postDelayed(forceReconnectRunnable, RECONNECT_DEBOUNCE_MS)
|
||||
}
|
||||
}
|
||||
|
||||
private fun publish(
|
||||
enabled: Boolean = _state.value.enabled,
|
||||
status: RuIpListStatus = _state.value.status,
|
||||
lastUpdatedMs: Long = _state.value.lastUpdatedMs,
|
||||
ipv4Ranges: Int = _state.value.ipv4Ranges,
|
||||
ipv6Ranges: Int = _state.value.ipv6Ranges,
|
||||
invalidLines: Int = _state.value.invalidLines,
|
||||
mode: String = _state.value.mode,
|
||||
)
|
||||
{
|
||||
_state.value = RuIpListState(enabled, status, lastUpdatedMs, ipv4Ranges, ipv6Ranges, invalidLines, mode)
|
||||
}
|
||||
|
||||
companion object
|
||||
{
|
||||
private const val TAG = "RuIpListManager"
|
||||
private const val DIR = "rusplit"
|
||||
private const val FILE_NAME = "ru.list"
|
||||
private const val PREF_LAST_UPDATE = "rusplit_last_update"
|
||||
private const val PREF_SHA256 = "rusplit_sha256"
|
||||
private const val PREF_LAST_FAIL = "rusplit_last_fail"
|
||||
/* after a failed download wait this long before the next automatic try */
|
||||
internal const val FAIL_RETRY_MS = 3 * 60 * 60 * 1000L
|
||||
|
||||
/** Whether the automatic refresh is due (pure, unit-testable). */
|
||||
@JvmStatic
|
||||
internal fun isAutoCheckDue(now: Long, lastUpdate: Long, lastFail: Long): Boolean
|
||||
{
|
||||
if (now - lastUpdate < Constants.RUSPLIT_CHECK_INTERVAL_MS)
|
||||
{
|
||||
return false
|
||||
}
|
||||
/* clock moved backwards: don't let a "future" failure block forever */
|
||||
return lastFail <= 0L || lastFail > now || now - lastFail >= FAIL_RETRY_MS
|
||||
}
|
||||
private const val RECONNECT_DEBOUNCE_MS = 2000L
|
||||
private const val CONNECT_TIMEOUT_MS = 10_000
|
||||
private const val READ_TIMEOUT_MS = 30_000
|
||||
|
||||
data class Parsed(val v4: List<IPRange>, val v6: List<IPRange>, val invalid: Int)
|
||||
|
||||
/**
|
||||
* Parses a CIDR list tolerantly: blank lines and #-comments are
|
||||
* ignored, a leading '+' include marker is stripped, unparsable and
|
||||
* '-'-negated lines are counted but never fail the whole list.
|
||||
*/
|
||||
fun parse(text: String): Parsed
|
||||
{
|
||||
val v4 = ArrayList<IPRange>()
|
||||
val v6 = ArrayList<IPRange>()
|
||||
var invalid = 0
|
||||
for (raw in text.lineSequence())
|
||||
{
|
||||
var line = raw
|
||||
val comment = line.indexOf('#')
|
||||
if (comment >= 0)
|
||||
{
|
||||
line = line.substring(0, comment)
|
||||
}
|
||||
line = line.trim()
|
||||
if (line.isEmpty())
|
||||
{
|
||||
continue
|
||||
}
|
||||
if (line[0] == '+')
|
||||
{
|
||||
line = line.substring(1).trim()
|
||||
if (line.isEmpty())
|
||||
{
|
||||
continue
|
||||
}
|
||||
}
|
||||
if (line[0] == '-')
|
||||
{ /* negations are not part of a plain geoip list */
|
||||
invalid++
|
||||
continue
|
||||
}
|
||||
try
|
||||
{
|
||||
val range = IPRange(line)
|
||||
when (range.from)
|
||||
{
|
||||
is Inet4Address -> v4.add(range)
|
||||
is Inet6Address -> v6.add(range)
|
||||
else -> invalid++
|
||||
}
|
||||
}
|
||||
catch (e: Exception)
|
||||
{
|
||||
invalid++
|
||||
}
|
||||
}
|
||||
return Parsed(mergeRanges(v4), mergeRanges(v6), invalid)
|
||||
}
|
||||
|
||||
/** Sorts by address and merges overlapping/adjacent ranges linearly. */
|
||||
fun mergeRanges(ranges: List<IPRange>): List<IPRange>
|
||||
{
|
||||
if (ranges.isEmpty())
|
||||
{
|
||||
return emptyList()
|
||||
}
|
||||
val sorted = ranges.toMutableList()
|
||||
sorted.sort()
|
||||
val result = ArrayList<IPRange>(sorted.size)
|
||||
for (range in sorted)
|
||||
{
|
||||
val last = result.lastOrNull()
|
||||
val merged = last?.merge(range)
|
||||
if (merged != null)
|
||||
{
|
||||
result[result.size - 1] = merged
|
||||
}
|
||||
else
|
||||
{
|
||||
result.add(range)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Routes to install for the TUN builder: the given ranges minus the
|
||||
* (sorted, non-overlapping) exclusions, split into proper subnets,
|
||||
* or null if that would exceed [maxRoutes] routes. Applied directly
|
||||
* instead of via IPRangeSet.remove(), which is quadratic in the
|
||||
* number of excluded ranges.
|
||||
*/
|
||||
@JvmStatic
|
||||
fun subtractAndSplit(ranges: IPRangeSet, exclusions: List<IPRange>, maxRoutes: Int): List<IPRange>?
|
||||
{
|
||||
if (exclusions.isEmpty())
|
||||
{
|
||||
return ranges.subnets().toList()
|
||||
}
|
||||
val pieces = ArrayList<IPRange>()
|
||||
for (range in ranges)
|
||||
{
|
||||
pieces.addAll(subtract(range, exclusions))
|
||||
}
|
||||
val result = ArrayList<IPRange>()
|
||||
for (piece in pieces)
|
||||
{
|
||||
result.addAll(piece.toSubnets())
|
||||
if (result.size > maxRoutes)
|
||||
{
|
||||
return null
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/** The common part of two ranges, or null if they are disjoint. */
|
||||
@JvmStatic
|
||||
fun intersect(a: IPRange, b: IPRange): IPRange?
|
||||
{
|
||||
val from = if (compare(a.from, b.from) > 0) a.from else b.from
|
||||
val to = if (compare(a.to, b.to) < 0) a.to else b.to
|
||||
return if (compare(from, to) <= 0) IPRange(from, to) else null
|
||||
}
|
||||
|
||||
/**
|
||||
* The range rounded up to whole prefixes no longer than [maxPrefix].
|
||||
* The result always contains the original range.
|
||||
*/
|
||||
@JvmStatic
|
||||
fun roundUp(range: IPRange, maxPrefix: Int): IPRange =
|
||||
IPRange(alignDown(range.from, maxPrefix), alignUp(range.to, maxPrefix))
|
||||
|
||||
/** Zeroes all host bits beyond [maxPrefix]. */
|
||||
private fun alignDown(address: InetAddress, maxPrefix: Int): InetAddress
|
||||
{
|
||||
val bytes = address.address
|
||||
for (i in bytes.indices)
|
||||
{
|
||||
val keep = (maxPrefix - i * 8).coerceIn(0, 8)
|
||||
val mask = if (keep == 8) 0xff else (0xff shl (8 - keep)) and 0xff
|
||||
bytes[i] = (bytes[i].toInt() and mask).toByte()
|
||||
}
|
||||
return InetAddress.getByAddress(bytes)
|
||||
}
|
||||
|
||||
/** Sets all host bits beyond [maxPrefix], i.e. the last address of the containing prefix. */
|
||||
private fun alignUp(address: InetAddress, maxPrefix: Int): InetAddress
|
||||
{
|
||||
val bytes = address.address
|
||||
for (i in bytes.indices)
|
||||
{
|
||||
val keep = (maxPrefix - i * 8).coerceIn(0, 8)
|
||||
val mask = if (keep == 8) 0xff else (0xff shl (8 - keep)) and 0xff
|
||||
bytes[i] = ((bytes[i].toInt() and mask) or (mask.inv() and 0xff)).toByte()
|
||||
}
|
||||
return InetAddress.getByAddress(bytes)
|
||||
}
|
||||
|
||||
/**
|
||||
* Exclusion lists adjusted so the complement route set - what remains
|
||||
* of the tunnel routes after subtracting the exclusions from both
|
||||
* address families - is small enough for the system to install as
|
||||
* regular routes: ranges are rounded up to whole prefixes of
|
||||
* decreasing length (down to [MIN_FIT_PREFIX4]/[MIN_FIT_PREFIX6]) and
|
||||
* re-merged, widening the bypassed space a little. Installing the
|
||||
* complement instead of using excludeRoute() is deliberate: on some
|
||||
* devices (One UI was observed) ANY excluded route breaks the
|
||||
* resolver's DNS-over-TLS transport, while plain route sets of this
|
||||
* size are universally supported. Returns null when even the
|
||||
* coarsest acceptable rounding does not fit; the caller then tunnels
|
||||
* all traffic instead of risking a failed TUN setup.
|
||||
*/
|
||||
@JvmStatic
|
||||
fun fitExclusionsToComplement(
|
||||
ranges4: IPRangeSet?,
|
||||
ranges6: IPRangeSet?,
|
||||
exact4: List<IPRange>,
|
||||
exact6: List<IPRange>,
|
||||
): Pair<List<IPRange>, List<IPRange>>?
|
||||
{
|
||||
if (exact4.isEmpty() && exact6.isEmpty())
|
||||
{
|
||||
return Pair(exact4, exact6)
|
||||
}
|
||||
if (complementBytes(ranges4, ranges6, exact4, exact6) <= ROUTE_BYTES_BUDGET)
|
||||
{
|
||||
return Pair(exact4, exact6)
|
||||
}
|
||||
var len4 = 32
|
||||
var len6 = 128
|
||||
var cur4 = exact4
|
||||
var cur6 = exact6
|
||||
while (complementBytes(ranges4, ranges6, cur4, cur6) > ROUTE_BYTES_BUDGET)
|
||||
{
|
||||
if (len4 <= MIN_FIT_PREFIX4 && len6 <= MIN_FIT_PREFIX6)
|
||||
{
|
||||
return null
|
||||
}
|
||||
/* always round the original list, and coarsen whichever family
|
||||
* currently contributes more to the transaction */
|
||||
if (len4 > MIN_FIT_PREFIX4 && (len6 <= MIN_FIT_PREFIX6 || complementBytes4(ranges4, cur4) >= complementBytes6(ranges6, cur6)))
|
||||
{
|
||||
len4--
|
||||
cur4 = mergeRanges(exact4.map { roundUp(it, len4) })
|
||||
}
|
||||
else
|
||||
{
|
||||
len6--
|
||||
cur6 = mergeRanges(exact6.map { roundUp(it, len6) })
|
||||
}
|
||||
}
|
||||
return Pair(cur4, cur6)
|
||||
}
|
||||
|
||||
/** Worst-case marshalled size of a single route entry in the VpnConfig parcel. */
|
||||
private const val V4_ROUTE_BYTES = 64L
|
||||
private const val V6_ROUTE_BYTES = 80L
|
||||
|
||||
/**
|
||||
* Keep the estimated size of the complement routes below this, so the
|
||||
* system reliably receives them (binder transactions are limited to
|
||||
* 1 MB; the rest of the config needs room, too). With the real RU
|
||||
* list this lands at roughly 3-4 thousand routes (about 230 KB):
|
||||
* IPv4 rounded to /16, IPv6 to /22.
|
||||
*/
|
||||
private const val ROUTE_BYTES_BUDGET = 350_000L
|
||||
|
||||
/** Coarser rounding than this bypasses too much address space. */
|
||||
private const val MIN_FIT_PREFIX4 = 16
|
||||
private const val MIN_FIT_PREFIX6 = 22
|
||||
|
||||
/** Estimated marshalled size of the complement routes of one family. */
|
||||
private fun complementBytes4(ranges: IPRangeSet?, exclusions: List<IPRange>): Long =
|
||||
complementSubnets(ranges, exclusions) * V4_ROUTE_BYTES
|
||||
|
||||
private fun complementBytes6(ranges: IPRangeSet?, exclusions: List<IPRange>): Long =
|
||||
complementSubnets(ranges, exclusions) * V6_ROUTE_BYTES
|
||||
|
||||
private fun complementBytes(
|
||||
ranges4: IPRangeSet?,
|
||||
ranges6: IPRangeSet?,
|
||||
exclusions4: List<IPRange>,
|
||||
exclusions6: List<IPRange>,
|
||||
): Long = complementBytes4(ranges4, exclusions4) + complementBytes6(ranges6, exclusions6)
|
||||
|
||||
/** Number of proper subnets the complement of the exclusions consists of. */
|
||||
private fun complementSubnets(ranges: IPRangeSet?, exclusions: List<IPRange>): Int
|
||||
{
|
||||
if (ranges == null)
|
||||
{
|
||||
return 0
|
||||
}
|
||||
var count = 0
|
||||
for (range in ranges)
|
||||
{
|
||||
for (piece in subtract(range, exclusions))
|
||||
{
|
||||
count += piece.toSubnets().size
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
/** The part of `range` not covered by the sorted, non-overlapping `exclusions`. */
|
||||
fun subtract(range: IPRange, exclusions: List<IPRange>): List<IPRange>
|
||||
{
|
||||
val result = ArrayList<IPRange>()
|
||||
var cursor = range.from
|
||||
val end = range.to
|
||||
for (exclusion in exclusions)
|
||||
{
|
||||
if (compare(cursor, exclusion.to) > 0)
|
||||
{ /* entirely before the cursor */
|
||||
continue
|
||||
}
|
||||
if (compare(exclusion.from, end) > 0)
|
||||
{ /* sorted, so nothing further can overlap */
|
||||
break
|
||||
}
|
||||
val from = if (compare(exclusion.from, cursor) > 0) exclusion.from else cursor
|
||||
if (compare(from, cursor) > 0)
|
||||
{ /* uncovered gap in front of the exclusion */
|
||||
result.add(IPRange(cursor, dec(from)))
|
||||
}
|
||||
if (compare(exclusion.to, end) >= 0)
|
||||
{ /* the rest of the range is covered */
|
||||
return result
|
||||
}
|
||||
cursor = inc(exclusion.to)
|
||||
}
|
||||
if (compare(cursor, end) <= 0)
|
||||
{
|
||||
result.add(IPRange(cursor, end))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/** Unsigned lexicographic byte comparison of two addresses. */
|
||||
private fun compare(a: InetAddress, b: InetAddress): Int
|
||||
{
|
||||
val x = a.address
|
||||
val y = b.address
|
||||
if (x.size != y.size)
|
||||
{
|
||||
return if (x.size < y.size) -1 else 1
|
||||
}
|
||||
for (i in x.indices)
|
||||
{
|
||||
val diff = (x[i].toInt() and 0xff) - (y[i].toInt() and 0xff)
|
||||
if (diff != 0)
|
||||
{
|
||||
return diff
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
private fun dec(address: InetAddress): InetAddress
|
||||
{
|
||||
val bytes = address.address
|
||||
for (i in bytes.indices.reversed())
|
||||
{
|
||||
bytes[i] = (bytes[i].toInt() - 1).toByte()
|
||||
if (bytes[i] != 0xff.toByte())
|
||||
{
|
||||
break
|
||||
}
|
||||
}
|
||||
return InetAddress.getByAddress(bytes)
|
||||
}
|
||||
|
||||
private fun inc(address: InetAddress): InetAddress
|
||||
{
|
||||
val bytes = address.address
|
||||
for (i in bytes.indices.reversed())
|
||||
{
|
||||
bytes[i] = (bytes[i].toInt() + 1).toByte()
|
||||
if (bytes[i].toInt() != 0)
|
||||
{
|
||||
break
|
||||
}
|
||||
}
|
||||
return InetAddress.getByAddress(bytes)
|
||||
}
|
||||
|
||||
@Volatile
|
||||
private var instance: RuIpListManager? = null
|
||||
|
||||
@JvmStatic
|
||||
fun getInstance(context: Context): RuIpListManager =
|
||||
instance ?: synchronized(this) {
|
||||
instance ?: RuIpListManager(context.applicationContext).also { instance = it }
|
||||
}
|
||||
}
|
||||
}
|
||||
+678
@@ -0,0 +1,678 @@
|
||||
/*
|
||||
* Copyright (C) 2026 SHX VPN
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or modify it
|
||||
* under the terms of the GNU General Public License as published by the
|
||||
* Free Software Foundation; either version 2 of the License, or (at your
|
||||
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful, but
|
||||
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
||||
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
|
||||
* for more details.
|
||||
*/
|
||||
|
||||
package org.strongswan.android.logic.scenarios
|
||||
|
||||
import android.app.AlarmManager
|
||||
import android.app.PendingIntent
|
||||
import android.app.usage.UsageEvents
|
||||
import android.app.usage.UsageStatsManager
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.NetworkCapabilities
|
||||
import android.net.VpnService
|
||||
import android.os.Bundle
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.os.PowerManager
|
||||
import android.telephony.TelephonyManager
|
||||
import android.util.Log
|
||||
import androidx.preference.PreferenceManager
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import org.strongswan.android.R
|
||||
import org.strongswan.android.data.VpnProfileDataSource
|
||||
import org.strongswan.android.logic.DiagLog
|
||||
import org.strongswan.android.logic.PauseManager
|
||||
import org.strongswan.android.logic.VpnStateService
|
||||
import org.strongswan.android.logic.autoconnect.SsidReader
|
||||
import org.strongswan.android.logic.connectivity.WhiteListManager
|
||||
import org.strongswan.android.logic.connectivity.WhiteListStatus
|
||||
import org.strongswan.android.logic.events.EventCategory
|
||||
import org.strongswan.android.logic.events.EventKind
|
||||
import org.strongswan.android.logic.events.EventLog
|
||||
import org.strongswan.android.logic.routing.AppTraffic
|
||||
import org.strongswan.android.utils.Constants
|
||||
import java.io.File
|
||||
import java.util.Calendar
|
||||
import java.util.UUID
|
||||
|
||||
/** «When» of a scenario (F_ScenarioAdd). */
|
||||
enum class ScenarioTrigger { WIFI, MOBILE, APP, TIME, ROAMING, WHITELIST }
|
||||
|
||||
/** «What to do». */
|
||||
enum class ScenarioAction { VPN_ON, VPN_OFF, PAUSE, SWITCH_SERVER }
|
||||
|
||||
/**
|
||||
* A user scenario. While its trigger holds, its action is in force; when
|
||||
* the trigger ends the VPN goes back to how it was («restore»).
|
||||
*/
|
||||
data class Scenario(
|
||||
val id: String = UUID.randomUUID().toString(),
|
||||
val enabled: Boolean = true,
|
||||
val trigger: ScenarioTrigger,
|
||||
/** WIFI: the networks; empty = any Wi-Fi */
|
||||
val ssids: Set<String> = emptySet(),
|
||||
/** APP: the packages */
|
||||
val packages: Set<String> = emptySet(),
|
||||
/** TIME: minutes of the day, the window may wrap past midnight */
|
||||
val startMinute: Int = 60,
|
||||
val endMinute: Int = 7 * 60,
|
||||
val action: ScenarioAction,
|
||||
/** VPN_ON / SWITCH_SERVER: the profile, null = the default one */
|
||||
val profileUuid: String? = null,
|
||||
)
|
||||
|
||||
/** Whether [minute] of the day is inside the (possibly wrapping) window. */
|
||||
internal fun inWindow(minute: Int, start: Int, end: Int): Boolean =
|
||||
if (start == end) false else if (start < end) minute in start until end else minute >= start || minute < end
|
||||
|
||||
/** What the engine sees right now; the decision is a pure function of it. */
|
||||
internal data class ScenarioInputs(
|
||||
val ssid: String?,
|
||||
val wifi: Boolean,
|
||||
val mobile: Boolean,
|
||||
val roaming: Boolean,
|
||||
val foreground: String?,
|
||||
val minute: Int,
|
||||
val whitelist: Boolean,
|
||||
)
|
||||
|
||||
/** The first enabled scenario whose trigger holds: the top one wins. */
|
||||
internal fun activeScenario(list: List<Scenario>, now: ScenarioInputs): Scenario? = list.firstOrNull { s ->
|
||||
s.enabled && when (s.trigger)
|
||||
{
|
||||
ScenarioTrigger.WIFI -> now.wifi && (s.ssids.isEmpty() || s.ssids.any { it.equals(now.ssid, ignoreCase = true) })
|
||||
ScenarioTrigger.MOBILE -> now.mobile && !now.wifi
|
||||
ScenarioTrigger.APP -> now.foreground != null && now.foreground in s.packages
|
||||
ScenarioTrigger.TIME -> inWindow(now.minute, s.startMinute, s.endMinute)
|
||||
ScenarioTrigger.ROAMING -> now.roaming
|
||||
ScenarioTrigger.WHITELIST -> now.whitelist
|
||||
}
|
||||
}
|
||||
|
||||
/** The two network rules of F_Automation, as entries of the common order. */
|
||||
const val BUILTIN_TRUSTED = "builtin:trusted"
|
||||
const val BUILTIN_UNTRUSTED = "builtin:untrusted"
|
||||
|
||||
/** What the built-in network rules say about the current network. */
|
||||
internal data class BuiltinRules(
|
||||
val enabled: Boolean,
|
||||
val parkOnTrusted: Boolean,
|
||||
val connectOnUntrusted: Boolean,
|
||||
val trustedSsids: Set<String>,
|
||||
)
|
||||
|
||||
/** The order of everything: [order] first (known ids), the rest after it (built-ins before new scenarios). */
|
||||
internal fun orderedIds(order: List<String>, scenarios: List<Scenario>): List<String>
|
||||
{
|
||||
val all = listOf(BUILTIN_TRUSTED, BUILTIN_UNTRUSTED) + scenarios.map { it.id }
|
||||
return order.filter { it in all } + all.filter { it !in order }
|
||||
}
|
||||
|
||||
/**
|
||||
* The top entry of the common order whose condition holds (built-in rule
|
||||
* id or scenario id), null when nothing applies. «Upper wins».
|
||||
*/
|
||||
internal fun topEntry(order: List<String>, scenarios: List<Scenario>, rules: BuiltinRules, now: ScenarioInputs): String?
|
||||
{
|
||||
val byId = scenarios.associateBy { it.id }
|
||||
val trustedHere = now.wifi && now.ssid != null && rules.trustedSsids.any { it.equals(now.ssid, ignoreCase = true) }
|
||||
for (id in orderedIds(order, scenarios))
|
||||
{
|
||||
val holds = when (id)
|
||||
{
|
||||
BUILTIN_TRUSTED -> rules.enabled && rules.parkOnTrusted && trustedHere
|
||||
/* like the auto-connect: a Wi-Fi with an unreadable name counts as foreign */
|
||||
BUILTIN_UNTRUSTED -> rules.enabled && rules.connectOnUntrusted && ((now.wifi && !trustedHere) || (!now.wifi && now.mobile))
|
||||
else -> byId[id]?.let { activeScenario(listOf(it), now) != null } ?: false
|
||||
}
|
||||
if (holds)
|
||||
{
|
||||
return id
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs the user scenarios of F_Automation. Re-evaluated on network
|
||||
* changes, at the edges of time windows (alarm), while an app trigger
|
||||
* exists (foreground app polled every few seconds, needs usage access)
|
||||
* and when the whitelists come or go.
|
||||
*/
|
||||
object ScenarioEngine
|
||||
{
|
||||
private const val TAG = "Scenarios"
|
||||
private const val FILE_NAME = "scenarios.json"
|
||||
private const val POLL_MS = 4000L
|
||||
/* the foreground app did not change for a minute: poll half as often */
|
||||
private const val SLOW_AFTER_POLLS = 15
|
||||
private const val SLOW_POLL_MS = 8000L
|
||||
private const val PREF_APPLIED = "pref_scenario_applied"
|
||||
private const val PREF_WAS_ON = "pref_scenario_was_on"
|
||||
private const val PREF_WAS_PROFILE = "pref_scenario_was_profile"
|
||||
/* the scenario the user overrode by hand: it stays out until it ends */
|
||||
private const val PREF_SUSPENDED = "pref_scenario_suspended"
|
||||
private const val PREF_ORDER = "pref_scenario_order"
|
||||
|
||||
private val _list = MutableStateFlow<List<Scenario>>(emptyList())
|
||||
val list: StateFlow<List<Scenario>> = _list
|
||||
@Volatile private var loaded = false
|
||||
private val handler = Handler(Looper.getMainLooper())
|
||||
private var appContext: Context? = null
|
||||
private var polling = false
|
||||
private var unchangedPolls = 0
|
||||
private var lastForeground: String? = null
|
||||
|
||||
fun flow(context: Context): StateFlow<List<Scenario>>
|
||||
{
|
||||
load(context)
|
||||
return list
|
||||
}
|
||||
|
||||
fun save(context: Context, list: List<Scenario>)
|
||||
{
|
||||
load(context)
|
||||
_list.value = list
|
||||
persist(context)
|
||||
start(context)
|
||||
evaluate(context)
|
||||
}
|
||||
|
||||
/** Starts listening (from the application); idempotent. */
|
||||
@JvmStatic
|
||||
fun start(context: Context)
|
||||
{
|
||||
val app = context.applicationContext
|
||||
appContext = app
|
||||
load(app)
|
||||
scheduleAlarm(app)
|
||||
val needPoll = _list.value.any { it.enabled && it.trigger == ScenarioTrigger.APP }
|
||||
if (needPoll && !polling)
|
||||
{
|
||||
polling = true
|
||||
registerScreenReceiver(app)
|
||||
if (isScreenOn(app))
|
||||
{
|
||||
handler.post(poll)
|
||||
}
|
||||
}
|
||||
else if (!needPoll)
|
||||
{
|
||||
polling = false
|
||||
handler.removeCallbacks(poll)
|
||||
unregisterScreenReceiver(app)
|
||||
}
|
||||
}
|
||||
|
||||
private fun isScreenOn(context: Context): Boolean =
|
||||
(context.getSystemService(Context.POWER_SERVICE) as? PowerManager)?.isInteractive ?: true
|
||||
|
||||
/* the foreground app only changes while the screen is on: no polling (and no
|
||||
* wake-ups for it) while it is off, one immediate poll when it comes back */
|
||||
private val screenReceiver = object : BroadcastReceiver()
|
||||
{
|
||||
override fun onReceive(context: Context, intent: Intent)
|
||||
{
|
||||
handler.removeCallbacks(poll)
|
||||
if (polling && intent.action == Intent.ACTION_SCREEN_ON)
|
||||
{
|
||||
unchangedPolls = 0
|
||||
handler.post(poll)
|
||||
}
|
||||
}
|
||||
}
|
||||
private var screenReceiverRegistered = false
|
||||
|
||||
private fun registerScreenReceiver(app: Context)
|
||||
{
|
||||
if (screenReceiverRegistered)
|
||||
{
|
||||
return
|
||||
}
|
||||
val filter = IntentFilter().apply {
|
||||
addAction(Intent.ACTION_SCREEN_ON)
|
||||
addAction(Intent.ACTION_SCREEN_OFF)
|
||||
}
|
||||
runCatching { app.registerReceiver(screenReceiver, filter, null, handler) }
|
||||
.onSuccess { screenReceiverRegistered = true }
|
||||
}
|
||||
|
||||
private fun unregisterScreenReceiver(app: Context)
|
||||
{
|
||||
if (!screenReceiverRegistered)
|
||||
{
|
||||
return
|
||||
}
|
||||
runCatching { app.unregisterReceiver(screenReceiver) }
|
||||
screenReceiverRegistered = false
|
||||
}
|
||||
|
||||
private val poll = object : Runnable
|
||||
{
|
||||
override fun run()
|
||||
{
|
||||
val app = appContext ?: return
|
||||
if (!polling || !isScreenOn(app))
|
||||
{ /* resumed by the screen-on broadcast */
|
||||
return
|
||||
}
|
||||
val fg = foregroundApp(app)
|
||||
if (fg != null && fg != lastForeground)
|
||||
{
|
||||
lastForeground = fg
|
||||
unchangedPolls = 0
|
||||
evaluate(app)
|
||||
}
|
||||
else
|
||||
{
|
||||
unchangedPolls++
|
||||
}
|
||||
handler.postDelayed(this, if (unchangedPolls >= SLOW_AFTER_POLLS) SLOW_POLL_MS else POLL_MS)
|
||||
}
|
||||
}
|
||||
|
||||
/** Network, whitelist or alarm: decide again. */
|
||||
@JvmStatic
|
||||
fun evaluate(context: Context)
|
||||
{
|
||||
val app = context.applicationContext
|
||||
load(app)
|
||||
if (_list.value.none { it.enabled })
|
||||
{
|
||||
restoreIfApplied(app)
|
||||
return
|
||||
}
|
||||
val now = inputs(app)
|
||||
val top = topEntry(order(app), _list.value, builtinRules(app), now)
|
||||
val prefs = PreferenceManager.getDefaultSharedPreferences(app)
|
||||
/* a scenario overridden by hand stays out until it ends */
|
||||
val suspended = prefs.getString(PREF_SUSPENDED, null)
|
||||
if (suspended != null)
|
||||
{
|
||||
if (top == suspended)
|
||||
{
|
||||
return
|
||||
}
|
||||
prefs.edit().remove(PREF_SUSPENDED).apply()
|
||||
}
|
||||
val active = _list.value.firstOrNull { it.id == top }
|
||||
val applied = prefs.getString(PREF_APPLIED, null)
|
||||
if (active?.id == applied && active != null)
|
||||
{
|
||||
return
|
||||
}
|
||||
if (active == null)
|
||||
{ /* nothing, or a built-in network rule above: the auto-connect acts */
|
||||
restoreIfApplied(app)
|
||||
return
|
||||
}
|
||||
val service = VpnStateService.getLiveInstance() ?: return
|
||||
if (applied == null)
|
||||
{ /* remember how it was, to put it back when the scenario ends */
|
||||
val on = service.state != VpnStateService.State.DISABLED
|
||||
prefs.edit().putBoolean(PREF_WAS_ON, on)
|
||||
.putString(PREF_WAS_PROFILE, service.profile?.getUUID()?.toString()).apply()
|
||||
}
|
||||
prefs.edit().putString(PREF_APPLIED, active.id).apply()
|
||||
DiagLog.i(TAG, "scenario ${active.trigger} → ${active.action}")
|
||||
apply(app, service, active)
|
||||
}
|
||||
|
||||
private fun apply(context: Context, service: VpnStateService, scenario: Scenario)
|
||||
{
|
||||
val profile = scenario.profileUuid ?: defaultProfile(context) ?: service.profile?.getUUID()?.toString()
|
||||
when (scenario.action)
|
||||
{
|
||||
ScenarioAction.VPN_ON, ScenarioAction.SWITCH_SERVER ->
|
||||
{
|
||||
val running = service.state != VpnStateService.State.DISABLED
|
||||
if (profile != null && (!running || service.profile?.getUUID()?.toString() != profile) && !connect(context, service, profile))
|
||||
{ /* nothing happened: no «scenario did it» event */
|
||||
return
|
||||
}
|
||||
}
|
||||
ScenarioAction.VPN_OFF -> if (service.state != VpnStateService.State.DISABLED) service.disconnect()
|
||||
ScenarioAction.PAUSE ->
|
||||
{
|
||||
val current = service.profile?.getUUID()?.toString() ?: profile ?: return
|
||||
val until = if (scenario.trigger == ScenarioTrigger.TIME) windowEnd(scenario) else System.currentTimeMillis() + 12L * 60 * 60 * 1000
|
||||
if (service.state != VpnStateService.State.DISABLED)
|
||||
{
|
||||
PauseManager.pauseUntil(context, service, current, until)
|
||||
}
|
||||
}
|
||||
}
|
||||
EventLog.getInstance(context).record(
|
||||
EventKind.INFO, EventCategory.CONNECTION,
|
||||
context.getString(R.string.event_scenario_title, context.getString(actionLabel(scenario.action))),
|
||||
context.getString(R.string.event_scenario_text, describeWhen(context, scenario)), key = "scenario:${scenario.id}",
|
||||
)
|
||||
}
|
||||
|
||||
/** The common order of the built-in rules and the scenarios (F_Automation, top wins). */
|
||||
fun order(context: Context): List<String>
|
||||
{
|
||||
val raw = PreferenceManager.getDefaultSharedPreferences(context).getString(PREF_ORDER, null)
|
||||
return orderedIds(raw?.split(',')?.filter { it.isNotBlank() } ?: emptyList(), flow(context).value)
|
||||
}
|
||||
|
||||
fun saveOrder(context: Context, order: List<String>)
|
||||
{
|
||||
PreferenceManager.getDefaultSharedPreferences(context).edit().putString(PREF_ORDER, order.joinToString(",")).apply()
|
||||
_orderChanges.value = _orderChanges.value + 1
|
||||
evaluate(context)
|
||||
}
|
||||
|
||||
private val _orderChanges = MutableStateFlow(0)
|
||||
/** Bumps when the order changed (the list screen redraws). */
|
||||
val orderChanges: StateFlow<Int> = _orderChanges
|
||||
|
||||
/** Moves [id] one place up (-1) or down (+1) in the common order. */
|
||||
fun move(context: Context, id: String, delta: Int)
|
||||
{
|
||||
val list = order(context).toMutableList()
|
||||
val i = list.indexOf(id)
|
||||
val j = i + delta
|
||||
if (i < 0 || j < 0 || j >= list.size)
|
||||
{
|
||||
return
|
||||
}
|
||||
list.add(j, list.removeAt(i))
|
||||
saveOrder(context, list)
|
||||
}
|
||||
|
||||
/**
|
||||
* The user turned the VPN on or off or paused it by hand: the running
|
||||
* scenario steps back until it ends and puts nothing back afterwards.
|
||||
*/
|
||||
@JvmStatic
|
||||
fun onManual(context: Context)
|
||||
{
|
||||
val prefs = PreferenceManager.getDefaultSharedPreferences(context.applicationContext)
|
||||
val applied = prefs.getString(PREF_APPLIED, null) ?: return
|
||||
DiagLog.i(TAG, "manual action, scenario steps back until it ends")
|
||||
prefs.edit().putString(PREF_SUSPENDED, applied)
|
||||
.remove(PREF_APPLIED).remove(PREF_WAS_ON).remove(PREF_WAS_PROFILE).apply()
|
||||
}
|
||||
|
||||
/** A scenario is in force: the built-in network rules (auto-connect) stand aside. */
|
||||
@JvmStatic
|
||||
fun inControl(context: Context): Boolean =
|
||||
PreferenceManager.getDefaultSharedPreferences(context.applicationContext).getString(PREF_APPLIED, null) != null
|
||||
|
||||
private fun builtinRules(context: Context): BuiltinRules
|
||||
{
|
||||
val c = org.strongswan.android.logic.autoconnect.AutoConnectManager.getInstance(context).currentConfig
|
||||
return BuiltinRules(c.enabled, c.parkOnTrusted, c.connectOnUntrusted, c.trustedSsids)
|
||||
}
|
||||
|
||||
private fun restoreIfApplied(context: Context)
|
||||
{
|
||||
val prefs = PreferenceManager.getDefaultSharedPreferences(context)
|
||||
if (prefs.getString(PREF_APPLIED, null) == null)
|
||||
{
|
||||
return
|
||||
}
|
||||
val wasOn = prefs.getBoolean(PREF_WAS_ON, false)
|
||||
val wasProfile = prefs.getString(PREF_WAS_PROFILE, null)
|
||||
prefs.edit().remove(PREF_APPLIED).remove(PREF_WAS_ON).remove(PREF_WAS_PROFILE).apply()
|
||||
val service = VpnStateService.getLiveInstance() ?: return
|
||||
DiagLog.i(TAG, "scenario over, restoring (on=$wasOn)")
|
||||
if (PauseManager.isPaused(context))
|
||||
{
|
||||
if (wasOn) PauseManager.resumeNow(context) else PauseManager.cancel(context)
|
||||
return
|
||||
}
|
||||
val running = service.state != VpnStateService.State.DISABLED
|
||||
when
|
||||
{
|
||||
wasOn && wasProfile != null && (!running || service.profile?.getUUID()?.toString() != wasProfile) -> connect(context, service, wasProfile)
|
||||
!wasOn && running -> service.disconnect()
|
||||
}
|
||||
}
|
||||
|
||||
/** Starts [uuid]; false when it could not (no VPN permission, no saved password). */
|
||||
private fun connect(context: Context, service: VpnStateService, uuid: String): Boolean
|
||||
{
|
||||
val needsConsent = runCatching { VpnService.prepare(context) != null }.getOrDefault(true)
|
||||
if (needsConsent)
|
||||
{
|
||||
DiagLog.i(TAG, "VPN permission missing, scenario cannot connect")
|
||||
return false
|
||||
}
|
||||
/* a password typed for the running session goes along with it */
|
||||
val livePassword = service.profile?.takeIf { it.getUUID().toString() == uuid }?.password
|
||||
if (livePassword == null && org.strongswan.android.logic.profiles.blockedByPassword(context, uuid))
|
||||
{
|
||||
return false
|
||||
}
|
||||
val info = Bundle()
|
||||
info.putString(VpnProfileDataSource.KEY_UUID, uuid)
|
||||
livePassword?.let { info.putString(VpnProfileDataSource.KEY_PASSWORD, it) }
|
||||
service.connect(info, true)
|
||||
return true
|
||||
}
|
||||
|
||||
private fun defaultProfile(context: Context): String? =
|
||||
PreferenceManager.getDefaultSharedPreferences(context).getString(Constants.PREF_MRU_VPN_PROFILE, null)
|
||||
|
||||
private fun inputs(context: Context): ScenarioInputs
|
||||
{
|
||||
val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
|
||||
var wifi = false
|
||||
var mobile = false
|
||||
var ssid: String? = null
|
||||
@Suppress("DEPRECATION")
|
||||
cm?.allNetworks?.forEach { network ->
|
||||
val caps = runCatching { cm.getNetworkCapabilities(network) }.getOrNull() ?: return@forEach
|
||||
if (caps.hasTransport(NetworkCapabilities.TRANSPORT_VPN)) return@forEach
|
||||
if (caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI))
|
||||
{
|
||||
wifi = true
|
||||
ssid = SsidReader.from(context, caps)
|
||||
}
|
||||
else if (caps.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR))
|
||||
{
|
||||
mobile = true
|
||||
}
|
||||
}
|
||||
val roaming = mobile && runCatching { (context.getSystemService(Context.TELEPHONY_SERVICE) as? TelephonyManager)?.isNetworkRoaming == true }.getOrDefault(false)
|
||||
val c = Calendar.getInstance()
|
||||
val whitelist = runCatching { WhiteListManager.getInstance(context).state.value.status == WhiteListStatus.ACTIVE }.getOrDefault(false)
|
||||
val fg = if (_list.value.any { it.enabled && it.trigger == ScenarioTrigger.APP }) lastForeground ?: foregroundApp(context) else null
|
||||
return ScenarioInputs(ssid, wifi, mobile, roaming, fg, c.get(Calendar.HOUR_OF_DAY) * 60 + c.get(Calendar.MINUTE), whitelist)
|
||||
}
|
||||
|
||||
/** The app last moved to the foreground (usage events of the last minute); null without access. */
|
||||
private fun foregroundApp(context: Context): String?
|
||||
{
|
||||
if (!AppTraffic.hasAccess(context))
|
||||
{
|
||||
return null
|
||||
}
|
||||
val usm = context.getSystemService(Context.USAGE_STATS_SERVICE) as? UsageStatsManager ?: return null
|
||||
val now = System.currentTimeMillis()
|
||||
val events = runCatching { usm.queryEvents(now - 60_000, now) }.getOrNull() ?: return lastForeground
|
||||
var last: String? = null
|
||||
val event = UsageEvents.Event()
|
||||
while (events.hasNextEvent())
|
||||
{
|
||||
events.getNextEvent(event)
|
||||
if (event.eventType == UsageEvents.Event.ACTIVITY_RESUMED)
|
||||
{
|
||||
last = event.packageName
|
||||
}
|
||||
}
|
||||
return last ?: lastForeground
|
||||
}
|
||||
|
||||
private fun windowEnd(s: Scenario): Long
|
||||
{
|
||||
val c = Calendar.getInstance()
|
||||
val now = c.get(Calendar.HOUR_OF_DAY) * 60 + c.get(Calendar.MINUTE)
|
||||
c.set(Calendar.HOUR_OF_DAY, s.endMinute / 60)
|
||||
c.set(Calendar.MINUTE, s.endMinute % 60)
|
||||
c.set(Calendar.SECOND, 0)
|
||||
c.set(Calendar.MILLISECOND, 0)
|
||||
if (s.endMinute <= now)
|
||||
{
|
||||
c.add(Calendar.DAY_OF_YEAR, 1)
|
||||
}
|
||||
return c.timeInMillis
|
||||
}
|
||||
|
||||
/** An alarm at the next edge of any time window. */
|
||||
private fun scheduleAlarm(context: Context)
|
||||
{
|
||||
val am = context.getSystemService(Context.ALARM_SERVICE) as? AlarmManager ?: return
|
||||
val pi = PendingIntent.getBroadcast(context, 46, Intent(context, ScenarioReceiver::class.java),
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
|
||||
val edges = _list.value.filter { it.enabled && it.trigger == ScenarioTrigger.TIME }.flatMap { listOf(it.startMinute, it.endMinute) }
|
||||
if (edges.isEmpty())
|
||||
{
|
||||
am.cancel(pi)
|
||||
return
|
||||
}
|
||||
val c = Calendar.getInstance()
|
||||
val now = c.get(Calendar.HOUR_OF_DAY) * 60 + c.get(Calendar.MINUTE)
|
||||
val next = edges.minByOrNull { (it - now + 1440 - 1) % 1440 + 1 } ?: return
|
||||
c.set(Calendar.HOUR_OF_DAY, next / 60)
|
||||
c.set(Calendar.MINUTE, next % 60)
|
||||
c.set(Calendar.SECOND, 5)
|
||||
c.set(Calendar.MILLISECOND, 0)
|
||||
if (c.timeInMillis <= System.currentTimeMillis())
|
||||
{
|
||||
c.add(Calendar.DAY_OF_YEAR, 1)
|
||||
}
|
||||
try
|
||||
{
|
||||
am.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, c.timeInMillis, pi)
|
||||
}
|
||||
catch (_: SecurityException)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
fun actionLabel(action: ScenarioAction): Int = when (action)
|
||||
{
|
||||
ScenarioAction.VPN_ON -> R.string.scenario_do_on
|
||||
ScenarioAction.VPN_OFF -> R.string.scenario_do_off
|
||||
ScenarioAction.PAUSE -> R.string.scenario_do_pause
|
||||
ScenarioAction.SWITCH_SERVER -> R.string.scenario_do_switch
|
||||
}
|
||||
|
||||
/** «Telegram or YouTube is open», «Every night 01:00–07:00» … */
|
||||
fun describeWhen(context: Context, s: Scenario): String
|
||||
{
|
||||
fun hm(m: Int) = String.format("%02d:%02d", m / 60, m % 60)
|
||||
return when (s.trigger)
|
||||
{
|
||||
ScenarioTrigger.WIFI -> if (s.ssids.isEmpty()) context.getString(R.string.scenario_when_any_wifi)
|
||||
else context.getString(R.string.scenario_when_wifi, s.ssids.joinToString(", "))
|
||||
ScenarioTrigger.MOBILE -> context.getString(R.string.scenario_when_mobile)
|
||||
ScenarioTrigger.APP ->
|
||||
{
|
||||
val pm = context.packageManager
|
||||
val names = s.packages.map { pkg -> runCatching { pm.getApplicationLabel(pm.getApplicationInfo(pkg, 0)).toString() }.getOrDefault(pkg) }
|
||||
context.getString(R.string.scenario_when_app, names.joinToString(context.getString(R.string.scenario_or)))
|
||||
}
|
||||
ScenarioTrigger.TIME -> context.getString(R.string.scenario_when_time, hm(s.startMinute), hm(s.endMinute))
|
||||
ScenarioTrigger.ROAMING -> context.getString(R.string.scenario_when_roaming)
|
||||
ScenarioTrigger.WHITELIST -> context.getString(R.string.scenario_when_whitelist)
|
||||
}
|
||||
}
|
||||
|
||||
private fun load(context: Context)
|
||||
{
|
||||
if (loaded)
|
||||
{
|
||||
return
|
||||
}
|
||||
synchronized(this)
|
||||
{
|
||||
if (loaded)
|
||||
{
|
||||
return
|
||||
}
|
||||
try
|
||||
{
|
||||
val file = File(context.filesDir, FILE_NAME)
|
||||
if (file.exists())
|
||||
{
|
||||
val array = JSONArray(file.readText())
|
||||
_list.value = (0 until array.length()).mapNotNull { runCatching { fromJson(array.getJSONObject(it)) }.getOrNull() }
|
||||
}
|
||||
}
|
||||
catch (e: Exception)
|
||||
{
|
||||
Log.w(TAG, "Loading failed", e)
|
||||
}
|
||||
loaded = true
|
||||
}
|
||||
}
|
||||
|
||||
private fun persist(context: Context)
|
||||
{
|
||||
try
|
||||
{
|
||||
val array = JSONArray()
|
||||
_list.value.forEach { s ->
|
||||
array.put(
|
||||
JSONObject().put("id", s.id).put("on", s.enabled).put("when", s.trigger.name)
|
||||
.put("ssids", JSONArray(s.ssids.toList())).put("apps", JSONArray(s.packages.toList()))
|
||||
.put("start", s.startMinute).put("end", s.endMinute).put("do", s.action.name)
|
||||
.put("profile", s.profileUuid ?: JSONObject.NULL)
|
||||
)
|
||||
}
|
||||
File(context.filesDir, FILE_NAME).writeText(array.toString())
|
||||
}
|
||||
catch (e: Exception)
|
||||
{
|
||||
Log.w(TAG, "Saving failed", e)
|
||||
}
|
||||
}
|
||||
|
||||
private fun fromJson(o: JSONObject): Scenario
|
||||
{
|
||||
fun set(key: String): Set<String> = o.optJSONArray(key)?.let { a -> (0 until a.length()).map { a.getString(it) }.toSet() } ?: emptySet()
|
||||
return Scenario(
|
||||
id = o.getString("id"),
|
||||
enabled = o.optBoolean("on", true),
|
||||
trigger = ScenarioTrigger.valueOf(o.getString("when")),
|
||||
ssids = set("ssids"),
|
||||
packages = set("apps"),
|
||||
startMinute = o.optInt("start", 60),
|
||||
endMinute = o.optInt("end", 420),
|
||||
action = ScenarioAction.valueOf(o.getString("do")),
|
||||
profileUuid = o.optString("profile").takeIf { !o.isNull("profile") && it.isNotEmpty() },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Edge of a time window: decide again and arm the next edge. */
|
||||
class ScenarioReceiver : BroadcastReceiver()
|
||||
{
|
||||
override fun onReceive(context: Context, intent: Intent?)
|
||||
{
|
||||
ScenarioEngine.start(context)
|
||||
ScenarioEngine.evaluate(context)
|
||||
}
|
||||
}
|
||||
+212
@@ -0,0 +1,212 @@
|
||||
/*
|
||||
* Copyright (C) 2026 SHX VPN
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or modify it
|
||||
* under the terms of the GNU General Public License as published by the
|
||||
* Free Software Foundation; either version 2 of the License, or (at your
|
||||
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful, but
|
||||
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
||||
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
|
||||
* for more details.
|
||||
*/
|
||||
|
||||
package org.strongswan.android.logic.shortcuts
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.content.SharedPreferences
|
||||
import android.util.Log
|
||||
import androidx.core.content.pm.ShortcutInfoCompat
|
||||
import androidx.core.content.pm.ShortcutManagerCompat
|
||||
import androidx.core.graphics.drawable.IconCompat
|
||||
import androidx.localbroadcastmanager.content.LocalBroadcastManager
|
||||
import androidx.preference.PreferenceManager
|
||||
import org.strongswan.android.R
|
||||
import org.strongswan.android.data.VpnProfile
|
||||
import org.strongswan.android.data.VpnProfileSource
|
||||
import org.strongswan.android.logic.StrongSwanApplication
|
||||
import org.strongswan.android.ui.VpnProfileControlActivity
|
||||
import org.strongswan.android.utils.Constants
|
||||
import org.strongswan.android.utils.DefaultVpnProfile
|
||||
|
||||
/**
|
||||
* Publishes dynamic launcher shortcuts that automation tools (e.g. Samsung
|
||||
* Modes and Routines) can execute as routine actions: connecting the default
|
||||
* or a specific profile, disconnecting and toggling the VPN.
|
||||
*
|
||||
* All shortcuts target VpnProfileControlActivity with EXTRA_SILENT set, so
|
||||
* routines run without confirmation dialogs (one-time consent dialogs aside).
|
||||
*/
|
||||
class VpnShortcutManager private constructor(private val context: Context)
|
||||
{
|
||||
companion object
|
||||
{
|
||||
/* hardcoded: the runtime class name is obfuscated by R8 in release builds */
|
||||
private const val TAG = "VpnShortcutManager"
|
||||
|
||||
/** slots always occupied by the connect/disconnect/toggle shortcuts */
|
||||
private const val RESERVED_SLOTS = 3
|
||||
|
||||
private var mInstance: VpnShortcutManager? = null
|
||||
|
||||
@JvmStatic
|
||||
fun getInstance(context: Context): VpnShortcutManager
|
||||
{
|
||||
return mInstance ?: VpnShortcutManager(context.applicationContext).also { mInstance = it }
|
||||
}
|
||||
}
|
||||
|
||||
private val mPrefs: SharedPreferences = PreferenceManager.getDefaultSharedPreferences(context)
|
||||
|
||||
private val mProfilesChangedReceiver = object : BroadcastReceiver()
|
||||
{
|
||||
override fun onReceive(context: Context, intent: Intent)
|
||||
{
|
||||
refresh()
|
||||
}
|
||||
}
|
||||
|
||||
fun isEnabled(): Boolean
|
||||
{
|
||||
return mPrefs.getBoolean(Constants.PREF_PUBLISH_SHORTCUTS, true)
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable or disable the automation shortcuts and update the published set
|
||||
*/
|
||||
fun setEnabled(enabled: Boolean)
|
||||
{
|
||||
mPrefs.edit().putBoolean(Constants.PREF_PUBLISH_SHORTCUTS, enabled).apply()
|
||||
refresh()
|
||||
}
|
||||
|
||||
/**
|
||||
* (Re)publish the automation shortcuts, or remove them if the feature is disabled
|
||||
*/
|
||||
fun refresh()
|
||||
{
|
||||
StrongSwanApplication.getInstance().getExecutor().execute { updateShortcuts() }
|
||||
}
|
||||
|
||||
/**
|
||||
* Start listening for profile changes; called from StrongSwanApplication
|
||||
*/
|
||||
fun start()
|
||||
{
|
||||
LocalBroadcastManager.getInstance(context).registerReceiver(
|
||||
mProfilesChangedReceiver, IntentFilter(Constants.VPN_PROFILES_CHANGED))
|
||||
refresh()
|
||||
}
|
||||
|
||||
private fun updateShortcuts()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!isEnabled())
|
||||
{
|
||||
ShortcutManagerCompat.removeAllDynamicShortcuts(context)
|
||||
return
|
||||
}
|
||||
publishShortcuts()
|
||||
}
|
||||
catch (e: Exception)
|
||||
{
|
||||
Log.e(TAG, "Failed to update automation shortcuts", e)
|
||||
}
|
||||
}
|
||||
|
||||
private fun publishShortcuts()
|
||||
{
|
||||
val max = ShortcutManagerCompat.getMaxShortcutCountPerActivity(context)
|
||||
if (max <= 0)
|
||||
{ /* dynamic shortcuts are not supported on this API level */
|
||||
return
|
||||
}
|
||||
|
||||
val shortcuts = ArrayList<ShortcutInfoCompat>(max)
|
||||
shortcuts.add(buildFunctionShortcut("disconnect_vpn", R.string.shortcut_disconnect_vpn,
|
||||
VpnProfileControlActivity.DISCONNECT, null))
|
||||
if (shortcuts.size < max)
|
||||
{
|
||||
shortcuts.add(buildFunctionShortcut("toggle_vpn", R.string.shortcut_toggle_vpn,
|
||||
VpnProfileControlActivity.TOGGLE_VPN, null))
|
||||
}
|
||||
if (shortcuts.size < max)
|
||||
{
|
||||
shortcuts.add(buildFunctionShortcut("connect_vpn", R.string.shortcut_connect_vpn,
|
||||
VpnProfileControlActivity.START_PROFILE, null))
|
||||
}
|
||||
|
||||
val profileSlots = (max - RESERVED_SLOTS).coerceAtLeast(0)
|
||||
if (profileSlots > 0)
|
||||
{
|
||||
val profiles = loadProfiles()
|
||||
val defaultUuid = DefaultVpnProfile.getDefaultProfileUuid(context)
|
||||
profiles.sortWith(compareBy(
|
||||
{ it.getUUID().toString() != defaultUuid },
|
||||
{ it.name?.lowercase() ?: it.getUUID().toString() }))
|
||||
for (profile in profiles.take(profileSlots))
|
||||
{
|
||||
if (shortcuts.size >= max)
|
||||
{
|
||||
break
|
||||
}
|
||||
shortcuts.add(buildProfileShortcut(profile))
|
||||
}
|
||||
}
|
||||
ShortcutManagerCompat.setDynamicShortcuts(context, shortcuts)
|
||||
}
|
||||
|
||||
private fun buildFunctionShortcut(id: String, labelRes: Int, action: String, profileUuid: String?): ShortcutInfoCompat
|
||||
{
|
||||
val label = context.getString(labelRes)
|
||||
return buildShortcut(id, label, label, action, profileUuid)
|
||||
}
|
||||
|
||||
private fun buildProfileShortcut(profile: VpnProfile): ShortcutInfoCompat
|
||||
{
|
||||
val name = profile.name?.takeIf { it.isNotBlank() }
|
||||
?: profile.gateway?.takeIf { it.isNotBlank() }
|
||||
?: profile.getUUID().toString()
|
||||
val longLabel = context.getString(R.string.shortcut_connect_profile_fmt, name)
|
||||
return buildShortcut("connect_" + profile.getUUID(), name, longLabel,
|
||||
VpnProfileControlActivity.START_PROFILE, profile.getUUID().toString())
|
||||
}
|
||||
|
||||
private fun buildShortcut(id: String, shortLabel: String, longLabel: String, action: String, profileUuid: String?): ShortcutInfoCompat
|
||||
{
|
||||
val intent = Intent(context, VpnProfileControlActivity::class.java)
|
||||
.setAction(action)
|
||||
.putExtra(VpnProfileControlActivity.EXTRA_SILENT, true)
|
||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
if (profileUuid != null)
|
||||
{
|
||||
intent.putExtra(VpnProfileControlActivity.EXTRA_VPN_PROFILE_UUID, profileUuid)
|
||||
}
|
||||
return ShortcutInfoCompat.Builder(context, id)
|
||||
.setShortLabel(shortLabel)
|
||||
.setLongLabel(longLabel)
|
||||
.setIntent(intent)
|
||||
.setLongLived(true)
|
||||
.setIcon(IconCompat.createWithResource(context, R.mipmap.ic_shortcut))
|
||||
.build()
|
||||
}
|
||||
|
||||
private fun loadProfiles(): MutableList<VpnProfile>
|
||||
{
|
||||
val source = VpnProfileSource(context)
|
||||
try
|
||||
{
|
||||
source.open()
|
||||
return source.allVpnProfiles
|
||||
}
|
||||
finally
|
||||
{
|
||||
source.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
+214
@@ -0,0 +1,214 @@
|
||||
/*
|
||||
* Copyright (C) 2026 SHX VPN
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or modify it
|
||||
* under the terms of the GNU General Public License as published by the
|
||||
* Free Software Foundation; either version 2 of the License, or (at your
|
||||
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful, but
|
||||
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
||||
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
|
||||
* for more details.
|
||||
*/
|
||||
|
||||
package org.strongswan.android.logic.stats
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import org.strongswan.android.logic.CharonVpnService
|
||||
import java.io.File
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Calendar
|
||||
import java.util.Locale
|
||||
import java.util.concurrent.Executors
|
||||
import java.util.concurrent.ScheduledFuture
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/** Bytes of one day: through the tunnel and straight past it (userspace router). */
|
||||
data class DayTraffic(val day: String, val vpn: Long, val direct: Long)
|
||||
{
|
||||
val total: Long get() = vpn + direct
|
||||
}
|
||||
|
||||
/**
|
||||
* Daily traffic history (F_Stats). While the tunnel is up the daemon
|
||||
* counters are sampled every [SAMPLE_S] seconds and the growth is added to
|
||||
* the current day; the counters restart with every connection, so a drop
|
||||
* starts a new baseline. Kept for [KEEP_DAYS] days in traffic.json.
|
||||
*/
|
||||
object TrafficHistory
|
||||
{
|
||||
private const val TAG = "TrafficHistory"
|
||||
private const val FILE_NAME = "traffic.json"
|
||||
private const val SAMPLE_S = 15L
|
||||
private const val KEEP_DAYS = 120
|
||||
|
||||
private val executor = Executors.newSingleThreadScheduledExecutor()
|
||||
private var task: ScheduledFuture<*>? = null
|
||||
private var lastVpn = -1L
|
||||
private var lastDirect = -1L
|
||||
@Volatile private var loaded = false
|
||||
private val days = LinkedHashMap<String, LongArray>()
|
||||
private var firstDay: String? = null
|
||||
|
||||
private val _changes = MutableStateFlow(0L)
|
||||
/** Bumps whenever a sample changed the history (for live screens). */
|
||||
val changes: StateFlow<Long> = _changes
|
||||
|
||||
internal fun dayKey(ms: Long): String = SimpleDateFormat("yyyy-MM-dd", Locale.US).format(ms)
|
||||
|
||||
/** Starts sampling (the tunnel came up); harmless when already running. */
|
||||
@JvmStatic
|
||||
@Synchronized
|
||||
fun start(context: Context)
|
||||
{
|
||||
val app = context.applicationContext
|
||||
if (task != null)
|
||||
{
|
||||
return
|
||||
}
|
||||
lastVpn = -1L
|
||||
lastDirect = -1L
|
||||
task = executor.scheduleWithFixedDelay({ sample(app) }, 0, SAMPLE_S, TimeUnit.SECONDS)
|
||||
}
|
||||
|
||||
/** Stops sampling after a last sample (the tunnel goes down). */
|
||||
@JvmStatic
|
||||
@Synchronized
|
||||
fun stop(context: Context)
|
||||
{
|
||||
val app = context.applicationContext
|
||||
val running = task ?: return
|
||||
running.cancel(false)
|
||||
task = null
|
||||
executor.execute { sample(app) }
|
||||
}
|
||||
|
||||
private fun sample(context: Context)
|
||||
{
|
||||
val traffic = runCatching { CharonVpnService.queryTraffic() }.getOrNull()
|
||||
val vpn = traffic?.let { it.getOrElse(0) { 0L } + it.getOrElse(1) { 0L } } ?: return
|
||||
/* direct bytes only exist with the userspace router (s[3]) */
|
||||
val direct = runCatching { CharonVpnService.getRoutingStats() }.getOrNull()?.takeIf { it.size >= 4 }?.get(3) ?: 0L
|
||||
val dVpn = if (lastVpn < 0 || vpn < lastVpn) 0L else vpn - lastVpn
|
||||
val dDirect = if (lastDirect < 0 || direct < lastDirect) 0L else direct - lastDirect
|
||||
/* a new session: count what it already did since its counters started */
|
||||
val addVpn = if (lastVpn >= 0 && vpn < lastVpn) vpn else dVpn
|
||||
val addDirect = if (lastDirect >= 0 && direct < lastDirect) direct else dDirect
|
||||
lastVpn = vpn
|
||||
lastDirect = direct
|
||||
if (addVpn > 0 || addDirect > 0)
|
||||
{
|
||||
add(context, System.currentTimeMillis(), addVpn, addDirect)
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
internal fun add(context: Context, at: Long, vpn: Long, direct: Long)
|
||||
{
|
||||
load(context)
|
||||
val key = dayKey(at)
|
||||
val row = days.getOrPut(key) { LongArray(2) }
|
||||
row[0] += vpn
|
||||
row[1] += direct
|
||||
if (firstDay == null)
|
||||
{
|
||||
firstDay = key
|
||||
}
|
||||
prune()
|
||||
save(context)
|
||||
_changes.value = System.currentTimeMillis()
|
||||
org.strongswan.android.ui.widget.ShxWidgetState.refresh(context)
|
||||
}
|
||||
|
||||
/** The days from [from] to [to] (inclusive, wall clock), zeros where nothing was recorded. */
|
||||
@Synchronized
|
||||
fun range(context: Context, from: Long, to: Long): List<DayTraffic>
|
||||
{
|
||||
load(context)
|
||||
val result = ArrayList<DayTraffic>()
|
||||
val c = Calendar.getInstance()
|
||||
c.timeInMillis = from
|
||||
while (c.timeInMillis <= to)
|
||||
{
|
||||
val key = dayKey(c.timeInMillis)
|
||||
val row = days[key]
|
||||
result += DayTraffic(key, row?.get(0) ?: 0L, row?.get(1) ?: 0L)
|
||||
c.add(Calendar.DAY_OF_YEAR, 1)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun today(context: Context): DayTraffic = range(context, System.currentTimeMillis(), System.currentTimeMillis()).first()
|
||||
|
||||
/** First day with any record, null before the first connection. */
|
||||
@Synchronized
|
||||
fun firstDay(context: Context): String?
|
||||
{
|
||||
load(context)
|
||||
return firstDay
|
||||
}
|
||||
|
||||
private fun prune()
|
||||
{
|
||||
val cutoff = dayKey(System.currentTimeMillis() - KEEP_DAYS * 24L * 60 * 60 * 1000)
|
||||
days.keys.filter { it < cutoff }.forEach { days.remove(it) }
|
||||
}
|
||||
|
||||
private fun load(context: Context)
|
||||
{
|
||||
if (loaded)
|
||||
{
|
||||
return
|
||||
}
|
||||
loaded = true
|
||||
try
|
||||
{
|
||||
val file = File(context.filesDir, FILE_NAME)
|
||||
if (!file.exists())
|
||||
{
|
||||
return
|
||||
}
|
||||
val json = JSONObject(file.readText())
|
||||
firstDay = json.optString("first").ifEmpty { null }
|
||||
val array = json.optJSONArray("days") ?: JSONArray()
|
||||
for (i in 0 until array.length())
|
||||
{
|
||||
val o = array.getJSONObject(i)
|
||||
days[o.getString("d")] = longArrayOf(o.optLong("v"), o.optLong("r"))
|
||||
}
|
||||
}
|
||||
catch (e: Exception)
|
||||
{
|
||||
Log.w(TAG, "Loading the traffic history failed", e)
|
||||
}
|
||||
}
|
||||
|
||||
private fun save(context: Context)
|
||||
{
|
||||
try
|
||||
{
|
||||
val array = JSONArray()
|
||||
days.toSortedMap().forEach { (d, row) -> array.put(JSONObject().put("d", d).put("v", row[0]).put("r", row[1])) }
|
||||
val json = JSONObject().put("first", firstDay ?: JSONObject.NULL).put("days", array)
|
||||
val file = File(context.filesDir, FILE_NAME)
|
||||
val tmp = File(file.path + ".tmp")
|
||||
tmp.writeText(json.toString())
|
||||
if (!tmp.renameTo(file))
|
||||
{
|
||||
file.writeText(json.toString())
|
||||
tmp.delete()
|
||||
}
|
||||
}
|
||||
catch (e: Exception)
|
||||
{
|
||||
Log.w(TAG, "Saving the traffic history failed", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
+123
@@ -0,0 +1,123 @@
|
||||
/*
|
||||
* Copyright (C) 2026 SHX VPN
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or modify it
|
||||
* under the terms of the GNU General Public License as published by the
|
||||
* Free Software Foundation; either version 2 of the License, or (at your
|
||||
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful, but
|
||||
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
||||
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
|
||||
* for more details.
|
||||
*/
|
||||
|
||||
package org.strongswan.android.logic.stats
|
||||
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.work.CoroutineWorker
|
||||
import androidx.work.ExistingPeriodicWorkPolicy
|
||||
import androidx.work.PeriodicWorkRequestBuilder
|
||||
import androidx.work.WorkManager
|
||||
import androidx.work.WorkerParameters
|
||||
import org.strongswan.android.R
|
||||
import org.strongswan.android.logic.events.NotifPrefs
|
||||
import org.strongswan.android.ui.StatsActivity
|
||||
import org.strongswan.android.ui.compose.formatBytes
|
||||
import java.util.Calendar
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/** «Weekly summary» (F_SysNotifs): last week's traffic on Monday morning, off by default. */
|
||||
object WeeklySummary
|
||||
{
|
||||
private const val WORK_NAME = "weekly-summary"
|
||||
private const val CHANNEL = "org.strongswan.android.weekly"
|
||||
private const val NOTIFICATION_ID = 0x5717
|
||||
|
||||
/** Arms (or cancels) the Monday 10:00 job after the switch. */
|
||||
@JvmStatic
|
||||
fun schedule(context: Context)
|
||||
{
|
||||
val wm = WorkManager.getInstance(context)
|
||||
if (!NotifPrefs.enabled(context, NotifPrefs.WEEKLY))
|
||||
{
|
||||
wm.cancelUniqueWork(WORK_NAME)
|
||||
return
|
||||
}
|
||||
val c = Calendar.getInstance()
|
||||
c.set(Calendar.DAY_OF_WEEK, Calendar.MONDAY)
|
||||
c.set(Calendar.HOUR_OF_DAY, 10)
|
||||
c.set(Calendar.MINUTE, 0)
|
||||
c.set(Calendar.SECOND, 0)
|
||||
if (c.timeInMillis <= System.currentTimeMillis())
|
||||
{
|
||||
c.add(Calendar.WEEK_OF_YEAR, 1)
|
||||
}
|
||||
val request = PeriodicWorkRequestBuilder<WeeklySummaryWorker>(7, TimeUnit.DAYS)
|
||||
.setInitialDelay(c.timeInMillis - System.currentTimeMillis(), TimeUnit.MILLISECONDS)
|
||||
.build()
|
||||
wm.enqueueUniquePeriodicWork(WORK_NAME, ExistingPeriodicWorkPolicy.UPDATE, request)
|
||||
}
|
||||
|
||||
internal fun post(context: Context)
|
||||
{
|
||||
if (!NotifPrefs.enabled(context, NotifPrefs.WEEKLY))
|
||||
{
|
||||
return
|
||||
}
|
||||
val day = 24L * 60 * 60 * 1000
|
||||
val now = System.currentTimeMillis()
|
||||
val week = TrafficHistory.range(context, now - 7 * day, now - day)
|
||||
val total = week.sumOf { it.total }
|
||||
if (total <= 0)
|
||||
{
|
||||
return
|
||||
}
|
||||
val vpnShare = (week.sumOf { it.vpn } * 100 / total).toInt()
|
||||
val manager = NotificationManagerCompat.from(context)
|
||||
if (!manager.areNotificationsEnabled())
|
||||
{
|
||||
return
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O)
|
||||
{
|
||||
context.getSystemService(NotificationManager::class.java)?.createNotificationChannel(
|
||||
NotificationChannel(CHANNEL, context.getString(R.string.notif_weekly), NotificationManager.IMPORTANCE_LOW)
|
||||
)
|
||||
}
|
||||
val open = PendingIntent.getActivity(
|
||||
context, 0, Intent(context, StatsActivity::class.java).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK),
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
val notification = NotificationCompat.Builder(context, CHANNEL)
|
||||
.setSmallIcon(R.drawable.ic_notification)
|
||||
.setContentTitle(context.getString(R.string.weekly_title, formatBytes(total)))
|
||||
.setContentText(context.getString(R.string.weekly_text, vpnShare, 100 - vpnShare))
|
||||
.setContentIntent(open)
|
||||
.setAutoCancel(true)
|
||||
.build()
|
||||
try
|
||||
{
|
||||
manager.notify(NOTIFICATION_ID, notification)
|
||||
}
|
||||
catch (_: SecurityException)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class WeeklySummaryWorker(context: Context, params: WorkerParameters) : CoroutineWorker(context, params)
|
||||
{
|
||||
override suspend fun doWork(): Result
|
||||
{
|
||||
WeeklySummary.post(applicationContext)
|
||||
return Result.success()
|
||||
}
|
||||
}
|
||||
+96
@@ -0,0 +1,96 @@
|
||||
/*
|
||||
* Copyright (C) 2026 SHX VPN
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or modify it
|
||||
* under the terms of the GNU General Public License as published by the
|
||||
* Free Software Foundation; either version 2 of the License, or (at your
|
||||
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful, but
|
||||
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
||||
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
|
||||
* for more details.
|
||||
*/
|
||||
|
||||
package org.strongswan.android.logic.updates
|
||||
|
||||
import java.net.URL
|
||||
import java.security.MessageDigest
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import java.util.TimeZone
|
||||
import javax.crypto.Mac
|
||||
import javax.crypto.spec.SecretKeySpec
|
||||
|
||||
/**
|
||||
* Minimal AWS Signature Version 4 signer for authenticated GET requests to an
|
||||
* S3-compatible storage (no request body, no query parameters).
|
||||
*/
|
||||
object S3Signer
|
||||
{
|
||||
private const val ALGORITHM = "AWS4-HMAC-SHA256"
|
||||
private val EMPTY_BODY_SHA256 = sha256Hex(ByteArray(0))
|
||||
|
||||
/**
|
||||
* Computes the headers that must be set on the request:
|
||||
* x-amz-date, x-amz-content-sha256 and Authorization
|
||||
*/
|
||||
fun signGet(url: URL, region: String, accessKey: String, secretKey: String, nowMillis: Long): Map<String, String>
|
||||
{
|
||||
val timestamp = SimpleDateFormat("yyyyMMdd'T'HHmmss'Z'", Locale.US).apply {
|
||||
timeZone = TimeZone.getTimeZone("UTC")
|
||||
}.format(Date(nowMillis))
|
||||
val dateStamp = timestamp.substring(0, 8)
|
||||
|
||||
val host = url.host.let { if (url.port in -1..0 || url.port == 443) it else "$it:${url.port}" }
|
||||
val path = url.path?.ifEmpty { "/" } ?: "/"
|
||||
|
||||
val canonicalHeaders = "host:$host\n" +
|
||||
"x-amz-content-sha256:$EMPTY_BODY_SHA256\n" +
|
||||
"x-amz-date:$timestamp\n"
|
||||
val signedHeaders = "host;x-amz-content-sha256;x-amz-date"
|
||||
val canonicalRequest = "GET\n$path\n\n$canonicalHeaders\n$signedHeaders\n$EMPTY_BODY_SHA256"
|
||||
|
||||
val scope = "$dateStamp/$region/s3/aws4_request"
|
||||
val stringToSign = "$ALGORITHM\n$timestamp\n$scope\n${sha256Hex(canonicalRequest.toByteArray())}"
|
||||
|
||||
val signingKey = deriveSigningKey(secretKey, dateStamp, region, "s3")
|
||||
val signature = hex(hmacSha256(signingKey, stringToSign.toByteArray()))
|
||||
|
||||
return mapOf(
|
||||
"x-amz-date" to timestamp,
|
||||
"x-amz-content-sha256" to EMPTY_BODY_SHA256,
|
||||
"Authorization" to "$ALGORITHM Credential=$accessKey/$scope, SignedHeaders=$signedHeaders, Signature=$signature",
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The HMAC-SHA256 key chain described by AWS:
|
||||
* kDate -> kRegion -> kService -> kSigning
|
||||
*/
|
||||
fun deriveSigningKey(secretKey: String, dateStamp: String, region: String, service: String): ByteArray
|
||||
{
|
||||
var key = hmacSha256(("AWS4$secretKey").toByteArray(), dateStamp.toByteArray())
|
||||
key = hmacSha256(key, region.toByteArray())
|
||||
key = hmacSha256(key, service.toByteArray())
|
||||
return hmacSha256(key, "aws4_request".toByteArray())
|
||||
}
|
||||
|
||||
internal fun hmacSha256(key: ByteArray, data: ByteArray): ByteArray
|
||||
{
|
||||
val mac = Mac.getInstance("HmacSHA256")
|
||||
mac.init(SecretKeySpec(key, "HmacSHA256"))
|
||||
return mac.doFinal(data)
|
||||
}
|
||||
|
||||
private fun sha256Hex(data: ByteArray): String
|
||||
{
|
||||
return hex(MessageDigest.getInstance("SHA-256").digest(data))
|
||||
}
|
||||
|
||||
internal fun hex(bytes: ByteArray): String
|
||||
{
|
||||
return bytes.joinToString("") { "%02x".format(it) }
|
||||
}
|
||||
}
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
/*
|
||||
* Copyright (C) 2026 SHX VPN
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or modify it
|
||||
* under the terms of the GNU General Public License as published by the
|
||||
* Free Software Foundation; either version 2 of the License, or (at your
|
||||
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful, but
|
||||
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
||||
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
|
||||
* for more details.
|
||||
*/
|
||||
|
||||
package org.strongswan.android.logic.updates
|
||||
|
||||
import android.content.Context
|
||||
import androidx.work.CoroutineWorker
|
||||
import androidx.work.WorkerParameters
|
||||
|
||||
/**
|
||||
* Periodic background update check. Runs via WorkManager, so it survives the
|
||||
* process being killed by the system (an in-process timer does not - the
|
||||
* system reclaims backgrounded processes aggressively, especially with the
|
||||
* VPN disconnected). The actual check is throttled by
|
||||
* [UpdateManager.checkIfDue] through the shared preference timestamp.
|
||||
*/
|
||||
class UpdateCheckWorker(context: Context, params: WorkerParameters) : CoroutineWorker(context, params)
|
||||
{
|
||||
override suspend fun doWork(): Result
|
||||
{
|
||||
/* wait for the check (and a silent pre-download it starts): returning
|
||||
* early lets the system reclaim the process mid-request, outside the
|
||||
* worker's charging/network constraints */
|
||||
val manager = UpdateManager.getInstance(applicationContext)
|
||||
manager.checkIfDue()?.join()
|
||||
manager.awaitDownload()
|
||||
return Result.success()
|
||||
}
|
||||
|
||||
companion object
|
||||
{
|
||||
const val WORK_NAME = "update-check"
|
||||
}
|
||||
}
|
||||
+74
@@ -0,0 +1,74 @@
|
||||
/*
|
||||
* Copyright (C) 2026 SHX VPN
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or modify it
|
||||
* under the terms of the GNU General Public License as published by the
|
||||
* Free Software Foundation; either version 2 of the License, or (at your
|
||||
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful, but
|
||||
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
||||
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
|
||||
* for more details.
|
||||
*/
|
||||
|
||||
package org.strongswan.android.logic.updates
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageInstaller
|
||||
import android.widget.Toast
|
||||
import androidx.core.content.IntentCompat
|
||||
import org.strongswan.android.R
|
||||
|
||||
/**
|
||||
* Receives the result of the self-update installation from PackageInstaller,
|
||||
* shows the system confirmation dialog when required and notifies the user
|
||||
* about the outcome
|
||||
*/
|
||||
class UpdateInstallStatusReceiver : BroadcastReceiver()
|
||||
{
|
||||
override fun onReceive(context: Context, intent: Intent)
|
||||
{
|
||||
val status = intent.getIntExtra(PackageInstaller.EXTRA_STATUS, PackageInstaller.STATUS_FAILURE)
|
||||
when (status)
|
||||
{
|
||||
PackageInstaller.STATUS_PENDING_USER_ACTION ->
|
||||
{
|
||||
/* the system requires the user to confirm the installation */
|
||||
android.util.Log.w("UpdateInstall", "System required user action, the silent install was overridden")
|
||||
val confirmation = IntentCompat.getParcelableExtra(intent, Intent.EXTRA_INTENT, Intent::class.java)
|
||||
if (confirmation != null)
|
||||
{
|
||||
confirmation.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
try
|
||||
{
|
||||
context.startActivity(confirmation)
|
||||
}
|
||||
catch (e: Exception)
|
||||
{
|
||||
Toast.makeText(context,
|
||||
context.getString(R.string.update_install_failed_fmt, ""),
|
||||
Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
PackageInstaller.STATUS_SUCCESS ->
|
||||
{
|
||||
Toast.makeText(context, R.string.update_install_success, Toast.LENGTH_LONG).show()
|
||||
UpdateManager.getInstance(context).onInstallFinished()
|
||||
}
|
||||
else ->
|
||||
{
|
||||
val message = intent.getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE)
|
||||
Toast.makeText(
|
||||
context,
|
||||
context.getString(R.string.update_install_failed_fmt, message ?: ""),
|
||||
Toast.LENGTH_LONG
|
||||
).show()
|
||||
UpdateManager.getInstance(context).onInstallFinished()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+144
@@ -0,0 +1,144 @@
|
||||
/*
|
||||
* Copyright (C) 2026 SHX VPN
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or modify it
|
||||
* under the terms of the GNU General Public License as published by the
|
||||
* Free Software Foundation; either version 2 of the License, or (at your
|
||||
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful, but
|
||||
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
||||
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
|
||||
* for more details.
|
||||
*/
|
||||
|
||||
package org.strongswan.android.logic.updates
|
||||
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageInstaller
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.provider.Settings
|
||||
import android.util.Log
|
||||
import androidx.core.content.FileProvider
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Installs a downloaded update APK as an in-place update (same package, higher
|
||||
* versionCode, same signing key - the app data is preserved, nothing is uninstalled).
|
||||
*/
|
||||
object UpdateInstaller
|
||||
{
|
||||
/* hardcoded: the runtime class name is obfuscated by R8 in release builds */
|
||||
private const val TAG = "UpdateInstaller"
|
||||
|
||||
fun install(context: Context, apk: File)
|
||||
{
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O)
|
||||
{
|
||||
try
|
||||
{
|
||||
installViaSession(context, apk)
|
||||
return
|
||||
}
|
||||
catch (e: Exception)
|
||||
{
|
||||
/* fall through to the package-archive intent below */
|
||||
Log.e(TAG, "PackageInstaller session failed, falling back", e)
|
||||
}
|
||||
}
|
||||
installViaIntent(context, apk)
|
||||
}
|
||||
|
||||
private fun installViaSession(context: Context, apk: File)
|
||||
{
|
||||
val packageInstaller = context.packageManager.packageInstaller
|
||||
val params = PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S)
|
||||
{
|
||||
/* the app updates itself (same package, same signing key, higher
|
||||
* versionCode); with UPDATE_PACKAGES_WITHOUT_USER_ACTION the
|
||||
* system applies the update without any confirmation dialog */
|
||||
params.setRequireUserAction(PackageInstaller.SessionParams.USER_ACTION_NOT_REQUIRED)
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
|
||||
{ /* claim update ownership (Android 14+): without it, newer Android
|
||||
* versions (and vendors like Samsung) override the silent install
|
||||
* and show the Play Protect confirmation on every update */
|
||||
params.setRequestUpdateOwnership(true)
|
||||
}
|
||||
val sessionId = packageInstaller.createSession(params)
|
||||
Log.i(TAG, "Committing install session $sessionId (silent self-update)")
|
||||
val session = packageInstaller.openSession(sessionId)
|
||||
try
|
||||
{
|
||||
session.openWrite("shx-vpn", 0, apk.length()).use { output ->
|
||||
apk.inputStream().use { input ->
|
||||
input.copyTo(output, 64 * 1024)
|
||||
}
|
||||
session.fsync(output)
|
||||
}
|
||||
val statusIntent = Intent(context, UpdateInstallStatusReceiver::class.java)
|
||||
val pendingIntent = PendingIntent.getBroadcast(context, sessionId, statusIntent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE)
|
||||
session.commit(pendingIntent.intentSender)
|
||||
}
|
||||
catch (e: Exception)
|
||||
{
|
||||
try
|
||||
{
|
||||
packageInstaller.abandonSession(sessionId)
|
||||
}
|
||||
catch (ignored: Exception)
|
||||
{
|
||||
}
|
||||
throw e
|
||||
}
|
||||
finally
|
||||
{
|
||||
session.close()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fallback for older devices (or a failing session): open the downloaded
|
||||
* APK with the system package installer
|
||||
*/
|
||||
private fun installViaIntent(context: Context, apk: File)
|
||||
{
|
||||
val uri = FileProvider.getUriForFile(context, context.packageName + ".fileprovider", apk)
|
||||
val intent = Intent(Intent.ACTION_VIEW)
|
||||
.setDataAndType(uri, "application/vnd.android.package-archive")
|
||||
.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
context.startActivity(intent)
|
||||
}
|
||||
|
||||
/** Whether the session-based install may be used right away */
|
||||
fun canInstall(context: Context): Boolean
|
||||
{
|
||||
return Build.VERSION.SDK_INT < Build.VERSION_CODES.O ||
|
||||
context.packageManager.canRequestPackageInstalls()
|
||||
}
|
||||
|
||||
/**
|
||||
* Intent for the system page that grants this app the "install unknown
|
||||
* apps" permission (required since Android 8 for the session-based install)
|
||||
*/
|
||||
fun createInstallPermissionIntent(context: Context): Intent
|
||||
{
|
||||
return Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES,
|
||||
Uri.parse("package:" + context.packageName))
|
||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the system page that grants this app the "install unknown apps"
|
||||
* permission (required since Android 8 for the session-based install)
|
||||
*/
|
||||
fun requestInstallPermission(context: Context)
|
||||
{
|
||||
context.startActivity(createInstallPermissionIntent(context))
|
||||
}
|
||||
}
|
||||
+839
@@ -0,0 +1,839 @@
|
||||
/*
|
||||
* Copyright (C) 2026 SHX VPN
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or modify it
|
||||
* under the terms of the GNU General Public License as published by the
|
||||
* Free Software Foundation; either version 2 of the License, or (at your
|
||||
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful, but
|
||||
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
||||
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
|
||||
* for more details.
|
||||
*/
|
||||
|
||||
package org.strongswan.android.logic.updates
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.ServiceConnection
|
||||
import android.content.SharedPreferences
|
||||
import android.content.pm.PackageInfo
|
||||
import android.content.pm.PackageManager
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.NetworkCapabilities
|
||||
import android.os.Build
|
||||
import android.os.IBinder
|
||||
import android.util.Log
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.content.pm.PackageInfoCompat
|
||||
import androidx.preference.PreferenceManager
|
||||
import androidx.work.Constraints
|
||||
import androidx.work.ExistingPeriodicWorkPolicy
|
||||
import androidx.work.NetworkType
|
||||
import androidx.work.PeriodicWorkRequestBuilder
|
||||
import androidx.work.WorkManager
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import org.json.JSONObject
|
||||
import org.strongswan.android.BuildConfig
|
||||
import org.strongswan.android.R
|
||||
import org.strongswan.android.logic.DiagLog
|
||||
import org.strongswan.android.logic.StrongSwanApplication
|
||||
import org.strongswan.android.logic.VpnStateService
|
||||
import org.strongswan.android.logic.connectivity.UnderlyingNetwork
|
||||
import org.strongswan.android.logic.events.Events
|
||||
import org.strongswan.android.ui.MainActivity
|
||||
import org.strongswan.android.utils.Constants
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.Proxy
|
||||
import java.net.URL
|
||||
import java.security.MessageDigest
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/** Categories of update failures, mapped to localized messages by the UI */
|
||||
enum class UpdateError { NETWORK, HASH, SIGNATURE, INSTALL, OTHER }
|
||||
|
||||
class UpdateException(val kind: UpdateError, message: String?, cause: Throwable? = null) :
|
||||
Exception(message, cause)
|
||||
|
||||
data class UpdateInfo(
|
||||
val versionCode: Long,
|
||||
val versionName: String,
|
||||
val apkUrl: String,
|
||||
val sha256: String,
|
||||
val size: Long,
|
||||
val notes: Map<String, String>,
|
||||
)
|
||||
|
||||
sealed class UpdateState
|
||||
{
|
||||
/** No update, nothing in progress */
|
||||
object Idle : UpdateState()
|
||||
|
||||
/** The check succeeded and the installed version is the newest one */
|
||||
object UpToDate : UpdateState()
|
||||
|
||||
object Checking : UpdateState()
|
||||
|
||||
data class Available(val info: UpdateInfo) : UpdateState()
|
||||
|
||||
data class Downloading(val info: UpdateInfo, val progress: Int) : UpdateState()
|
||||
|
||||
data class Failed(val error: UpdateError, val detail: String?) : UpdateState()
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks an update manifest on the update storage, downloads a newer APK
|
||||
* and hands it over to [UpdateInstaller] for an in-place update.
|
||||
*
|
||||
* The automatic check on app start is throttled and can be disabled in the
|
||||
* settings; a manual check is always possible.
|
||||
*/
|
||||
class UpdateManager private constructor(private val context: Context)
|
||||
{
|
||||
companion object
|
||||
{
|
||||
/* hardcoded: the runtime class name is obfuscated by R8 in release
|
||||
* builds, which turned the diag tag into "a:" */
|
||||
private const val TAG = "UpdateManager"
|
||||
|
||||
private const val UPDATE_DIR = "update"
|
||||
private const val APK_NAME = "update.apk"
|
||||
private const val CONNECT_TIMEOUT_MS = 10_000
|
||||
private const val READ_TIMEOUT_MS = 10_000
|
||||
private const val UPDATES_CHANNEL = "org.strongswan.android.updates"
|
||||
private const val UPDATES_NOTIFICATION_ID = 1001
|
||||
private const val UPDATES_PROGRESS_NOTIFICATION_ID = 1002
|
||||
|
||||
@Volatile
|
||||
private var mInstance: UpdateManager? = null
|
||||
|
||||
/* called from the main thread and the WorkManager thread: a second
|
||||
* instance would run its own check loop and state */
|
||||
@JvmStatic
|
||||
fun getInstance(context: Context): UpdateManager
|
||||
{
|
||||
return mInstance ?: synchronized(this)
|
||||
{
|
||||
mInstance ?: UpdateManager(context.applicationContext).also { mInstance = it }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the update base URL: baked in at build time from .env
|
||||
* (S3_PUBLIC_BASE_URL), with a fallback constant
|
||||
*/
|
||||
@JvmStatic
|
||||
fun getBaseUrl(): String
|
||||
{
|
||||
val configured = BuildConfig.UPDATE_BASE_URL
|
||||
return if (configured.isNullOrBlank())
|
||||
{
|
||||
Constants.UPDATE_BASE_URL
|
||||
}
|
||||
else if (configured.endsWith("/"))
|
||||
{
|
||||
configured
|
||||
}
|
||||
else
|
||||
{
|
||||
"$configured/"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val mPrefs: SharedPreferences = PreferenceManager.getDefaultSharedPreferences(context)
|
||||
private val mScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
private val mState = MutableStateFlow<UpdateState>(UpdateState.Idle)
|
||||
val state: StateFlow<UpdateState> = mState
|
||||
|
||||
private var mCheckJob: Job? = null
|
||||
private var mDownloadJob: Job? = null
|
||||
/* the user asked to install (e.g. tapped "update ready") while no update
|
||||
* was known in this process: install as soon as the check finds it */
|
||||
@Volatile
|
||||
private var mInstallPending = false
|
||||
private var mVpnService: VpnStateService? = null
|
||||
|
||||
private val mConnection = object : ServiceConnection
|
||||
{
|
||||
override fun onServiceConnected(name: ComponentName?, binder: IBinder?)
|
||||
{
|
||||
mVpnService = (binder as VpnStateService.LocalBinder).service
|
||||
}
|
||||
|
||||
override fun onServiceDisconnected(name: ComponentName?)
|
||||
{
|
||||
mVpnService = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Called from StrongSwanApplication; binds the VPN state (needed to pick
|
||||
* the right moment for silent installs), schedules the periodic
|
||||
* WorkManager check (survives the process being killed) and re-checks
|
||||
* in-process while the app is alive
|
||||
*/
|
||||
fun start()
|
||||
{
|
||||
cleanup()
|
||||
context.bindService(Intent(context, VpnStateService::class.java), mConnection, Context.BIND_AUTO_CREATE)
|
||||
val request = PeriodicWorkRequestBuilder<UpdateCheckWorker>(12, TimeUnit.HOURS)
|
||||
.setConstraints(Constraints.Builder()
|
||||
.setRequiredNetworkType(NetworkType.CONNECTED)
|
||||
/* background checks only run while charging - zero battery impact */
|
||||
.setRequiresCharging(true)
|
||||
.build())
|
||||
.build()
|
||||
WorkManager.getInstance(context).enqueueUniquePeriodicWork(
|
||||
UpdateCheckWorker.WORK_NAME, ExistingPeriodicWorkPolicy.UPDATE, request)
|
||||
/* no in-process timer: the VPN service keeps the process alive, so a
|
||||
* loop here would check (and pre-download) on battery every 12 h and
|
||||
* defeat the worker's charging constraint. The worker and the check
|
||||
* on app resume cover it. */
|
||||
}
|
||||
|
||||
/** Waits for a running silent download (used by the worker). */
|
||||
suspend fun awaitDownload()
|
||||
{
|
||||
mDownloadJob?.join()
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs the automatic check if it is enabled and due (throttled by the
|
||||
* last-check timestamp). Called by the periodic worker, the in-process
|
||||
* loop and on app resume.
|
||||
*/
|
||||
fun checkIfDue(): Job?
|
||||
{
|
||||
if (!isAutoCheckEnabled() || mCheckJob?.isActive == true || mDownloadJob?.isActive == true)
|
||||
{
|
||||
return null
|
||||
}
|
||||
val last = mPrefs.getLong(Constants.PREF_LAST_UPDATE_CHECK, 0L)
|
||||
if (System.currentTimeMillis() - last >= Constants.UPDATE_CHECK_INTERVAL_MS)
|
||||
{
|
||||
return check()
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
fun isAutoCheckEnabled(): Boolean
|
||||
{
|
||||
return mPrefs.getBoolean(Constants.PREF_AUTO_UPDATE_CHECK, true)
|
||||
}
|
||||
|
||||
/**
|
||||
* Manual check from the settings screen (always allowed)
|
||||
*/
|
||||
fun checkNow()
|
||||
{
|
||||
check()
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
private fun check(): Job?
|
||||
{
|
||||
if (mCheckJob?.isActive == true || mDownloadJob?.isActive == true)
|
||||
{
|
||||
return mCheckJob?.takeIf { it.isActive }
|
||||
}
|
||||
val job = mScope.launch {
|
||||
mState.value = UpdateState.Checking
|
||||
val info = fetchManifest() ?: run {
|
||||
mInstallPending = false
|
||||
return@launch
|
||||
}
|
||||
mPrefs.edit().putLong(Constants.PREF_LAST_UPDATE_CHECK, System.currentTimeMillis()).apply()
|
||||
if (info.versionCode > currentVersionCode())
|
||||
{
|
||||
DiagLog.i(TAG, "update: available ${info.versionName} (${info.versionCode}), " +
|
||||
"installed ${currentVersionName()} (${currentVersionCode()})")
|
||||
mState.value = UpdateState.Available(info)
|
||||
Events.updateAvailable(context, info.versionName, info.versionCode)
|
||||
if (mInstallPending)
|
||||
{
|
||||
mInstallPending = false
|
||||
downloadAndInstall()
|
||||
}
|
||||
else
|
||||
{
|
||||
maybeDownloadSilently(info)
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
mInstallPending = false
|
||||
DiagLog.i(TAG, "update: up to date (${currentVersionName()} ${currentVersionCode()})")
|
||||
mState.value = UpdateState.UpToDate
|
||||
}
|
||||
}
|
||||
mCheckJob = job
|
||||
return job
|
||||
}
|
||||
|
||||
/**
|
||||
* Silent mode: pre-download the update (unmetered networks only). If the
|
||||
* VPN is down and the system allows installing without user interaction
|
||||
* (Android 12+, UPDATE_PACKAGES_WITHOUT_USER_ACTION) the update is applied
|
||||
* right away; otherwise a notification tells the user it is ready - one
|
||||
* tap installs it (a single system confirmation on Android < 12)
|
||||
*/
|
||||
@Synchronized
|
||||
private fun maybeDownloadSilently(info: UpdateInfo)
|
||||
{
|
||||
if (!isSilentUpdatesEnabled())
|
||||
{
|
||||
DiagLog.i(TAG, "update: silent download disabled, waiting for manual install")
|
||||
return
|
||||
}
|
||||
if (isNetworkMetered())
|
||||
{
|
||||
DiagLog.i(TAG, "update: silent download deferred (network metered)")
|
||||
return
|
||||
}
|
||||
if (mDownloadJob?.isActive == true)
|
||||
{
|
||||
return
|
||||
}
|
||||
mDownloadJob = mScope.launch {
|
||||
try
|
||||
{
|
||||
DiagLog.i(TAG, "update: downloading ${info.versionName} (${info.size} B)")
|
||||
val apk = downloadApk(info)
|
||||
verifySignature(apk)
|
||||
if (isVpnDown() && canInstallSilently())
|
||||
{
|
||||
Log.i(TAG, "Applying the downloaded update silently")
|
||||
DiagLog.i(TAG, "update: installing ${info.versionName} silently")
|
||||
UpdateInstaller.install(context, apk)
|
||||
mState.value = UpdateState.Idle
|
||||
}
|
||||
else
|
||||
{
|
||||
DiagLog.i(TAG, "update: ${info.versionName} downloaded, waiting for the user")
|
||||
/* back to Available: Downloading would block downloadAndInstall()
|
||||
* and leave the settings row stuck at 100% */
|
||||
mState.value = UpdateState.Available(info)
|
||||
if (mInstallPending)
|
||||
{
|
||||
mInstallPending = false
|
||||
UpdateInstaller.install(context, apk)
|
||||
mState.value = UpdateState.Idle
|
||||
}
|
||||
else
|
||||
{
|
||||
notifyUpdateReady(info)
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (e: Exception)
|
||||
{
|
||||
Log.e(TAG, "Silent update download failed", e)
|
||||
DiagLog.i(TAG, "update: silent download failed: ${e.javaClass.simpleName}: ${e.message}")
|
||||
/* the manual download from the settings screen still works */
|
||||
mState.value = UpdateState.Available(info)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun isSilentUpdatesEnabled(): Boolean
|
||||
{
|
||||
return mPrefs.getBoolean(Constants.PREF_SILENT_UPDATES, false)
|
||||
}
|
||||
|
||||
private fun isNetworkMetered(): Boolean
|
||||
{
|
||||
val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager ?: return true
|
||||
/* the VPN marks itself unmetered (setMetered(false)), so with the tunnel
|
||||
* up the active network always looks unmetered - judge by the physical
|
||||
* network below it instead */
|
||||
val underlying = UnderlyingNetwork.resolve(context) ?: return cm.isActiveNetworkMetered
|
||||
val caps = try
|
||||
{
|
||||
cm.getNetworkCapabilities(underlying)
|
||||
}
|
||||
catch (_: Exception)
|
||||
{
|
||||
null
|
||||
} ?: return true
|
||||
return !caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED)
|
||||
}
|
||||
|
||||
private fun isVpnDown(): Boolean
|
||||
{
|
||||
return mVpnService?.state == VpnStateService.State.DISABLED
|
||||
}
|
||||
|
||||
private fun canInstallSilently(): Boolean
|
||||
{
|
||||
return Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && UpdateInstaller.canInstall(context)
|
||||
}
|
||||
|
||||
|
||||
private fun ensureUpdatesChannel(manager: NotificationManager)
|
||||
{
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O)
|
||||
{
|
||||
manager.createNotificationChannel(
|
||||
NotificationChannel(UPDATES_CHANNEL, context.getString(R.string.updates_channel_name),
|
||||
NotificationManager.IMPORTANCE_DEFAULT))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows the download progress. Android 16+ gets Notification.ProgressStyle, promoted
|
||||
* to a Live Update (POST_PROMOTED_NOTIFICATIONS is a normal permission, granted at
|
||||
* install, so there is no extra prompt); older versions get the classic progress bar.
|
||||
*/
|
||||
private fun postDownloadProgress(info: UpdateInfo, progress: Int)
|
||||
{
|
||||
val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as? NotificationManager ?: return
|
||||
ensureUpdatesChannel(manager)
|
||||
val title = context.getString(R.string.update_downloading_title, info.versionName)
|
||||
val intent = Intent(context, MainActivity::class.java).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP)
|
||||
val pending = PendingIntent.getActivity(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
|
||||
val notification = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.BAKLAVA)
|
||||
{
|
||||
Notification.Builder(context, UPDATES_CHANNEL)
|
||||
.setSmallIcon(R.drawable.ic_notification)
|
||||
.setContentTitle(title)
|
||||
.setContentText("$progress%")
|
||||
.setShortCriticalText("$progress%")
|
||||
.setStyle(Notification.ProgressStyle()
|
||||
.setProgressSegments(listOf(Notification.ProgressStyle.Segment(100)))
|
||||
.setProgress(progress))
|
||||
.setRequestPromotedOngoing(true)
|
||||
.setOngoing(true)
|
||||
.setOnlyAlertOnce(true)
|
||||
.setContentIntent(pending)
|
||||
.build()
|
||||
}
|
||||
else
|
||||
{
|
||||
NotificationCompat.Builder(context, UPDATES_CHANNEL)
|
||||
.setSmallIcon(R.drawable.ic_notification)
|
||||
.setContentTitle(title)
|
||||
.setContentText("$progress%")
|
||||
.setProgress(100, progress, false)
|
||||
.setOngoing(true)
|
||||
.setOnlyAlertOnce(true)
|
||||
.setContentIntent(pending)
|
||||
.build()
|
||||
}
|
||||
try
|
||||
{
|
||||
manager.notify(UPDATES_PROGRESS_NOTIFICATION_ID, notification)
|
||||
}
|
||||
catch (e: SecurityException)
|
||||
{ /* POST_NOTIFICATIONS denied - progress stays visible in the settings */
|
||||
Log.w(TAG, "Cannot post the update progress notification", e)
|
||||
}
|
||||
}
|
||||
|
||||
private fun cancelDownloadProgress()
|
||||
{
|
||||
(context.getSystemService(Context.NOTIFICATION_SERVICE) as? NotificationManager)
|
||||
?.cancel(UPDATES_PROGRESS_NOTIFICATION_ID)
|
||||
}
|
||||
|
||||
/** Posts a notification that the downloaded update is ready to install */
|
||||
private fun notifyUpdateReady(info: UpdateInfo)
|
||||
{
|
||||
if (!org.strongswan.android.logic.events.NotifPrefs.enabled(context, org.strongswan.android.logic.events.NotifPrefs.UPDATES))
|
||||
{ /* switched off in the notification settings; the update stays in Settings and Events */
|
||||
return
|
||||
}
|
||||
val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as? NotificationManager
|
||||
if (manager == null)
|
||||
{
|
||||
return
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O)
|
||||
{
|
||||
manager.createNotificationChannel(
|
||||
NotificationChannel(UPDATES_CHANNEL, context.getString(R.string.updates_channel_name),
|
||||
NotificationManager.IMPORTANCE_DEFAULT))
|
||||
}
|
||||
val intent = Intent(context, MainActivity::class.java)
|
||||
.putExtra(MainActivity.EXTRA_INSTALL_UPDATE, true)
|
||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP)
|
||||
val pending = PendingIntent.getActivity(context, info.versionCode.toInt(), intent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
|
||||
val text = context.getString(R.string.update_ready_text, info.versionName)
|
||||
val notification = NotificationCompat.Builder(context, UPDATES_CHANNEL)
|
||||
.setSmallIcon(R.drawable.ic_notification)
|
||||
.setContentTitle(context.getString(R.string.update_ready_title))
|
||||
.setContentText(text)
|
||||
.setStyle(NotificationCompat.BigTextStyle().bigText(text))
|
||||
.setContentIntent(pending)
|
||||
.setAutoCancel(true)
|
||||
/* F_SysNotifs: «Update» / «What is new» */
|
||||
.addAction(0, context.getString(R.string.notif_update), pending)
|
||||
.addAction(0, context.getString(R.string.notif_whats_new), PendingIntent.getActivity(
|
||||
context, info.versionCode.toInt() + 1,
|
||||
Intent(context, MainActivity::class.java).putExtra(MainActivity.EXTRA_OPEN_UPDATES, true)
|
||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP),
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE))
|
||||
.build()
|
||||
try
|
||||
{
|
||||
manager.notify(UPDATES_NOTIFICATION_ID, notification)
|
||||
}
|
||||
catch (e: SecurityException)
|
||||
{ /* POST_NOTIFICATIONS denied - the update stays available in the settings */
|
||||
Log.w(TAG, "Cannot post the update notification", e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Downloads the update APK (with progress) and installs it as an
|
||||
* in-place update (the app data is preserved)
|
||||
*/
|
||||
@Synchronized
|
||||
fun downloadAndInstall()
|
||||
{
|
||||
if (mDownloadJob?.isActive == true)
|
||||
{ /* a silent download is running: install once it is done */
|
||||
mInstallPending = true
|
||||
return
|
||||
}
|
||||
val info = (mState.value as? UpdateState.Available)?.info
|
||||
if (info == null)
|
||||
{ /* e.g. the "update ready" notification was tapped after the process
|
||||
* restarted: look the update up again and install it when found */
|
||||
mInstallPending = true
|
||||
check()
|
||||
return
|
||||
}
|
||||
mDownloadJob = mScope.launch {
|
||||
try
|
||||
{
|
||||
DiagLog.i(TAG, "update: downloading ${info.versionName} (manual)")
|
||||
val apk = downloadApk(info)
|
||||
verifySignature(apk)
|
||||
DiagLog.i(TAG, "update: installing ${info.versionName} (manual)")
|
||||
UpdateInstaller.install(context, apk)
|
||||
mState.value = UpdateState.Idle
|
||||
}
|
||||
catch (e: UpdateException)
|
||||
{
|
||||
Log.e(TAG, "Update failed: ${e.kind}", e)
|
||||
DiagLog.i(TAG, "update: install failed (${e.kind}): ${e.message}")
|
||||
mState.value = UpdateState.Failed(e.kind, e.message)
|
||||
cleanup()
|
||||
}
|
||||
catch (e: Exception)
|
||||
{
|
||||
Log.e(TAG, "Update failed", e)
|
||||
DiagLog.i(TAG, "update: install failed: ${e.javaClass.simpleName}: ${e.message}")
|
||||
mState.value = UpdateState.Failed(UpdateError.OTHER, e.message)
|
||||
cleanup()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Called by the install status receiver once an installation finished
|
||||
*/
|
||||
fun onInstallFinished()
|
||||
{
|
||||
cleanup()
|
||||
if (mState.value !is UpdateState.Idle)
|
||||
{
|
||||
mState.value = UpdateState.Idle
|
||||
}
|
||||
}
|
||||
|
||||
private fun fetchManifest(): UpdateInfo?
|
||||
{
|
||||
try
|
||||
{
|
||||
val conn = openConnection(getBaseUrl() + Constants.UPDATE_MANIFEST_FILE)
|
||||
val code = conn.responseCode
|
||||
if (code != HttpURLConnection.HTTP_OK)
|
||||
{
|
||||
conn.disconnect()
|
||||
DiagLog.i(TAG, "update: check failed: HTTP $code")
|
||||
mState.value = UpdateState.Failed(UpdateError.NETWORK, "HTTP $code")
|
||||
return null
|
||||
}
|
||||
val body = conn.inputStream.use { stream -> stream.readBytes() }.decodeToString()
|
||||
val json = JSONObject(body)
|
||||
val info = UpdateInfo(
|
||||
versionCode = json.getLong("versionCode"),
|
||||
versionName = json.optString("versionName", ""),
|
||||
apkUrl = getBaseUrl() + json.optString("apk", "shx-vpn.apk"),
|
||||
sha256 = json.optString("sha256", "").lowercase(),
|
||||
size = json.optLong("size", -1L),
|
||||
notes = parseNotes(json.optJSONObject("notes")),
|
||||
)
|
||||
return info
|
||||
}
|
||||
catch (e: Exception)
|
||||
{
|
||||
Log.e(TAG, "Failed to fetch the update manifest", e)
|
||||
DiagLog.i(TAG, "update: check failed: ${e.javaClass.simpleName}: ${e.message}")
|
||||
mState.value = UpdateState.Failed(UpdateError.NETWORK, e.message)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseNotes(notes: JSONObject?): Map<String, String>
|
||||
{
|
||||
if (notes == null)
|
||||
{
|
||||
return emptyMap()
|
||||
}
|
||||
val result = mutableMapOf<String, String>()
|
||||
for (key in notes.keys())
|
||||
{
|
||||
result[key.lowercase()] = notes.optString(key, "")
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private fun downloadApk(info: UpdateInfo): File
|
||||
{
|
||||
val dir = File(context.filesDir, UPDATE_DIR)
|
||||
if (!dir.exists() && !dir.mkdirs())
|
||||
{
|
||||
throw UpdateException(UpdateError.OTHER, "cannot create ${dir.name}")
|
||||
}
|
||||
val apk = File(dir, APK_NAME)
|
||||
if (info.sha256.isNotBlank() && apk.isFile && sha256File(apk) == info.sha256)
|
||||
{ /* already fetched by the silent pre-download */
|
||||
return apk
|
||||
}
|
||||
apk.delete()
|
||||
|
||||
val conn = openConnection(info.apkUrl)
|
||||
conn.readTimeout = 30_000
|
||||
try
|
||||
{
|
||||
val code = conn.responseCode
|
||||
if (code != HttpURLConnection.HTTP_OK)
|
||||
{
|
||||
throw UpdateException(UpdateError.NETWORK, "HTTP $code")
|
||||
}
|
||||
val expected = if (info.size > 0) info.size else -1L
|
||||
val digest = MessageDigest.getInstance("SHA-256")
|
||||
val buffer = ByteArray(64 * 1024)
|
||||
var downloaded = 0L
|
||||
var lastProgress = -1
|
||||
|
||||
conn.inputStream.use { input ->
|
||||
FileOutputStream(apk).use { output ->
|
||||
while (true)
|
||||
{
|
||||
val len = input.read(buffer)
|
||||
if (len < 0)
|
||||
{
|
||||
break
|
||||
}
|
||||
output.write(buffer, 0, len)
|
||||
digest.update(buffer, 0, len)
|
||||
downloaded += len
|
||||
if (expected > 0)
|
||||
{
|
||||
val progress = (downloaded * 100 / expected).toInt().coerceIn(0, 100)
|
||||
if (progress != lastProgress)
|
||||
{
|
||||
lastProgress = progress
|
||||
mState.value = UpdateState.Downloading(info, progress)
|
||||
postDownloadProgress(info, progress)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val actualHash = digest.digest().joinToString("") { "%02x".format(it) }
|
||||
if (info.sha256.isNotBlank() && actualHash != info.sha256)
|
||||
{
|
||||
throw UpdateException(UpdateError.HASH, "SHA-256 mismatch")
|
||||
}
|
||||
if (expected > 0 && downloaded != expected)
|
||||
{
|
||||
throw UpdateException(UpdateError.HASH, "size mismatch")
|
||||
}
|
||||
return apk
|
||||
}
|
||||
finally
|
||||
{
|
||||
conn.disconnect()
|
||||
cancelDownloadProgress()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compares the signing certificate of the downloaded APK with the one of
|
||||
* the installed app; a mismatch is rejected here so the user gets a clear
|
||||
* error instead of the system offering to remove the app first
|
||||
*/
|
||||
private fun verifySignature(apk: File)
|
||||
{
|
||||
val installed = signingDigest(getPackageInfo())
|
||||
val archive = signingDigest(getArchiveInfo(apk))
|
||||
if (installed != null && archive != null && installed != archive)
|
||||
{
|
||||
throw UpdateException(UpdateError.SIGNATURE, "signing certificate mismatch")
|
||||
}
|
||||
}
|
||||
|
||||
private fun currentVersionCode(): Long
|
||||
{
|
||||
return try
|
||||
{
|
||||
PackageInfoCompat.getLongVersionCode(getPackageInfo()!!)
|
||||
}
|
||||
catch (e: Exception)
|
||||
{
|
||||
0L
|
||||
}
|
||||
}
|
||||
|
||||
private fun currentVersionName(): String
|
||||
{
|
||||
return try
|
||||
{
|
||||
getPackageInfo()?.versionName ?: "?"
|
||||
}
|
||||
catch (e: Exception)
|
||||
{
|
||||
"?"
|
||||
}
|
||||
}
|
||||
|
||||
private fun getPackageInfo(): PackageInfo?
|
||||
{
|
||||
val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) PackageManager.GET_SIGNING_CERTIFICATES
|
||||
else @Suppress("DEPRECATION") PackageManager.GET_SIGNATURES
|
||||
return context.packageManager.getPackageInfo(context.packageName, flags)
|
||||
}
|
||||
|
||||
private fun getArchiveInfo(apk: File): PackageInfo?
|
||||
{
|
||||
val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) PackageManager.GET_SIGNING_CERTIFICATES
|
||||
else @Suppress("DEPRECATION") PackageManager.GET_SIGNATURES
|
||||
return context.packageManager.getPackageArchiveInfo(apk.absolutePath, flags)
|
||||
}
|
||||
|
||||
private fun signingDigest(info: PackageInfo?): String?
|
||||
{
|
||||
if (info == null)
|
||||
{
|
||||
return null
|
||||
}
|
||||
val signatures = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) info.signingInfo?.apkContentsSigners
|
||||
else @Suppress("DEPRECATION") info.signatures
|
||||
val signature = signatures?.firstOrNull() ?: return null
|
||||
val digest = MessageDigest.getInstance("SHA-256").digest(signature.toByteArray())
|
||||
return digest.joinToString("") { "%02x".format(it) }
|
||||
}
|
||||
|
||||
/** SHA-256 of a local file, or null if it cannot be read */
|
||||
private fun sha256File(file: File): String?
|
||||
{
|
||||
return try
|
||||
{
|
||||
val digest = MessageDigest.getInstance("SHA-256")
|
||||
file.inputStream().use { input ->
|
||||
val buffer = ByteArray(64 * 1024)
|
||||
while (true)
|
||||
{
|
||||
val len = input.read(buffer)
|
||||
if (len < 0)
|
||||
{
|
||||
break
|
||||
}
|
||||
digest.update(buffer, 0, len)
|
||||
}
|
||||
}
|
||||
digest.digest().joinToString("") { "%02x".format(it) }
|
||||
}
|
||||
catch (e: Exception)
|
||||
{
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun openConnection(url: String): HttpURLConnection
|
||||
{
|
||||
val conn = URL(url).openConnection(Proxy.NO_PROXY) as HttpURLConnection
|
||||
conn.connectTimeout = CONNECT_TIMEOUT_MS
|
||||
conn.readTimeout = READ_TIMEOUT_MS
|
||||
conn.setRequestProperty("Connection", "close")
|
||||
conn.setRequestProperty("Cache-Control", "no-cache")
|
||||
applyS3Auth(conn, url)
|
||||
return conn
|
||||
}
|
||||
|
||||
/**
|
||||
* Signs the request with the baked-in S3 credentials (AWS Signature
|
||||
* Version 4) so the update storage can stay private. Without credentials
|
||||
* the storage must allow public reads.
|
||||
*/
|
||||
private fun applyS3Auth(conn: HttpURLConnection, url: String)
|
||||
{
|
||||
val access = decodeUpdateSecret(BuildConfig.UPDATE_S3_ACCESS_KEY)
|
||||
val secret = decodeUpdateSecret(BuildConfig.UPDATE_S3_SECRET_KEY)
|
||||
if (access.isEmpty() || secret.isEmpty())
|
||||
{
|
||||
return
|
||||
}
|
||||
val headers = S3Signer.signGet(URL(url), BuildConfig.UPDATE_S3_REGION, access, secret, System.currentTimeMillis())
|
||||
for ((name, value) in headers)
|
||||
{
|
||||
conn.setRequestProperty(name, value)
|
||||
}
|
||||
}
|
||||
|
||||
private fun decodeUpdateSecret(encoded: String): String
|
||||
{
|
||||
if (encoded.isEmpty())
|
||||
{
|
||||
return ""
|
||||
}
|
||||
return try
|
||||
{
|
||||
val key = android.util.Base64.decode(BuildConfig.UPDATE_S3_KEY_XOR, android.util.Base64.DEFAULT)
|
||||
val data = android.util.Base64.decode(encoded, android.util.Base64.DEFAULT)
|
||||
for (i in data.indices)
|
||||
{
|
||||
data[i] = (data[i].toInt() xor key[i % key.size].toInt()).toByte()
|
||||
}
|
||||
String(data, Charsets.UTF_8)
|
||||
}
|
||||
catch (e: Exception)
|
||||
{
|
||||
Log.e(TAG, "Failed to decode the update credentials", e)
|
||||
""
|
||||
}
|
||||
}
|
||||
|
||||
private fun cleanup()
|
||||
{
|
||||
try
|
||||
{
|
||||
File(context.filesDir, UPDATE_DIR).deleteRecursively()
|
||||
}
|
||||
catch (e: Exception)
|
||||
{
|
||||
Log.w(TAG, "Failed to clean up the update directory", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
/*
|
||||
* Copyright (C) 2026 SHX VPN
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or modify it
|
||||
* under the terms of the GNU General Public License as published by the
|
||||
* Free Software Foundation; either version 2 of the License, or (at your
|
||||
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful, but
|
||||
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
||||
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
|
||||
* for more details.
|
||||
*/
|
||||
|
||||
package org.strongswan.android.logic.updates
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import org.strongswan.android.logic.autoconnect.AutoConnectManager
|
||||
|
||||
/**
|
||||
* Runs after the app updated itself (the system kills the process during the
|
||||
* install, so this is the first code of the new version that runs): clears
|
||||
* the update state and lets the auto-connect logic restore the VPN connection
|
||||
* if it was up before the update.
|
||||
*/
|
||||
class UpdateReplacedReceiver : BroadcastReceiver()
|
||||
{
|
||||
override fun onReceive(context: Context, intent: Intent)
|
||||
{
|
||||
if (intent.action != Intent.ACTION_MY_PACKAGE_REPLACED)
|
||||
{
|
||||
return
|
||||
}
|
||||
UpdateManager.getInstance(context).onInstallFinished()
|
||||
AutoConnectManager.getInstance(context).reevaluate()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
/*
|
||||
* Copyright (C) 2026 SHX VPN
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or modify it
|
||||
* under the terms of the GNU General Public License as published by the
|
||||
* Free Software Foundation; either version 2 of the License, or (at your
|
||||
* option) any later version.
|
||||
*/
|
||||
|
||||
package org.strongswan.android.ui
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.PowerManager
|
||||
import android.provider.Settings
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import org.strongswan.android.R
|
||||
import org.strongswan.android.ui.compose.SecondaryScaffold
|
||||
import org.strongswan.android.ui.compose.design.ShxButton
|
||||
import org.strongswan.android.ui.compose.design.ShxButtonStyle
|
||||
import org.strongswan.android.ui.compose.design.ShxChip
|
||||
import org.strongswan.android.ui.compose.design.ShxIcons
|
||||
import org.strongswan.android.ui.compose.theme.LocalShxColors
|
||||
import org.strongswan.android.ui.compose.theme.ShxTheme
|
||||
import org.strongswan.android.ui.compose.theme.ShxType
|
||||
|
||||
/** «Without battery saving» (F_Battery): the system exemption, then the vendor switches. */
|
||||
class BatteryActivity : AppCompatActivity()
|
||||
{
|
||||
override fun onCreate(savedInstanceState: Bundle?)
|
||||
{
|
||||
super.onCreate(savedInstanceState)
|
||||
enableEdgeToEdge()
|
||||
setContent {
|
||||
ShxTheme {
|
||||
BatteryScreen(onBack = { finish() })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* phone makers with their own background killers; the steps differ per brand */
|
||||
private enum class Brand(val label: Int, val steps: List<Int>)
|
||||
{
|
||||
XIAOMI(R.string.battery_brand_xiaomi, listOf(R.string.battery_step_open, R.string.battery_xiaomi_2, R.string.battery_xiaomi_3)),
|
||||
SAMSUNG(R.string.battery_brand_samsung, listOf(R.string.battery_step_open, R.string.battery_samsung_2, R.string.battery_samsung_3)),
|
||||
HUAWEI(R.string.battery_brand_huawei, listOf(R.string.battery_step_open, R.string.battery_huawei_2, R.string.battery_huawei_3)),
|
||||
OPPO(R.string.battery_brand_oppo, listOf(R.string.battery_step_open, R.string.battery_oppo_2, R.string.battery_oppo_3)),
|
||||
OTHER(R.string.battery_brand_other, listOf(R.string.battery_step_open, R.string.battery_other_2)),
|
||||
}
|
||||
|
||||
private fun detectBrand(): Brand
|
||||
{
|
||||
val m = (Build.MANUFACTURER + " " + Build.BRAND).lowercase()
|
||||
return when
|
||||
{
|
||||
listOf("xiaomi", "redmi", "poco").any { it in m } -> Brand.XIAOMI
|
||||
"samsung" in m -> Brand.SAMSUNG
|
||||
"huawei" in m || "honor" in m -> Brand.HUAWEI
|
||||
listOf("oppo", "realme", "oneplus").any { it in m } -> Brand.OPPO
|
||||
else -> Brand.OTHER
|
||||
}
|
||||
}
|
||||
|
||||
private fun isExempt(context: Context): Boolean =
|
||||
(context.getSystemService(Context.POWER_SERVICE) as? PowerManager)?.isIgnoringBatteryOptimizations(context.packageName) ?: true
|
||||
|
||||
@SuppressLint("BatteryLife")
|
||||
@Composable
|
||||
private fun BatteryScreen(onBack: () -> Unit)
|
||||
{
|
||||
val c = LocalShxColors.current
|
||||
val context = LocalContext.current
|
||||
var checks by remember { mutableIntStateOf(0) }
|
||||
val owner = LocalLifecycleOwner.current
|
||||
DisposableEffect(owner) {
|
||||
val observer = LifecycleEventObserver { _, event -> if (event == Lifecycle.Event.ON_RESUME) checks++ }
|
||||
owner.lifecycle.addObserver(observer)
|
||||
onDispose { owner.lifecycle.removeObserver(observer) }
|
||||
}
|
||||
val exempt = remember(checks) { isExempt(context) }
|
||||
val detected = remember { detectBrand() }
|
||||
var brand by rememberSaveable { mutableIntStateOf(detected.ordinal) }
|
||||
fun open(intent: Intent) = runCatching { context.startActivity(intent) }.isSuccess
|
||||
|
||||
SecondaryScaffold(title = stringResource(R.string.battery_title), onBack = onBack) { padding ->
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
modifier = Modifier.fillMaxSize().padding(padding).verticalScroll(rememberScrollState())
|
||||
.padding(start = 16.dp, end = 16.dp, bottom = 28.dp),
|
||||
)
|
||||
{
|
||||
Text(
|
||||
stringResource(R.string.battery_intro), style = ShxType.note.copy(fontSize = 15.sp, lineHeight = 22.sp, fontWeight = FontWeight.Normal),
|
||||
color = c.textTonal, modifier = Modifier.padding(horizontal = 4.dp),
|
||||
)
|
||||
StepCard(1, stringResource(R.string.battery_step1_title), highlight = !exempt)
|
||||
{
|
||||
Text(stringResource(R.string.battery_step1_text), style = ShxType.note.copy(lineHeight = 19.sp, fontWeight = FontWeight.Normal), color = c.textSoft)
|
||||
if (exempt)
|
||||
{
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp))
|
||||
{
|
||||
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(24.dp).clip(CircleShape).background(c.success))
|
||||
{
|
||||
Icon(ShxIcons.Check, contentDescription = null, tint = Color.White, modifier = Modifier.size(14.dp))
|
||||
}
|
||||
Text(stringResource(R.string.battery_step1_done), style = ShxType.body.copy(fontWeight = FontWeight.Bold), color = c.onSuccess)
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
ShxButton(
|
||||
stringResource(R.string.perm_allow),
|
||||
{ open(Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS, Uri.parse("package:" + context.packageName))) },
|
||||
height = 48.dp, modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
}
|
||||
StepCard(2, stringResource(R.string.battery_step2_title), highlight = false)
|
||||
{
|
||||
FlowRow(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalArrangement = Arrangement.spacedBy(6.dp))
|
||||
{
|
||||
Brand.entries.forEach { b -> ShxChip(stringResource(b.label), brand == b.ordinal, { brand = b.ordinal }) }
|
||||
}
|
||||
if (detected != Brand.OTHER)
|
||||
{
|
||||
Text(
|
||||
stringResource(R.string.battery_detected, Build.MANUFACTURER.replaceFirstChar { it.uppercase() }),
|
||||
style = ShxType.meta.copy(fontWeight = FontWeight.Bold), color = c.accentText,
|
||||
)
|
||||
}
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp))
|
||||
{
|
||||
Brand.entries[brand].steps.forEachIndexed { i, step ->
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(10.dp))
|
||||
{
|
||||
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(22.dp).clip(CircleShape).background(c.tile))
|
||||
{
|
||||
Text("${i + 1}", style = ShxType.meta.copy(fontWeight = FontWeight.ExtraBold), color = c.onAccentContainer)
|
||||
}
|
||||
Text(stringResource(step), style = ShxType.body.copy(fontWeight = FontWeight.Normal, lineHeight = 20.sp), color = c.text)
|
||||
}
|
||||
}
|
||||
}
|
||||
ShxButton(
|
||||
stringResource(R.string.battery_open_app_settings),
|
||||
{ open(Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, Uri.fromParts("package", context.packageName, null))) },
|
||||
style = ShxButtonStyle.OUTLINE, height = 48.dp, modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(18.dp))
|
||||
.background(if (exempt) c.successSoft else c.warningContainer).padding(horizontal = 14.dp, vertical = 12.dp),
|
||||
)
|
||||
{
|
||||
Icon(if (exempt) ShxIcons.Check else ShxIcons.Alert, contentDescription = null, tint = if (exempt) c.onSuccess else c.onWarningContainer, modifier = Modifier.size(20.dp))
|
||||
Text(
|
||||
stringResource(if (exempt) R.string.battery_ok else R.string.battery_not_done),
|
||||
style = ShxType.note.copy(lineHeight = 19.sp, fontWeight = FontWeight.Normal), color = if (exempt) c.onSuccess else c.onWarningContainer,
|
||||
)
|
||||
}
|
||||
ShxButton(stringResource(R.string.battery_check_again), { checks++ }, style = ShxButtonStyle.SOFT, modifier = Modifier.fillMaxWidth())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StepCard(n: Int, title: String, highlight: Boolean, content: @Composable () -> Unit)
|
||||
{
|
||||
val c = LocalShxColors.current
|
||||
val shape = RoundedCornerShape(24.dp)
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
modifier = Modifier.fillMaxWidth().clip(shape).background(c.card).border(1.dp, if (highlight) c.accentBorder else c.cardBorder, shape).padding(16.dp),
|
||||
)
|
||||
{
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp))
|
||||
{
|
||||
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(28.dp).clip(CircleShape).background(c.accent))
|
||||
{
|
||||
Text("$n", style = ShxType.chip.copy(fontWeight = FontWeight.ExtraBold), color = Color.White)
|
||||
}
|
||||
Text(title, style = ShxType.rowTitle.copy(fontWeight = FontWeight.ExtraBold), color = c.text)
|
||||
}
|
||||
content()
|
||||
}
|
||||
}
|
||||
-82
@@ -1,82 +0,0 @@
|
||||
/*
|
||||
* Copyright (C) 2014 Tobias Brunner
|
||||
*
|
||||
* Copyright (C) secunet Security Networks AG
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or modify it
|
||||
* under the terms of the GNU General Public License as published by the
|
||||
* Free Software Foundation; either version 2 of the License, or (at your
|
||||
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful, but
|
||||
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
||||
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
|
||||
* for more details.
|
||||
*/
|
||||
|
||||
package org.strongswan.android.ui;
|
||||
|
||||
import android.app.Dialog;
|
||||
import android.content.Context;
|
||||
import android.content.DialogInterface;
|
||||
import android.os.Bundle;
|
||||
|
||||
import org.strongswan.android.R;
|
||||
|
||||
import androidx.appcompat.app.AlertDialog;
|
||||
import androidx.appcompat.app.AppCompatDialogFragment;
|
||||
|
||||
/**
|
||||
* Class that displays a confirmation dialog to delete a selected local
|
||||
* certificate.
|
||||
*/
|
||||
public class CertificateDeleteConfirmationDialog extends AppCompatDialogFragment
|
||||
{
|
||||
public static final String ALIAS = "alias";
|
||||
OnCertificateDeleteListener mListener;
|
||||
|
||||
/**
|
||||
* Interface that can be implemented by parent activities to get the
|
||||
* alias of the certificate to delete, if the user confirms the deletion.
|
||||
*/
|
||||
public interface OnCertificateDeleteListener
|
||||
{
|
||||
public void onDelete(String alias);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onAttach(Context context)
|
||||
{
|
||||
super.onAttach(context);
|
||||
if (context instanceof OnCertificateDeleteListener)
|
||||
{
|
||||
mListener = (OnCertificateDeleteListener)context;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public Dialog onCreateDialog(Bundle savedInstanceState)
|
||||
{
|
||||
return new AlertDialog.Builder(getActivity())
|
||||
.setIcon(android.R.drawable.ic_dialog_alert)
|
||||
.setTitle(R.string.delete_certificate_question)
|
||||
.setMessage(R.string.delete_certificate)
|
||||
.setPositiveButton(R.string.delete_profile, new DialogInterface.OnClickListener() {
|
||||
@Override
|
||||
public void onClick(DialogInterface dialog, int whichButton)
|
||||
{
|
||||
if (mListener != null)
|
||||
{
|
||||
mListener.onDelete(getArguments().getString(ALIAS));
|
||||
}
|
||||
}
|
||||
})
|
||||
.setNegativeButton(android.R.string.cancel, new DialogInterface.OnClickListener() {
|
||||
@Override
|
||||
public void onClick(DialogInterface dialog, int which)
|
||||
{
|
||||
dismiss();
|
||||
}
|
||||
}).create();
|
||||
}
|
||||
}
|
||||
+6
-12
@@ -23,29 +23,23 @@ import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import org.strongswan.android.R
|
||||
import org.strongswan.android.data.VpnProfileSource
|
||||
import org.strongswan.android.logic.autoconnect.AutoConnectManager
|
||||
import org.strongswan.android.logic.autoconnect.SsidReader
|
||||
import org.strongswan.android.logic.connectivity.HealthCheckManager
|
||||
import org.strongswan.android.ui.compose.ConnectivityCheckScreen
|
||||
import org.strongswan.android.ui.compose.SecondaryScaffold
|
||||
import org.strongswan.android.ui.compose.TrustedNetworksScreen
|
||||
import org.strongswan.android.ui.compose.theme.ShxTheme
|
||||
|
||||
class TrustedNetworksActivity : AppCompatActivity()
|
||||
class ConnectivityCheckActivity : AppCompatActivity()
|
||||
{
|
||||
override fun onCreate(savedInstanceState: Bundle?)
|
||||
{
|
||||
super.onCreate(savedInstanceState)
|
||||
enableEdgeToEdge()
|
||||
AutoConnectManager.getInstance(this).reloadConfig()
|
||||
val source = VpnProfileSource(this).open()
|
||||
val profiles = source.allVpnProfiles
|
||||
source.close()
|
||||
val currentSsid = SsidReader.from(this)
|
||||
HealthCheckManager.getInstance(this).reloadConfig()
|
||||
setContent {
|
||||
ShxTheme {
|
||||
SecondaryScaffold(title = stringResource(R.string.auto_connect_title), onBack = { finish() }) { padding ->
|
||||
SecondaryScaffold(title = stringResource(R.string.health_check_title), onBack = { finish() }) { padding ->
|
||||
Box(Modifier.padding(padding)) {
|
||||
TrustedNetworksScreen(profiles = profiles, currentSsid = currentSsid)
|
||||
ConnectivityCheckScreen()
|
||||
}
|
||||
}
|
||||
}
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
/*
|
||||
* Copyright (C) 2026 SHX VPN
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or modify it
|
||||
* under the terms of the GNU General Public License as published by the
|
||||
* Free Software Foundation; either version 2 of the License, or (at your
|
||||
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful, but
|
||||
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
||||
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
|
||||
* for more details.
|
||||
*/
|
||||
|
||||
package org.strongswan.android.ui
|
||||
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.ui.Modifier
|
||||
import org.strongswan.android.ui.compose.DiagnosticsScreen
|
||||
import org.strongswan.android.ui.compose.RouteCheckMode
|
||||
import org.strongswan.android.ui.compose.theme.LocalShxColors
|
||||
import org.strongswan.android.ui.compose.theme.ShxTheme
|
||||
|
||||
/** «Diagnostics», opened from the pulse button on Home and from the alert states. */
|
||||
class DiagnosticsActivity : AppCompatActivity()
|
||||
{
|
||||
override fun onCreate(savedInstanceState: Bundle?)
|
||||
{
|
||||
super.onCreate(savedInstanceState)
|
||||
enableEdgeToEdge()
|
||||
setContent {
|
||||
ShxTheme {
|
||||
Box(Modifier.fillMaxSize().background(LocalShxColors.current.background))
|
||||
{
|
||||
DiagnosticsScreen(
|
||||
onBack = { finish() },
|
||||
onLog = { startActivity(Intent(this@DiagnosticsActivity, LogActivity::class.java)) },
|
||||
onCheckSite = { site -> startActivity(RouteCheckActivity.intent(this@DiagnosticsActivity, RouteCheckMode.SITE, site)) },
|
||||
onCheckApp = { pkg -> startActivity(RouteCheckActivity.intent(this@DiagnosticsActivity, RouteCheckMode.APP, pkg)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
-225
@@ -1,225 +0,0 @@
|
||||
/*
|
||||
* Copyright (C) 2013 Tobias Brunner
|
||||
*
|
||||
* Copyright (C) secunet Security Networks AG
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or modify it
|
||||
* under the terms of the GNU General Public License as published by the
|
||||
* Free Software Foundation; either version 2 of the License, or (at your
|
||||
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful, but
|
||||
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
||||
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
|
||||
* for more details.
|
||||
*/
|
||||
|
||||
package org.strongswan.android.ui;
|
||||
|
||||
import android.app.Service;
|
||||
import android.content.ComponentName;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.content.ServiceConnection;
|
||||
import android.os.Bundle;
|
||||
import android.os.IBinder;
|
||||
import android.view.GestureDetector;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.MotionEvent;
|
||||
import android.view.View;
|
||||
import android.view.View.OnClickListener;
|
||||
import android.view.View.OnTouchListener;
|
||||
import android.view.ViewConfiguration;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.LinearLayout;
|
||||
import android.widget.TextView;
|
||||
|
||||
import org.strongswan.android.R;
|
||||
import org.strongswan.android.logic.VpnStateService;
|
||||
import org.strongswan.android.logic.VpnStateService.VpnStateListener;
|
||||
import org.strongswan.android.logic.imc.ImcState;
|
||||
import org.strongswan.android.logic.imc.RemediationInstruction;
|
||||
|
||||
import java.util.ArrayList;
|
||||
|
||||
import androidx.core.content.ContextCompat;
|
||||
import androidx.fragment.app.Fragment;
|
||||
import androidx.fragment.app.FragmentManager;
|
||||
import androidx.fragment.app.FragmentTransaction;
|
||||
|
||||
public class ImcStateFragment extends Fragment implements VpnStateListener
|
||||
{
|
||||
private int mColorIsolate;
|
||||
private int mColorBlock;
|
||||
private boolean mVisible;
|
||||
private TextView mStateView;
|
||||
private TextView mAction;
|
||||
private LinearLayout mButton;
|
||||
private VpnStateService mService;
|
||||
private final ServiceConnection mServiceConnection = new ServiceConnection()
|
||||
{
|
||||
@Override
|
||||
public void onServiceDisconnected(ComponentName name)
|
||||
{
|
||||
mService = null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onServiceConnected(ComponentName name, IBinder service)
|
||||
{
|
||||
mService = ((VpnStateService.LocalBinder)service).getService();
|
||||
if (mVisible)
|
||||
{
|
||||
mService.registerListener(ImcStateFragment.this);
|
||||
updateView();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@Override
|
||||
public void onCreate(Bundle savedInstanceState)
|
||||
{
|
||||
super.onCreate(savedInstanceState);
|
||||
|
||||
mColorIsolate = ContextCompat.getColor(getActivity(), R.color.warning_text);
|
||||
mColorBlock = ContextCompat.getColor(getActivity(), R.color.error_text);
|
||||
|
||||
/* bind to the service only seems to work from the ApplicationContext */
|
||||
Context context = getActivity().getApplicationContext();
|
||||
context.bindService(new Intent(context, VpnStateService.class),
|
||||
mServiceConnection, Service.BIND_AUTO_CREATE);
|
||||
/* hide it initially */
|
||||
getParentFragmentManager().beginTransaction().hide(this).commit();
|
||||
}
|
||||
|
||||
@Override
|
||||
public View onCreateView(LayoutInflater inflater, ViewGroup container,
|
||||
Bundle savedInstanceState)
|
||||
{
|
||||
View view = inflater.inflate(R.layout.imc_state_fragment, container, false);
|
||||
|
||||
mButton = (LinearLayout)view.findViewById(R.id.imc_state_button);
|
||||
mButton.setOnClickListener(new OnClickListener()
|
||||
{
|
||||
@Override
|
||||
public void onClick(View v)
|
||||
{
|
||||
Intent intent;
|
||||
if (mService != null && !mService.getRemediationInstructions().isEmpty())
|
||||
{
|
||||
intent = new Intent(getActivity(), RemediationInstructionsActivity.class);
|
||||
intent.putParcelableArrayListExtra(RemediationInstructionsFragment.EXTRA_REMEDIATION_INSTRUCTIONS,
|
||||
new ArrayList<RemediationInstruction>(mService.getRemediationInstructions()));
|
||||
}
|
||||
else
|
||||
{
|
||||
intent = new Intent(getActivity(), LogActivity.class);
|
||||
}
|
||||
startActivity(intent);
|
||||
}
|
||||
});
|
||||
final GestureDetector gestures = new GestureDetector(getActivity(), new GestureDetector.SimpleOnGestureListener()
|
||||
{
|
||||
/* a better value would be getScaledTouchExplorationTapSlop() but that is hidden */
|
||||
private final int mMinDistance = ViewConfiguration.get(getActivity()).getScaledTouchSlop() * 4;
|
||||
|
||||
@Override
|
||||
public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY)
|
||||
{
|
||||
if (Math.abs(e1.getX() - e2.getX()) >= mMinDistance)
|
||||
{ /* only if the user swiped a minimum horizontal distance */
|
||||
if (mService != null)
|
||||
{
|
||||
mService.setImcState(ImcState.UNKNOWN);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
});
|
||||
mButton.setOnTouchListener(new OnTouchListener()
|
||||
{
|
||||
@Override
|
||||
public boolean onTouch(View v, MotionEvent event)
|
||||
{
|
||||
return gestures.onTouchEvent(event);
|
||||
}
|
||||
});
|
||||
|
||||
mStateView = (TextView)view.findViewById(R.id.imc_state);
|
||||
mAction = (TextView)view.findViewById(R.id.action);
|
||||
|
||||
return view;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onResume()
|
||||
{
|
||||
super.onResume();
|
||||
mVisible = true;
|
||||
if (mService != null)
|
||||
{
|
||||
mService.registerListener(this);
|
||||
updateView();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onPause()
|
||||
{
|
||||
super.onPause();
|
||||
mVisible = false;
|
||||
if (mService != null)
|
||||
{
|
||||
mService.unregisterListener(this);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDestroy()
|
||||
{
|
||||
super.onDestroy();
|
||||
if (mService != null)
|
||||
{
|
||||
getActivity().getApplicationContext().unbindService(mServiceConnection);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void stateChanged()
|
||||
{
|
||||
updateView();
|
||||
}
|
||||
|
||||
public void updateView()
|
||||
{
|
||||
FragmentManager fm = getParentFragmentManager();
|
||||
if (fm == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
FragmentTransaction ft = fm.beginTransaction();
|
||||
|
||||
switch (mService.getImcState())
|
||||
{
|
||||
case UNKNOWN:
|
||||
case ALLOW:
|
||||
ft.hide(this);
|
||||
break;
|
||||
case ISOLATE:
|
||||
mStateView.setText(R.string.imc_state_isolate);
|
||||
mStateView.setTextColor(mColorIsolate);
|
||||
ft.show(this);
|
||||
break;
|
||||
case BLOCK:
|
||||
mStateView.setText(R.string.imc_state_block);
|
||||
mStateView.setTextColor(mColorBlock);
|
||||
ft.show(this);
|
||||
break;
|
||||
}
|
||||
ft.commit();
|
||||
|
||||
mAction.setText(mService.getRemediationInstructions().isEmpty() ? R.string.show_log
|
||||
: R.string.show_remediation_instructions);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
/*
|
||||
* Copyright (C) 2026 SHX VPN
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or modify it
|
||||
* under the terms of the GNU General Public License as published by the
|
||||
* Free Software Foundation; either version 2 of the License, or (at your
|
||||
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful, but
|
||||
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
||||
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
|
||||
* for more details.
|
||||
*/
|
||||
|
||||
package org.strongswan.android.ui
|
||||
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.provider.Settings
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import org.strongswan.android.logic.events.EventAction
|
||||
import org.strongswan.android.logic.events.EventLog
|
||||
import org.strongswan.android.logic.events.ShxEvent
|
||||
import org.strongswan.android.logic.routing.RoutingManager
|
||||
import org.strongswan.android.ui.compose.InboxScreen
|
||||
import org.strongswan.android.ui.compose.theme.ShxTheme
|
||||
|
||||
/** «Events»: what the app noticed and did by itself, opened from the bell on Home and from problem notifications. */
|
||||
class InboxActivity : AppCompatActivity()
|
||||
{
|
||||
override fun onCreate(savedInstanceState: Bundle?)
|
||||
{
|
||||
super.onCreate(savedInstanceState)
|
||||
enableEdgeToEdge()
|
||||
setContent {
|
||||
ShxTheme {
|
||||
InboxScreen(onBack = { finish() }, onAction = ::perform, onNotificationSettings = ::openNotificationSettings)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onStop()
|
||||
{
|
||||
/* everything shown counts as seen once the user leaves the screen */
|
||||
EventLog.getInstance(this).markAllRead()
|
||||
super.onStop()
|
||||
}
|
||||
|
||||
private fun perform(event: ShxEvent)
|
||||
{
|
||||
val action = event.action
|
||||
if (action == EventAction.SET_APP_DIRECT)
|
||||
{
|
||||
event.target?.let { RoutingManager.getInstance(this).setAppDirect(it, true) }
|
||||
EventLog.getInstance(this).answer(event.id)
|
||||
return
|
||||
}
|
||||
val main = Intent(this, MainActivity::class.java).addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP)
|
||||
when (action)
|
||||
{
|
||||
EventAction.NONE, EventAction.SET_APP_DIRECT -> return
|
||||
EventAction.OPEN_HOME -> startActivity(main)
|
||||
EventAction.OPEN_ROUTING -> startActivity(main.putExtra(MainActivity.EXTRA_OPEN_ROUTING, true))
|
||||
EventAction.INSTALL_UPDATE -> startActivity(main.putExtra(MainActivity.EXTRA_INSTALL_UPDATE, true))
|
||||
EventAction.OPEN_ROUTE_CHECK -> startActivity(Intent(this, RouteCheckActivity::class.java))
|
||||
EventAction.PROFILE_UPDATE -> event.target?.let { startActivity(ServerActivity.intent(this, it, checkUpdate = true)) }
|
||||
EventAction.OPEN_SERVER -> event.target?.let { startActivity(ServerActivity.intent(this, it)) }
|
||||
EventAction.OPEN_LOG -> startActivity(Intent(this, LogActivity::class.java))
|
||||
}
|
||||
if (action == EventAction.OPEN_HOME || action == EventAction.OPEN_ROUTING || action == EventAction.INSTALL_UPDATE)
|
||||
{
|
||||
finish()
|
||||
}
|
||||
}
|
||||
|
||||
private fun openNotificationSettings()
|
||||
{
|
||||
val intent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O)
|
||||
{
|
||||
EventLog.ensureChannel(this)
|
||||
Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS).putExtra(Settings.EXTRA_APP_PACKAGE, packageName)
|
||||
}
|
||||
else
|
||||
{
|
||||
Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, android.net.Uri.fromParts("package", packageName, null))
|
||||
}
|
||||
try
|
||||
{
|
||||
startActivity(intent)
|
||||
}
|
||||
catch (_: ActivityNotFoundException)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -20,11 +20,7 @@ import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Share
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
@@ -35,20 +31,52 @@ import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.repeatOnLifecycle
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.awaitCancellation
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import org.strongswan.android.ui.compose.design.ShxButton
|
||||
import org.strongswan.android.ui.compose.design.ShxChip
|
||||
import org.strongswan.android.ui.compose.design.ShxIcons
|
||||
import org.strongswan.android.ui.compose.design.ShxNote
|
||||
import org.strongswan.android.ui.compose.theme.LocalShxColors
|
||||
import org.strongswan.android.R
|
||||
import org.strongswan.android.data.LogContentProvider
|
||||
import org.strongswan.android.logic.CharonVpnService
|
||||
import org.strongswan.android.logic.DiagLog
|
||||
import org.strongswan.android.ui.compose.SecondaryScaffold
|
||||
import org.strongswan.android.ui.compose.theme.ShxTheme
|
||||
import java.io.BufferedReader
|
||||
@@ -57,23 +85,30 @@ import java.io.FileReader
|
||||
|
||||
class LogActivity : AppCompatActivity()
|
||||
{
|
||||
private val messages = kotlinx.coroutines.flow.MutableSharedFlow<Int>(extraBufferCapacity = 1)
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?)
|
||||
{
|
||||
super.onCreate(savedInstanceState)
|
||||
enableEdgeToEdge()
|
||||
setContent {
|
||||
ShxTheme {
|
||||
LogScreen(onBack = { finish() }, onSend = { sendLog() })
|
||||
LogScreen(onBack = { finish() }, onSend = { sendLog() }, messages = messages)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun sendLog()
|
||||
{
|
||||
val logfile = File(filesDir, CharonVpnService.LOG_FILE)
|
||||
if (!logfile.exists() || logfile.length() == 0L)
|
||||
/* the daemon log plus the lifecycle log; the latter survives process
|
||||
* deaths and documents watchdog/reconnect decisions */
|
||||
val files = listOf(
|
||||
File(filesDir, CharonVpnService.LOG_FILE),
|
||||
DiagLog.file(this),
|
||||
).filter { it.exists() && it.length() > 0L }
|
||||
if (files.isEmpty())
|
||||
{
|
||||
Toast.makeText(this, R.string.empty_log, Toast.LENGTH_SHORT).show()
|
||||
messages.tryEmit(R.string.empty_log)
|
||||
return
|
||||
}
|
||||
var version = ""
|
||||
@@ -84,11 +119,19 @@ class LogActivity : AppCompatActivity()
|
||||
catch (_: PackageManager.NameNotFoundException)
|
||||
{
|
||||
}
|
||||
val intent = Intent(Intent.ACTION_SEND)
|
||||
val uris = ArrayList(files.mapNotNull { file -> LogContentProvider.createContentUri(file) })
|
||||
val intent = Intent(if (uris.size > 1) Intent.ACTION_SEND_MULTIPLE else Intent.ACTION_SEND)
|
||||
intent.putExtra(Intent.EXTRA_EMAIL, arrayOf(MainActivity.CONTACT_EMAIL))
|
||||
intent.putExtra(Intent.EXTRA_SUBJECT, getString(R.string.log_mail_subject, version))
|
||||
intent.type = "text/plain"
|
||||
intent.putExtra(Intent.EXTRA_STREAM, LogContentProvider.createContentUri())
|
||||
if (uris.size > 1)
|
||||
{
|
||||
intent.putExtra(Intent.EXTRA_STREAM, uris)
|
||||
}
|
||||
else
|
||||
{
|
||||
intent.putExtra(Intent.EXTRA_STREAM, uris.first())
|
||||
}
|
||||
startActivity(Intent.createChooser(intent, getString(R.string.send_log)))
|
||||
}
|
||||
}
|
||||
@@ -98,8 +141,15 @@ class LogViewModel : ViewModel()
|
||||
private val _lines = MutableStateFlow<List<String>>(emptyList())
|
||||
val lines: StateFlow<List<String>> = _lines
|
||||
|
||||
/* tail only while the screen is shown (set by LogScreen) */
|
||||
private val active = MutableStateFlow(false)
|
||||
private var started = false
|
||||
|
||||
fun setActive(value: Boolean)
|
||||
{
|
||||
active.value = value
|
||||
}
|
||||
|
||||
fun start(file: File)
|
||||
{
|
||||
if (started) return
|
||||
@@ -107,34 +157,64 @@ class LogViewModel : ViewModel()
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
val buffer = ArrayList<String>()
|
||||
var reader: BufferedReader? = null
|
||||
var lastLength = 0L
|
||||
suspend fun flush()
|
||||
{
|
||||
if (buffer.isEmpty())
|
||||
{
|
||||
return
|
||||
}
|
||||
val batch = buffer.toList()
|
||||
buffer.clear()
|
||||
withContext(Dispatchers.Main) {
|
||||
val merged = _lines.value + batch
|
||||
/* bounded: a long session must not grow the list forever */
|
||||
_lines.value = if (merged.size > MAX_LINES) merged.takeLast(MAX_LINES) else merged
|
||||
}
|
||||
}
|
||||
try
|
||||
{
|
||||
reader = if (file.exists()) BufferedReader(FileReader(file)) else null
|
||||
while (isActive)
|
||||
{
|
||||
val line = reader?.readLine()
|
||||
if (!active.value)
|
||||
{
|
||||
active.first { it }
|
||||
}
|
||||
if (reader == null)
|
||||
{ /* the log may not exist yet (no connection so far) */
|
||||
if (file.exists())
|
||||
{
|
||||
reader = BufferedReader(FileReader(file))
|
||||
lastLength = 0L
|
||||
}
|
||||
else
|
||||
{
|
||||
delay(POLL_MS)
|
||||
continue
|
||||
}
|
||||
}
|
||||
val line = reader.readLine()
|
||||
if (line == null)
|
||||
{
|
||||
if (buffer.isNotEmpty())
|
||||
{
|
||||
val batch = buffer.toList()
|
||||
buffer.clear()
|
||||
withContext(Dispatchers.Main) {
|
||||
_lines.value = _lines.value + batch
|
||||
}
|
||||
flush()
|
||||
val length = if (file.exists()) file.length() else -1L
|
||||
if (length < lastLength)
|
||||
{ /* deleted or truncated (a new connection starts a fresh log):
|
||||
* the reader would stay on the old file forever */
|
||||
try { reader.close() } catch (_: Exception) {}
|
||||
reader = null
|
||||
withContext(Dispatchers.Main) { _lines.value = emptyList() }
|
||||
continue
|
||||
}
|
||||
delay(800)
|
||||
lastLength = length
|
||||
delay(POLL_MS)
|
||||
}
|
||||
else
|
||||
{
|
||||
buffer.add(line)
|
||||
if (buffer.size >= 50)
|
||||
{
|
||||
val batch = buffer.toList()
|
||||
buffer.clear()
|
||||
withContext(Dispatchers.Main) {
|
||||
_lines.value = _lines.value + batch
|
||||
}
|
||||
flush()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -148,63 +228,181 @@ class LogViewModel : ViewModel()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private companion object
|
||||
{
|
||||
const val MAX_LINES = 5000
|
||||
const val POLL_MS = 800L
|
||||
}
|
||||
}
|
||||
|
||||
/* F_Log: «Important» (default), «All», «Errors» */
|
||||
private enum class LogLevelFilter(val labelRes: Int)
|
||||
{
|
||||
IMPORTANT(R.string.log_filter_important),
|
||||
ALL(R.string.log_filter_everything),
|
||||
ERROR(R.string.log_filter_error),
|
||||
}
|
||||
|
||||
private fun String.isErrorLine() = contains(" ERROR ") || contains("failed", ignoreCase = true) ||
|
||||
contains("timeout", ignoreCase = true) || contains("giving up", ignoreCase = true)
|
||||
|
||||
private fun String.isWarnLine() = contains(" WARN ") || contains(" WARNING ") || contains("retransmit", ignoreCase = true)
|
||||
|
||||
private fun String.isGoodLine() = contains("established", ignoreCase = true) || contains("successful", ignoreCase = true) ||
|
||||
contains("installed", ignoreCase = true)
|
||||
|
||||
/* the milestones of a connection: what «Important» keeps besides problems */
|
||||
private val MILESTONES = listOf("initiating", "IKE_SA", "CHILD_SA", "authentication", "established", "deleting", "closing", "reauth", "network")
|
||||
|
||||
private fun String.isImportantLine() = isErrorLine() || isWarnLine() || MILESTONES.any { contains(it, ignoreCase = true) }
|
||||
|
||||
/* «Oct 12 10:21:49 06[IKE] message» → «10:21:49», «message» */
|
||||
private val CHARON_LINE = Regex("""^\w{3}\s+\d+\s+(\d{2}:\d{2}:\d{2})\s+\d+\[\w+]\s?(.*)$""")
|
||||
|
||||
private fun splitLine(line: String): Pair<String, String>
|
||||
{
|
||||
val m = CHARON_LINE.find(line) ?: return "" to line
|
||||
return m.groupValues[1] to m.groupValues[2]
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LogScreen(onBack: () -> Unit, onSend: () -> Unit, viewModel: LogViewModel = viewModel())
|
||||
private fun LogScreen(
|
||||
onBack: () -> Unit,
|
||||
onSend: () -> Unit,
|
||||
messages: kotlinx.coroutines.flow.Flow<Int>,
|
||||
viewModel: LogViewModel = viewModel(),
|
||||
)
|
||||
{
|
||||
val context = LocalContext.current
|
||||
val lines by viewModel.lines.collectAsStateWithLifecycle()
|
||||
val c = LocalShxColors.current
|
||||
val allLines by viewModel.lines.collectAsStateWithLifecycle()
|
||||
val listState = rememberLazyListState()
|
||||
val snackbarHostState = remember { androidx.compose.material3.SnackbarHostState() }
|
||||
var filter by rememberSaveable { mutableStateOf(LogLevelFilter.IMPORTANT) }
|
||||
val lines = remember(allLines, filter) {
|
||||
when (filter)
|
||||
{
|
||||
LogLevelFilter.IMPORTANT -> allLines.filter { it.isImportantLine() }
|
||||
LogLevelFilter.ALL -> allLines
|
||||
LogLevelFilter.ERROR -> allLines.filter { it.isErrorLine() }
|
||||
}
|
||||
}
|
||||
|
||||
val lifecycle = LocalLifecycleOwner.current.lifecycle
|
||||
LaunchedEffect(Unit) {
|
||||
viewModel.start(File(context.filesDir, CharonVpnService.LOG_FILE))
|
||||
}
|
||||
LaunchedEffect(lines.size) {
|
||||
if (lines.isNotEmpty())
|
||||
{
|
||||
listState.scrollToItem(lines.lastIndex)
|
||||
LaunchedEffect(lifecycle) {
|
||||
lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
|
||||
viewModel.setActive(true)
|
||||
try
|
||||
{
|
||||
awaitCancellation()
|
||||
}
|
||||
finally
|
||||
{
|
||||
viewModel.setActive(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
LaunchedEffect(messages) {
|
||||
messages.collect { snackbarHostState.showSnackbar(context.getString(it)) }
|
||||
}
|
||||
/* follow the tail only while the user is at the bottom (or switched the
|
||||
* filter): scrolling up to read must not be undone by every new batch */
|
||||
var followedSize by remember { mutableStateOf(0) }
|
||||
var followedFilter by remember { mutableStateOf(filter) }
|
||||
LaunchedEffect(lines.size, filter) {
|
||||
if (lines.isNotEmpty())
|
||||
{
|
||||
val lastVisible = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: -1
|
||||
if (filter != followedFilter || followedSize == 0 || lastVisible >= followedSize - 2 ||
|
||||
lines.size < followedSize)
|
||||
{
|
||||
listState.scrollToItem(lines.lastIndex)
|
||||
}
|
||||
}
|
||||
followedSize = lines.size
|
||||
followedFilter = filter
|
||||
}
|
||||
val copied = stringResource(R.string.log_copied)
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
SecondaryScaffold(
|
||||
title = stringResource(R.string.log_title),
|
||||
onBack = onBack,
|
||||
actions = {
|
||||
IconButton(onClick = onSend) {
|
||||
Icon(Icons.Default.Share, contentDescription = stringResource(R.string.send_log))
|
||||
/* 44dp tonal circle of the mockup header */
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier.size(44.dp).clip(CircleShape).background(c.tile)
|
||||
.clickable(role = Role.Button) {
|
||||
val clipboard = context.getSystemService(android.content.Context.CLIPBOARD_SERVICE) as android.content.ClipboardManager
|
||||
clipboard.setPrimaryClip(android.content.ClipData.newPlainText("log", lines.joinToString("\n")))
|
||||
scope.launch { snackbarHostState.showSnackbar(copied) }
|
||||
},
|
||||
)
|
||||
{
|
||||
Icon(ShxIcons.Doc, contentDescription = stringResource(R.string.log_copy), tint = c.onAccentContainer, modifier = Modifier.size(20.dp))
|
||||
}
|
||||
},
|
||||
snackbarHost = { org.strongswan.android.ui.compose.components.ShxSnackbarHost(snackbarHostState) },
|
||||
) { padding ->
|
||||
if (lines.isEmpty())
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
modifier = Modifier.fillMaxSize().padding(padding).padding(start = 16.dp, end = 16.dp, bottom = 24.dp),
|
||||
)
|
||||
{
|
||||
Text(
|
||||
stringResource(R.string.empty_log),
|
||||
modifier = Modifier.padding(padding).padding(24.dp),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
else
|
||||
{
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
modifier = Modifier.fillMaxSize().padding(padding).padding(horizontal = 12.dp),
|
||||
) {
|
||||
items(lines.size) { index ->
|
||||
val line = lines[index]
|
||||
val isError = line.contains(" ERROR ", ignoreCase = false)
|
||||
Text(
|
||||
line,
|
||||
style = MaterialTheme.typography.bodySmall.copy(
|
||||
fontSize = 12.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
),
|
||||
color = if (isError) MaterialTheme.colorScheme.error
|
||||
else MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier.padding(vertical = 2.dp),
|
||||
)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp))
|
||||
{
|
||||
LogLevelFilter.entries.forEach { level ->
|
||||
ShxChip(stringResource(level.labelRes), filter == level, { filter = level })
|
||||
}
|
||||
}
|
||||
/* the dark console: grey time, coloured message */
|
||||
Box(Modifier.weight(1f).fillMaxWidth().clip(RoundedCornerShape(22.dp)).background(Color(0xFF1C1B20)))
|
||||
{
|
||||
if (lines.isEmpty())
|
||||
{
|
||||
Text(
|
||||
stringResource(R.string.empty_log), style = LOG_STYLE, color = Color(0xFF79767F),
|
||||
modifier = Modifier.padding(14.dp),
|
||||
)
|
||||
}
|
||||
else
|
||||
{
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
contentPadding = androidx.compose.foundation.layout.PaddingValues(14.dp),
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
) {
|
||||
items(lines.size) { index ->
|
||||
val line = lines[index]
|
||||
val (time, message) = splitLine(line)
|
||||
val color = when
|
||||
{
|
||||
line.isErrorLine() || line.isWarnLine() -> Color(0xFFFFB869)
|
||||
line.isGoodLine() -> Color(0xFF7CE3B0)
|
||||
else -> Color(0xFFE5E1E9)
|
||||
}
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(10.dp))
|
||||
{
|
||||
if (time.isNotEmpty())
|
||||
{
|
||||
Text(time, style = LOG_STYLE, color = Color(0xFF79767F))
|
||||
}
|
||||
Text(message, style = LOG_STYLE, color = color)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
ShxNote(stringResource(R.string.log_report_note))
|
||||
ShxButton(stringResource(R.string.log_send_report), onSend, modifier = Modifier.fillMaxWidth())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val LOG_STYLE = TextStyle(fontFamily = FontFamily.Monospace, fontWeight = FontWeight.Medium, fontSize = 12.sp, lineHeight = 17.sp)
|
||||
@@ -1,295 +0,0 @@
|
||||
/*
|
||||
* Copyright (C) 2012-2018 Tobias Brunner
|
||||
*
|
||||
* Copyright (C) secunet Security Networks AG
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or modify it
|
||||
* under the terms of the GNU General Public License as published by the
|
||||
* Free Software Foundation; either version 2 of the License, or (at your
|
||||
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful, but
|
||||
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
||||
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
|
||||
* for more details.
|
||||
*/
|
||||
|
||||
package org.strongswan.android.ui;
|
||||
|
||||
import android.content.Context;
|
||||
import android.os.Build;
|
||||
import android.os.Bundle;
|
||||
import android.os.FileObserver;
|
||||
import android.os.Handler;
|
||||
import android.os.Looper;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.ArrayAdapter;
|
||||
import android.widget.ListView;
|
||||
|
||||
import org.strongswan.android.R;
|
||||
import org.strongswan.android.logic.CharonVpnService;
|
||||
import org.strongswan.android.utils.Utils;
|
||||
|
||||
import java.io.BufferedReader;
|
||||
import java.io.File;
|
||||
import java.io.FileNotFoundException;
|
||||
import java.io.FileReader;
|
||||
import java.io.StringReader;
|
||||
import java.util.ArrayList;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.RequiresApi;
|
||||
import androidx.fragment.app.Fragment;
|
||||
|
||||
public class LogFragment extends Fragment
|
||||
{
|
||||
private static String SCROLL_POSITION = "SCROLL_POSITION";
|
||||
private String mLogFilePath;
|
||||
private Handler mLogHandler;
|
||||
private ListView mLog;
|
||||
private LogAdapter mLogAdapter;
|
||||
private FileObserver mDirectoryObserver;
|
||||
private int mScrollPosition;
|
||||
|
||||
@Override
|
||||
public void onCreate(Bundle savedInstanceState)
|
||||
{
|
||||
super.onCreate(savedInstanceState);
|
||||
|
||||
mLogFilePath = getActivity().getFilesDir() + File.separator + CharonVpnService.LOG_FILE;
|
||||
|
||||
mLogHandler = new Handler(Looper.getMainLooper());
|
||||
|
||||
File logdir = getActivity().getFilesDir();
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q)
|
||||
{
|
||||
mDirectoryObserver = new LogDirectoryObserver(logdir);
|
||||
}
|
||||
else
|
||||
{
|
||||
mDirectoryObserver = new LogDirectoryObserver(logdir.getAbsolutePath());
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState)
|
||||
{
|
||||
View view = inflater.inflate(R.layout.log_fragment, null);
|
||||
|
||||
mLogAdapter = new LogAdapter(getActivity());
|
||||
mLog = view.findViewById(R.id.log);
|
||||
mLog.setAdapter(mLogAdapter);
|
||||
|
||||
Utils.applyWindowInsetsAsPaddingForLists(mLog);
|
||||
|
||||
mScrollPosition = -1;
|
||||
if (savedInstanceState != null)
|
||||
{
|
||||
mScrollPosition = savedInstanceState.getInt(SCROLL_POSITION, mScrollPosition);
|
||||
}
|
||||
return view;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onSaveInstanceState(Bundle outState)
|
||||
{
|
||||
super.onSaveInstanceState(outState);
|
||||
|
||||
if (mLog.getLastVisiblePosition() == (mLogAdapter.getCount() - 1))
|
||||
{
|
||||
outState.putInt(SCROLL_POSITION, -1);
|
||||
}
|
||||
else
|
||||
{
|
||||
outState.putInt(SCROLL_POSITION, mLog.getFirstVisiblePosition());
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onStart()
|
||||
{
|
||||
super.onStart();
|
||||
mLogAdapter.restart();
|
||||
mDirectoryObserver.startWatching();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onStop()
|
||||
{
|
||||
super.onStop();
|
||||
mDirectoryObserver.stopWatching();
|
||||
mLogAdapter.stop();
|
||||
}
|
||||
|
||||
private class LogAdapter extends ArrayAdapter<String> implements Runnable
|
||||
{
|
||||
private BufferedReader mReader;
|
||||
private Thread mThread;
|
||||
private volatile boolean mRunning;
|
||||
|
||||
public LogAdapter(@NonNull Context context)
|
||||
{
|
||||
super(context, R.layout.log_list_item, R.id.log_line);
|
||||
}
|
||||
|
||||
public void restart()
|
||||
{
|
||||
if (mRunning)
|
||||
{
|
||||
stop();
|
||||
}
|
||||
|
||||
clear();
|
||||
|
||||
try
|
||||
{
|
||||
mReader = new BufferedReader(new FileReader(mLogFilePath));
|
||||
}
|
||||
catch (FileNotFoundException e)
|
||||
{
|
||||
mReader = new BufferedReader(new StringReader(""));
|
||||
}
|
||||
mRunning = true;
|
||||
mThread = new Thread(this);
|
||||
mThread.start();
|
||||
}
|
||||
|
||||
public void stop()
|
||||
{
|
||||
try
|
||||
{
|
||||
mRunning = false;
|
||||
mThread.interrupt();
|
||||
mThread.join();
|
||||
}
|
||||
catch (InterruptedException e)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
private void logLines(final ArrayList<String> lines)
|
||||
{
|
||||
mLogHandler.post(() -> {
|
||||
boolean scroll = getCount() == 0;
|
||||
setNotifyOnChange(false);
|
||||
for (String line : lines)
|
||||
{
|
||||
if (getResources().getConfiguration().screenWidthDp < 600)
|
||||
{ /* strip off prefix (month=3, day=2, time=8, thread=2, spaces=3) */
|
||||
line = line.length() > 18 ? line.substring(18) : line;
|
||||
}
|
||||
add(line);
|
||||
}
|
||||
notifyDataSetChanged();
|
||||
if (scroll)
|
||||
{ /* scroll to the bottom or saved position after adding the first batch */
|
||||
mLogHandler.post(() -> mLog.setSelection(mScrollPosition == -1 ? getCount() - 1 : mScrollPosition));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public void run()
|
||||
{
|
||||
ArrayList<String> lines = null;
|
||||
|
||||
while (mRunning)
|
||||
{
|
||||
try
|
||||
{ /* this works as long as the file is not truncated */
|
||||
String line = mReader.readLine();
|
||||
if (line == null)
|
||||
{
|
||||
if (lines != null)
|
||||
{
|
||||
logLines(lines);
|
||||
lines = null;
|
||||
}
|
||||
/* wait until there is more to log */
|
||||
Thread.sleep(1000);
|
||||
}
|
||||
else
|
||||
{
|
||||
if (lines == null)
|
||||
{
|
||||
lines = new ArrayList<>();
|
||||
}
|
||||
lines.add(line);
|
||||
}
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (lines != null)
|
||||
{
|
||||
logLines(lines);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* FileObserver that checks for changes regarding the log file. Since charon
|
||||
* truncates it (for which there is no explicit event) we check for any modification
|
||||
* to the file, keep track of the file size and reopen it if it got smaller.
|
||||
*/
|
||||
private class LogDirectoryObserver extends FileObserver
|
||||
{
|
||||
private static final int mMask = FileObserver.CREATE | FileObserver.MODIFY | FileObserver.DELETE;
|
||||
private final File mFile = new File(mLogFilePath);
|
||||
private long mSize = mFile.length();
|
||||
|
||||
@SuppressWarnings("deprecation")
|
||||
public LogDirectoryObserver(String path)
|
||||
{
|
||||
super(path, mMask);
|
||||
}
|
||||
|
||||
@RequiresApi(api = Build.VERSION_CODES.Q)
|
||||
public LogDirectoryObserver(File path)
|
||||
{
|
||||
super(path, mMask);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onEvent(int event, String path)
|
||||
{
|
||||
if (path == null || !path.equals(CharonVpnService.LOG_FILE))
|
||||
{
|
||||
return;
|
||||
}
|
||||
switch (event)
|
||||
{ /* even though we only subscribed for these we check them,
|
||||
* as strange events are sometimes received */
|
||||
case FileObserver.CREATE:
|
||||
case FileObserver.DELETE:
|
||||
restartLogReader();
|
||||
break;
|
||||
case FileObserver.MODIFY:
|
||||
/* if the size got smaller reopen the log file, as it was probably truncated */
|
||||
long size = mFile.length();
|
||||
if (size < mSize)
|
||||
{
|
||||
restartLogReader();
|
||||
}
|
||||
mSize = size;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private void restartLogReader()
|
||||
{
|
||||
/* we are called from a separate thread, so we use the handler */
|
||||
mLogHandler.post(new Runnable() {
|
||||
@Override
|
||||
public void run()
|
||||
{
|
||||
mLogAdapter.restart();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
+112
-1
@@ -16,15 +16,52 @@
|
||||
|
||||
package org.strongswan.android.ui;
|
||||
|
||||
import android.content.Intent;
|
||||
import android.os.Bundle;
|
||||
|
||||
import org.strongswan.android.logic.updates.UpdateManager;
|
||||
import org.strongswan.android.ui.compose.ShxAppKt;
|
||||
import org.strongswan.android.ui.compose.ShxNavigation;
|
||||
import org.strongswan.android.ui.compose.ShxTab;
|
||||
import android.service.quicksettings.TileService;
|
||||
|
||||
import androidx.appcompat.app.AppCompatActivity;
|
||||
import androidx.core.splashscreen.SplashScreen;
|
||||
|
||||
public class MainActivity extends AppCompatActivity
|
||||
{
|
||||
public static final String CONTACT_EMAIL = "android@strongswan.org";
|
||||
public static final String CONTACT_EMAIL = "support@shx.one";
|
||||
|
||||
/**
|
||||
* Set as an intent extra by the "update ready" notification; starts the
|
||||
* installation of the already downloaded update
|
||||
*/
|
||||
public static final String EXTRA_INSTALL_UPDATE = "org.strongswan.android.extra.INSTALL_UPDATE";
|
||||
|
||||
/**
|
||||
* Set as an intent extra to open the Routing tab (e.g. from the profile form)
|
||||
*/
|
||||
public static final String EXTRA_OPEN_ROUTING = "org.strongswan.android.extra.OPEN_ROUTING";
|
||||
|
||||
/**
|
||||
* Set as an intent extra to open the «Add server» sheet (end of the first-run screens)
|
||||
*/
|
||||
public static final String EXTRA_ADD_SERVER = "org.strongswan.android.extra.ADD_SERVER";
|
||||
|
||||
/**
|
||||
* Set as an intent extra to open the Servers tab (the «Change» action of the notification)
|
||||
*/
|
||||
public static final String EXTRA_OPEN_SERVERS = "org.strongswan.android.extra.OPEN_SERVERS";
|
||||
|
||||
/**
|
||||
* Set as an intent extra to connect the fastest server (the «Fastest» button of the widget)
|
||||
*/
|
||||
public static final String EXTRA_CONNECT_FASTEST = "org.strongswan.android.extra.CONNECT_FASTEST";
|
||||
|
||||
/**
|
||||
* Set as an intent extra to show the Updates page (the update notification)
|
||||
*/
|
||||
public static final String EXTRA_OPEN_UPDATES = "org.strongswan.android.extra.OPEN_UPDATES";
|
||||
|
||||
/**
|
||||
* Use "bring your own device" (BYOD) features
|
||||
@@ -34,7 +71,81 @@ public class MainActivity extends AppCompatActivity
|
||||
@Override
|
||||
public void onCreate(Bundle savedInstanceState)
|
||||
{
|
||||
/* must run before super.onCreate() so the system splash hands over cleanly */
|
||||
SplashScreen.Companion.installSplashScreen(this);
|
||||
super.onCreate(savedInstanceState);
|
||||
handleInstallUpdate(getIntent());
|
||||
handleOpenRouting(getIntent());
|
||||
handleAddServer(getIntent());
|
||||
ShxAppKt.installShxUi(this);
|
||||
if (savedInstanceState == null && OnboardingActivity.isDue(this))
|
||||
{ /* a fresh install: what the app does and the permissions first */
|
||||
startActivity(new Intent(this, OnboardingActivity.class));
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onResume()
|
||||
{
|
||||
super.onResume();
|
||||
/* catch up on due update checks right away (the periodic check
|
||||
* otherwise waits for the WorkManager tick or the next relaunch) */
|
||||
UpdateManager.getInstance(this).checkIfDue();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onNewIntent(Intent intent)
|
||||
{
|
||||
handleAddServer(intent);
|
||||
super.onNewIntent(intent);
|
||||
handleInstallUpdate(intent);
|
||||
handleOpenRouting(intent);
|
||||
}
|
||||
|
||||
private void handleOpenRouting(Intent intent)
|
||||
{
|
||||
if (intent != null && intent.getBooleanExtra(EXTRA_OPEN_ROUTING, false))
|
||||
{
|
||||
intent.removeExtra(EXTRA_OPEN_ROUTING);
|
||||
ShxNavigation.INSTANCE.request(ShxTab.ROUTING);
|
||||
}
|
||||
}
|
||||
|
||||
private void handleInstallUpdate(Intent intent)
|
||||
{
|
||||
if (intent != null && intent.getBooleanExtra(EXTRA_INSTALL_UPDATE, false))
|
||||
{
|
||||
intent.removeExtra(EXTRA_INSTALL_UPDATE);
|
||||
UpdateManager.getInstance(this).downloadAndInstall();
|
||||
}
|
||||
}
|
||||
|
||||
private void handleAddServer(Intent intent)
|
||||
{
|
||||
if (intent != null && intent.getBooleanExtra(EXTRA_ADD_SERVER, false))
|
||||
{
|
||||
intent.removeExtra(EXTRA_ADD_SERVER);
|
||||
ShxNavigation.INSTANCE.requestAddServer();
|
||||
}
|
||||
if (intent != null && TileService.ACTION_QS_TILE_PREFERENCES.equals(intent.getAction()))
|
||||
{
|
||||
intent.setAction(null);
|
||||
ShxNavigation.INSTANCE.request(ShxTab.PROFILES);
|
||||
}
|
||||
if (intent != null && intent.getBooleanExtra(EXTRA_OPEN_SERVERS, false))
|
||||
{
|
||||
intent.removeExtra(EXTRA_OPEN_SERVERS);
|
||||
ShxNavigation.INSTANCE.request(ShxTab.PROFILES);
|
||||
}
|
||||
if (intent != null && intent.getBooleanExtra(EXTRA_OPEN_UPDATES, false))
|
||||
{
|
||||
intent.removeExtra(EXTRA_OPEN_UPDATES);
|
||||
ShxNavigation.INSTANCE.requestUpdatesPage();
|
||||
}
|
||||
if (intent != null && intent.getBooleanExtra(EXTRA_CONNECT_FASTEST, false))
|
||||
{
|
||||
intent.removeExtra(EXTRA_CONNECT_FASTEST);
|
||||
ShxNavigation.INSTANCE.requestFastest();
|
||||
}
|
||||
}
|
||||
}
|
||||
+232
@@ -0,0 +1,232 @@
|
||||
/*
|
||||
* Copyright (C) 2026 SHX VPN
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or modify it
|
||||
* under the terms of the GNU General Public License as published by the
|
||||
* Free Software Foundation; either version 2 of the License, or (at your
|
||||
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful, but
|
||||
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
||||
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
|
||||
* for more details.
|
||||
*/
|
||||
|
||||
package org.strongswan.android.ui
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.safeDrawing
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.shadow
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.preference.PreferenceManager
|
||||
import org.strongswan.android.R
|
||||
import org.strongswan.android.ui.compose.PermissionsList
|
||||
import org.strongswan.android.ui.compose.design.ShxButton
|
||||
import org.strongswan.android.ui.compose.design.ShxButtonStyle
|
||||
import org.strongswan.android.ui.compose.design.ShxIcons
|
||||
import org.strongswan.android.ui.compose.theme.LocalShxColors
|
||||
import org.strongswan.android.ui.compose.theme.ShxTheme
|
||||
import org.strongswan.android.ui.compose.theme.ShxType
|
||||
|
||||
/**
|
||||
* First run (F_Onboarding → F_Permissions → add a server): what the app
|
||||
* does, the permissions it needs, then Home with the «Add server» sheet.
|
||||
*/
|
||||
class OnboardingActivity : AppCompatActivity()
|
||||
{
|
||||
override fun onCreate(savedInstanceState: Bundle?)
|
||||
{
|
||||
super.onCreate(savedInstanceState)
|
||||
enableEdgeToEdge()
|
||||
setContent {
|
||||
ShxTheme {
|
||||
var step by rememberSaveable { mutableIntStateOf(0) }
|
||||
BackHandler(enabled = step > 0) { step-- }
|
||||
Box(Modifier.fillMaxSize().background(LocalShxColors.current.background).windowInsetsPadding(WindowInsets.safeDrawing))
|
||||
{
|
||||
when (step)
|
||||
{
|
||||
0 -> IntroStep(onStart = { step = 1 }, onSkip = ::finishToHome, onScan = { finishToHome(addServer = true) })
|
||||
else -> PermissionsStep(onContinue = { finishToHome(addServer = true) })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun finishToHome(addServer: Boolean = false)
|
||||
{
|
||||
markDone(this)
|
||||
startActivity(
|
||||
Intent(this, MainActivity::class.java)
|
||||
.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP)
|
||||
.putExtra(MainActivity.EXTRA_ADD_SERVER, addServer)
|
||||
)
|
||||
finish()
|
||||
}
|
||||
|
||||
companion object
|
||||
{
|
||||
private const val PREF_DONE = "pref_onboarding_done"
|
||||
|
||||
/** Whether the first-run screens are still due: a fresh install that has not seen them. */
|
||||
@JvmStatic
|
||||
fun isDue(context: Context): Boolean
|
||||
{
|
||||
val prefs = PreferenceManager.getDefaultSharedPreferences(context)
|
||||
if (prefs.getBoolean(PREF_DONE, false))
|
||||
{
|
||||
return false
|
||||
}
|
||||
/* an update of an existing installation never shows them */
|
||||
val info = runCatching { context.packageManager.getPackageInfo(context.packageName, 0) }.getOrNull()
|
||||
if (info != null && info.firstInstallTime != info.lastUpdateTime)
|
||||
{
|
||||
markDone(context)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun markDone(context: Context) = PreferenceManager.getDefaultSharedPreferences(context).edit().putBoolean(PREF_DONE, true).apply()
|
||||
}
|
||||
}
|
||||
|
||||
/** Step 1: the two blobs with the route bubbles, the promise, Start / Scan QR. */
|
||||
@Composable
|
||||
private fun IntroStep(onStart: () -> Unit, onSkip: () -> Unit, onScan: () -> Unit)
|
||||
{
|
||||
val c = LocalShxColors.current
|
||||
Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(start = 24.dp, end = 24.dp, top = 24.dp, bottom = 28.dp))
|
||||
{
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End)
|
||||
{
|
||||
Text(
|
||||
stringResource(R.string.onb_skip), style = ShxType.body.copy(fontSize = 15.sp, fontWeight = FontWeight.Bold), color = c.textSub,
|
||||
modifier = Modifier.clip(RoundedCornerShape(12.dp)).clickable(role = Role.Button, onClick = onSkip).padding(horizontal = 8.dp, vertical = 12.dp),
|
||||
)
|
||||
}
|
||||
Box(Modifier.fillMaxWidth().height(290.dp).padding(top = 8.dp), contentAlignment = Alignment.Center)
|
||||
{
|
||||
Box(
|
||||
Modifier.size(260.dp).clip(RoundedCornerShape(topStartPercent = 42, topEndPercent = 58, bottomEndPercent = 50, bottomStartPercent = 50))
|
||||
.background(c.accentContainer),
|
||||
)
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier.size(180.dp).clip(RoundedCornerShape(topStartPercent = 55, topEndPercent = 45, bottomEndPercent = 48, bottomStartPercent = 52))
|
||||
.background(c.accent),
|
||||
)
|
||||
{
|
||||
Icon(painterResource(R.drawable.ic_notification), contentDescription = null, tint = Color.White, modifier = Modifier.size(84.dp))
|
||||
}
|
||||
Bubble(stringResource(R.string.onb_bubble_direct), Modifier.align(Alignment.TopStart).offset(x = 10.dp, y = 46.dp))
|
||||
Bubble(stringResource(R.string.onb_bubble_vpn), Modifier.align(Alignment.BottomEnd).offset(x = (-2).dp, y = (-50).dp))
|
||||
}
|
||||
Column(verticalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.padding(top = 20.dp))
|
||||
{
|
||||
Text(stringResource(R.string.onb_step, 1, 3).uppercase(), style = ShxType.section.copy(letterSpacing = androidx.compose.ui.unit.TextUnit(0.06f, androidx.compose.ui.unit.TextUnitType.Em)), color = c.accentText)
|
||||
Text(stringResource(R.string.onb_title), style = ShxType.screenTitle.copy(fontSize = 30.sp, lineHeight = 34.sp), color = c.text)
|
||||
Text(stringResource(R.string.onb_text), style = ShxType.note.copy(fontSize = 16.sp, lineHeight = 24.sp), color = c.textSoft)
|
||||
}
|
||||
Spacer(Modifier.height(32.dp))
|
||||
Dots(0)
|
||||
Spacer(Modifier.height(20.dp))
|
||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp))
|
||||
{
|
||||
ShxButton(stringResource(R.string.onb_start), onStart, modifier = Modifier.fillMaxWidth())
|
||||
ShxButton(stringResource(R.string.add_qr), onScan, style = ShxButtonStyle.SOFT, modifier = Modifier.fillMaxWidth(), icon = ShxIcons.Scan)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Step 2: the permissions list with Continue. */
|
||||
@Composable
|
||||
private fun PermissionsStep(onContinue: () -> Unit)
|
||||
{
|
||||
val c = LocalShxColors.current
|
||||
Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(start = 16.dp, end = 16.dp, top = 24.dp, bottom = 28.dp))
|
||||
{
|
||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.padding(start = 6.dp, end = 6.dp, top = 8.dp, bottom = 18.dp))
|
||||
{
|
||||
Text(stringResource(R.string.onb_step, 2, 3).uppercase(), style = ShxType.section, color = c.accentText)
|
||||
Text(stringResource(R.string.perm_title), style = ShxType.screenTitle.copy(fontSize = 28.sp, lineHeight = 32.sp), color = c.text)
|
||||
Text(stringResource(R.string.perm_intro), style = ShxType.note.copy(fontSize = 15.sp, lineHeight = 22.sp), color = c.textSoft)
|
||||
}
|
||||
PermissionsList()
|
||||
Spacer(Modifier.height(24.dp))
|
||||
ShxButton(stringResource(R.string.onb_continue), onContinue, modifier = Modifier.fillMaxWidth())
|
||||
Text(
|
||||
stringResource(R.string.perm_later), style = ShxType.meta.copy(fontWeight = FontWeight.Normal), color = c.textSub, textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 10.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Bubble(text: String, modifier: Modifier)
|
||||
{
|
||||
val c = LocalShxColors.current
|
||||
Text(
|
||||
text,
|
||||
style = ShxType.chip.copy(fontWeight = FontWeight.ExtraBold),
|
||||
color = c.onAccentContainer,
|
||||
modifier = modifier.shadow(6.dp, RoundedCornerShape(14.dp), ambientColor = c.onAccentContainer.copy(alpha = 0.2f), spotColor = c.onAccentContainer.copy(alpha = 0.2f))
|
||||
.clip(RoundedCornerShape(14.dp)).background(c.card).padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Dots(current: Int)
|
||||
{
|
||||
val c = LocalShxColors.current
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally))
|
||||
{
|
||||
repeat(3) { i ->
|
||||
Box(Modifier.width(if (i == current) 28.dp else 8.dp).height(8.dp).clip(RoundedCornerShape(4.dp)).background(if (i == current) c.accent else c.outline))
|
||||
}
|
||||
}
|
||||
}
|
||||
-141
@@ -1,141 +0,0 @@
|
||||
/*
|
||||
* Copyright (C) 2013-2016 Tobias Brunner
|
||||
*
|
||||
* Copyright (C) secunet Security Networks AG
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or modify it
|
||||
* under the terms of the GNU General Public License as published by the
|
||||
* Free Software Foundation; either version 2 of the License, or (at your
|
||||
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful, but
|
||||
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
||||
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
|
||||
* for more details.
|
||||
*/
|
||||
|
||||
package org.strongswan.android.ui;
|
||||
|
||||
import android.os.Build;
|
||||
import android.os.Bundle;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.ArrayAdapter;
|
||||
import android.widget.FrameLayout;
|
||||
import android.widget.TextView;
|
||||
|
||||
import org.strongswan.android.R;
|
||||
import org.strongswan.android.logic.imc.RemediationInstruction;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.fragment.app.ListFragment;
|
||||
|
||||
public class RemediationInstructionFragment extends ListFragment
|
||||
{
|
||||
public static final String ARG_REMEDIATION_INSTRUCTION = "instruction";
|
||||
private RemediationInstruction mInstruction = null;
|
||||
private TextView mTitle;
|
||||
private TextView mDescription;
|
||||
private TextView mHeader;
|
||||
|
||||
@Override
|
||||
public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState)
|
||||
{
|
||||
/* while the documentation recommends to include "@android:layout/list_content" to retain
|
||||
* the default functionality, this does not actually work with the ListFragment provided by
|
||||
* the support library as it builds the view manually and uses different IDs */
|
||||
View layout = inflater.inflate(R.layout.remediation_instruction, container, false);
|
||||
FrameLayout list = (FrameLayout)layout.findViewById(R.id.list_container);
|
||||
list.addView(super.onCreateView(inflater, list, savedInstanceState));
|
||||
return layout;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState)
|
||||
{
|
||||
super.onViewCreated(view, savedInstanceState);
|
||||
|
||||
if (savedInstanceState != null)
|
||||
{
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU)
|
||||
{
|
||||
mInstruction = getInstructionCompat(savedInstanceState);
|
||||
}
|
||||
else
|
||||
{
|
||||
mInstruction = savedInstanceState.getParcelable(ARG_REMEDIATION_INSTRUCTION, RemediationInstruction.class);
|
||||
}
|
||||
}
|
||||
/* show dividers only between list items */
|
||||
getListView().setHeaderDividersEnabled(false);
|
||||
getListView().setFooterDividersEnabled(false);
|
||||
/* don't show loader while adapter is not set */
|
||||
setListShown(true);
|
||||
mTitle = (TextView)getView().findViewById(R.id.title);
|
||||
mDescription = (TextView)getView().findViewById(R.id.description);
|
||||
mHeader = (TextView)getView().findViewById(R.id.list_header);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onSaveInstanceState(Bundle outState)
|
||||
{
|
||||
super.onSaveInstanceState(outState);
|
||||
outState.putParcelable(ARG_REMEDIATION_INSTRUCTION, mInstruction);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onStart()
|
||||
{
|
||||
super.onStart();
|
||||
|
||||
Bundle args = getArguments();
|
||||
if (args != null)
|
||||
{
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU)
|
||||
{
|
||||
mInstruction = getInstructionCompat(args);
|
||||
}
|
||||
else
|
||||
{
|
||||
mInstruction = args.getParcelable(ARG_REMEDIATION_INSTRUCTION, RemediationInstruction.class);
|
||||
}
|
||||
}
|
||||
updateView(mInstruction);
|
||||
}
|
||||
|
||||
public void updateView(RemediationInstruction instruction)
|
||||
{
|
||||
mInstruction = instruction;
|
||||
if (mInstruction != null)
|
||||
{
|
||||
mTitle.setText(mInstruction.getTitle());
|
||||
mDescription.setText(mInstruction.getDescription());
|
||||
if (mInstruction.getHeader() != null)
|
||||
{
|
||||
mHeader.setText(mInstruction.getHeader());
|
||||
setListAdapter(new ArrayAdapter<String>(getActivity(),
|
||||
android.R.layout.simple_list_item_1, mInstruction.getItems()));
|
||||
}
|
||||
else
|
||||
{
|
||||
mHeader.setText("");
|
||||
setListAdapter(null);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
mTitle.setText("");
|
||||
mDescription.setText("");
|
||||
mHeader.setText("");
|
||||
setListAdapter(null);
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressWarnings("deprecation")
|
||||
private static RemediationInstruction getInstructionCompat(Bundle bundle)
|
||||
{
|
||||
return bundle.getParcelable(ARG_REMEDIATION_INSTRUCTION);
|
||||
}
|
||||
}
|
||||
-107
@@ -1,107 +0,0 @@
|
||||
/*
|
||||
* Copyright (C) 2013 Tobias Brunner
|
||||
*
|
||||
* Copyright (C) secunet Security Networks AG
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or modify it
|
||||
* under the terms of the GNU General Public License as published by the
|
||||
* Free Software Foundation; either version 2 of the License, or (at your
|
||||
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful, but
|
||||
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
||||
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
|
||||
* for more details.
|
||||
*/
|
||||
|
||||
package org.strongswan.android.ui;
|
||||
|
||||
import android.os.Bundle;
|
||||
import androidx.appcompat.app.AppCompatActivity;
|
||||
import androidx.core.view.WindowCompat;
|
||||
|
||||
import android.view.MenuItem;
|
||||
|
||||
import org.strongswan.android.R;
|
||||
import org.strongswan.android.logic.imc.RemediationInstruction;
|
||||
import org.strongswan.android.ui.RemediationInstructionsFragment.OnRemediationInstructionSelectedListener;
|
||||
|
||||
import java.util.ArrayList;
|
||||
|
||||
public class RemediationInstructionsActivity extends AppCompatActivity implements OnRemediationInstructionSelectedListener
|
||||
{
|
||||
@Override
|
||||
protected void onCreate(Bundle savedInstanceState)
|
||||
{
|
||||
super.onCreate(savedInstanceState);
|
||||
setContentView(R.layout.remediation_instructions);
|
||||
WindowCompat.enableEdgeToEdge(getWindow());
|
||||
getSupportActionBar().setDisplayHomeAsUpEnabled(true);
|
||||
|
||||
if (savedInstanceState != null)
|
||||
{ /* only update if we're not restoring */
|
||||
return;
|
||||
}
|
||||
RemediationInstructionsFragment frag = (RemediationInstructionsFragment)getSupportFragmentManager().findFragmentById(R.id.remediation_instructions_fragment);
|
||||
if (frag != null)
|
||||
{ /* two-pane layout, update fragment */
|
||||
Bundle extras = getIntent().getExtras();
|
||||
ArrayList<RemediationInstruction> list = null;
|
||||
if (android.os.Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.TIRAMISU)
|
||||
{
|
||||
list = RemediationInstructionsFragment.getInstructionsCompat(extras);
|
||||
}
|
||||
else
|
||||
{
|
||||
list = extras.getParcelableArrayList(RemediationInstructionsFragment.EXTRA_REMEDIATION_INSTRUCTIONS,
|
||||
RemediationInstruction.class);
|
||||
}
|
||||
frag.updateView(list);
|
||||
}
|
||||
else
|
||||
{ /* one-pane layout, create fragment */
|
||||
frag = new RemediationInstructionsFragment();
|
||||
frag.setArguments(getIntent().getExtras());
|
||||
getSupportFragmentManager().beginTransaction().add(R.id.fragment_container, frag).commit();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onOptionsItemSelected(MenuItem item)
|
||||
{
|
||||
switch (item.getItemId())
|
||||
{
|
||||
case android.R.id.home:
|
||||
/* one-pane layout, pop possible fragment from stack, finish otherwise */
|
||||
if (!getSupportFragmentManager().popBackStackImmediate())
|
||||
{
|
||||
finish();
|
||||
}
|
||||
getSupportActionBar().setTitle(getTitle());
|
||||
return true;
|
||||
default:
|
||||
return super.onOptionsItemSelected(item);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onRemediationInstructionSelected(RemediationInstruction instruction)
|
||||
{
|
||||
RemediationInstructionFragment frag = (RemediationInstructionFragment)getSupportFragmentManager().findFragmentById(R.id.remediation_instruction_fragment);
|
||||
|
||||
if (frag != null)
|
||||
{ /* two-pane layout, update directly */
|
||||
frag.updateView(instruction);
|
||||
}
|
||||
else
|
||||
{ /* one-pane layout, replace fragment */
|
||||
frag = new RemediationInstructionFragment();
|
||||
Bundle args = new Bundle();
|
||||
args.putParcelable(RemediationInstructionFragment.ARG_REMEDIATION_INSTRUCTION, instruction);
|
||||
frag.setArguments(args);
|
||||
|
||||
getSupportFragmentManager().beginTransaction().replace(R.id.fragment_container, frag).addToBackStack(null).commit();
|
||||
getSupportActionBar().setTitle(instruction.getTitle());
|
||||
}
|
||||
}
|
||||
}
|
||||
+236
@@ -0,0 +1,236 @@
|
||||
/*
|
||||
* Copyright (C) 2013 Tobias Brunner
|
||||
*
|
||||
* Copyright (C) secunet Security Networks AG
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or modify it
|
||||
* under the terms of the GNU General Public License as published by the
|
||||
* Free Software Foundation; either version 2 of the License, or (at your
|
||||
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful, but
|
||||
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
||||
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
|
||||
* for more details.
|
||||
*/
|
||||
|
||||
package org.strongswan.android.ui
|
||||
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.slideInHorizontally
|
||||
import androidx.compose.animation.slideOutHorizontally
|
||||
import androidx.compose.animation.togetherWith
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.strongswan.android.R
|
||||
import org.strongswan.android.ui.compose.theme.LocalShxColors
|
||||
import org.strongswan.android.ui.compose.theme.ShxType
|
||||
import org.strongswan.android.ui.compose.design.ShxPageHeader
|
||||
import org.strongswan.android.ui.compose.design.ShxIcons
|
||||
import org.strongswan.android.ui.compose.components.PredictiveBackScale
|
||||
import org.strongswan.android.logic.imc.RemediationInstruction
|
||||
import org.strongswan.android.ui.compose.components.ShxCard
|
||||
import org.strongswan.android.ui.compose.components.ShxEmptyState
|
||||
import org.strongswan.android.ui.compose.components.ShxSectionDivider
|
||||
import org.strongswan.android.ui.compose.theme.ShxSpacing
|
||||
import org.strongswan.android.ui.compose.theme.ShxTheme
|
||||
|
||||
/**
|
||||
* Shows the remediation instructions received from the server after a failed
|
||||
* IMC/IMV compliance check. Single-pane Compose master-detail: the list is
|
||||
* replaced by the selected instruction (with back navigation).
|
||||
*/
|
||||
class RemediationInstructionsActivity : AppCompatActivity()
|
||||
{
|
||||
private var selected by mutableStateOf<RemediationInstruction?>(null)
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?)
|
||||
{
|
||||
super.onCreate(savedInstanceState)
|
||||
enableEdgeToEdge()
|
||||
|
||||
setContent {
|
||||
ShxTheme {
|
||||
Surface(Modifier.fillMaxSize(), color = LocalShxColors.current.background) {
|
||||
RemediationUi(
|
||||
instructions = loadInstructions(),
|
||||
selected = selected,
|
||||
onSelect = { selected = it },
|
||||
onFinish = { finish() },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadInstructions(): ArrayList<RemediationInstruction>
|
||||
{
|
||||
val extras = getIntent().getExtras() ?: return arrayListOf()
|
||||
return if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU)
|
||||
{
|
||||
@Suppress("DEPRECATION")
|
||||
extras.getParcelableArrayList(RemediationInstructionsExtra.KEY) ?: arrayListOf()
|
||||
}
|
||||
else
|
||||
{
|
||||
extras.getParcelableArrayList(RemediationInstructionsExtra.KEY, RemediationInstruction::class.java)
|
||||
?: arrayListOf()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private object RemediationInstructionsExtra
|
||||
{
|
||||
/* former RemediationInstructionsFragment.EXTRA_REMEDIATION_INSTRUCTIONS */
|
||||
const val KEY = "instructions"
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun RemediationUi(
|
||||
instructions: List<RemediationInstruction>,
|
||||
selected: RemediationInstruction?,
|
||||
onSelect: (RemediationInstruction?) -> Unit,
|
||||
onFinish: () -> Unit,
|
||||
)
|
||||
{
|
||||
PredictiveBackScale(enabled = selected != null, onBack = { onSelect(null) }, modifier = Modifier.fillMaxSize())
|
||||
{
|
||||
Column(Modifier.fillMaxSize())
|
||||
{
|
||||
ShxPageHeader(
|
||||
selected?.getTitle() ?: stringResource(R.string.remediation_instructions_title),
|
||||
onBack = { if (selected != null) onSelect(null) else onFinish() },
|
||||
)
|
||||
AnimatedContent(
|
||||
targetState = selected,
|
||||
transitionSpec = {
|
||||
val forward = targetState != null
|
||||
val direction = if (forward) 1 else -1
|
||||
(slideInHorizontally(tween(240)) { it / 4 * direction } + fadeIn(tween(240))) togetherWith
|
||||
(slideOutHorizontally(tween(180)) { -it / 4 * direction } + fadeOut(tween(160)))
|
||||
},
|
||||
label = "remediationPane",
|
||||
) { instruction ->
|
||||
if (instruction == null)
|
||||
{
|
||||
if (instructions.isEmpty())
|
||||
{
|
||||
ShxEmptyState(
|
||||
icon = ShxIcons.Check,
|
||||
message = stringResource(R.string.remediation_instructions_title),
|
||||
modifier = Modifier.padding(ShxSpacing.lg),
|
||||
)
|
||||
}
|
||||
else
|
||||
{
|
||||
LazyColumn(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
) {
|
||||
itemsIndexed(instructions) { index, item ->
|
||||
ShxCard(
|
||||
modifier = Modifier
|
||||
.padding(horizontal = ShxSpacing.lg, vertical = 6.dp)
|
||||
.animateItem(),
|
||||
) {
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { onSelect(item) }
|
||||
.padding(ShxSpacing.lg),
|
||||
) {
|
||||
Text(item.getTitle(), style = ShxType.cardTitle)
|
||||
if (!item.getDescription().isNullOrEmpty())
|
||||
{
|
||||
Spacer(Modifier.height(ShxSpacing.xs))
|
||||
Text(
|
||||
item.getDescription(),
|
||||
style = ShxType.note,
|
||||
color = LocalShxColors.current.textSub,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = ShxSpacing.lg),
|
||||
) {
|
||||
Text(
|
||||
instruction.getDescription(),
|
||||
style = ShxType.note,
|
||||
modifier = Modifier.padding(top = ShxSpacing.sm),
|
||||
)
|
||||
if (!instruction.getHeader().isNullOrEmpty())
|
||||
{
|
||||
Text(
|
||||
instruction.getHeader(),
|
||||
style = ShxType.section,
|
||||
color = LocalShxColors.current.textSub,
|
||||
modifier = Modifier.padding(top = 20.dp, bottom = ShxSpacing.sm),
|
||||
)
|
||||
ShxCard {
|
||||
Column(Modifier.padding(vertical = ShxSpacing.xs)) {
|
||||
instruction.getItems().forEachIndexed { index, item ->
|
||||
Text(
|
||||
item,
|
||||
style = ShxType.body,
|
||||
modifier = Modifier.padding(horizontal = ShxSpacing.lg, vertical = 10.dp),
|
||||
)
|
||||
if (index < instruction.getItems().lastIndex)
|
||||
{
|
||||
ShxSectionDivider(indent = 16.dp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(ShxSpacing.xl))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
-147
@@ -1,147 +0,0 @@
|
||||
/*
|
||||
* Copyright (C) 2013 Tobias Brunner
|
||||
*
|
||||
* Copyright (C) secunet Security Networks AG
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or modify it
|
||||
* under the terms of the GNU General Public License as published by the
|
||||
* Free Software Foundation; either version 2 of the License, or (at your
|
||||
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful, but
|
||||
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
||||
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
|
||||
* for more details.
|
||||
*/
|
||||
|
||||
package org.strongswan.android.ui;
|
||||
|
||||
import android.content.Context;
|
||||
import android.os.Build;
|
||||
import android.os.Bundle;
|
||||
import android.view.View;
|
||||
import android.widget.ListView;
|
||||
|
||||
import org.strongswan.android.R;
|
||||
import org.strongswan.android.logic.imc.RemediationInstruction;
|
||||
import org.strongswan.android.ui.adapter.RemediationInstructionAdapter;
|
||||
|
||||
import java.util.ArrayList;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.fragment.app.ListFragment;
|
||||
|
||||
public class RemediationInstructionsFragment extends ListFragment
|
||||
{
|
||||
public static final String EXTRA_REMEDIATION_INSTRUCTIONS = "instructions";
|
||||
private static final String KEY_POSITION = "position";
|
||||
private ArrayList<RemediationInstruction> mInstructions = null;
|
||||
private OnRemediationInstructionSelectedListener mListener;
|
||||
private RemediationInstructionAdapter mAdapter;
|
||||
private int mCurrentPosition = -1;
|
||||
|
||||
/**
|
||||
* The activity containing this fragment should implement this interface
|
||||
*/
|
||||
public interface OnRemediationInstructionSelectedListener
|
||||
{
|
||||
public void onRemediationInstructionSelected(RemediationInstruction instruction);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState)
|
||||
{
|
||||
super.onViewCreated(view, savedInstanceState);
|
||||
|
||||
if (savedInstanceState != null)
|
||||
{
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU)
|
||||
{
|
||||
mInstructions = getInstructionsCompat(savedInstanceState);
|
||||
}
|
||||
else
|
||||
{
|
||||
mInstructions = savedInstanceState.getParcelableArrayList(EXTRA_REMEDIATION_INSTRUCTIONS, RemediationInstruction.class);
|
||||
}
|
||||
mCurrentPosition = savedInstanceState.getInt(KEY_POSITION);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onSaveInstanceState(Bundle outState)
|
||||
{
|
||||
super.onSaveInstanceState(outState);
|
||||
outState.putParcelableArrayList(RemediationInstructionsFragment.EXTRA_REMEDIATION_INSTRUCTIONS, mInstructions);
|
||||
outState.putInt(KEY_POSITION, mCurrentPosition);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onAttach(Context context)
|
||||
{
|
||||
super.onAttach(context);
|
||||
|
||||
if (context instanceof OnRemediationInstructionSelectedListener)
|
||||
{
|
||||
mListener = (OnRemediationInstructionSelectedListener)context;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onStart()
|
||||
{
|
||||
super.onStart();
|
||||
|
||||
boolean two_pane = getParentFragmentManager().findFragmentById(R.id.remediation_instruction_fragment) != null;
|
||||
if (two_pane)
|
||||
{ /* two-pane layout, make list items selectable */
|
||||
getListView().setChoiceMode(ListView.CHOICE_MODE_SINGLE);
|
||||
}
|
||||
|
||||
Bundle args = getArguments();
|
||||
if (mInstructions == null && args != null)
|
||||
{
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU)
|
||||
{
|
||||
mInstructions = getInstructionsCompat(args);
|
||||
}
|
||||
else
|
||||
{
|
||||
mInstructions = args.getParcelableArrayList(EXTRA_REMEDIATION_INSTRUCTIONS, RemediationInstruction.class);
|
||||
}
|
||||
}
|
||||
updateView(mInstructions);
|
||||
|
||||
if (two_pane && mCurrentPosition == -1 && mInstructions.size() > 0)
|
||||
{ /* two-pane layout, select first instruction */
|
||||
mCurrentPosition = 0;
|
||||
mListener.onRemediationInstructionSelected(mInstructions.get(0));
|
||||
}
|
||||
getListView().setItemChecked(mCurrentPosition, true);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onListItemClick(ListView l, View v, int position, long id)
|
||||
{
|
||||
mCurrentPosition = position;
|
||||
mListener.onRemediationInstructionSelected(mInstructions.get(position));
|
||||
getListView().setItemChecked(position, true);
|
||||
}
|
||||
|
||||
public void updateView(ArrayList<RemediationInstruction> instructions)
|
||||
{
|
||||
if (mAdapter == null)
|
||||
{
|
||||
mAdapter = new RemediationInstructionAdapter(getActivity());
|
||||
setListAdapter(mAdapter);
|
||||
}
|
||||
mInstructions = instructions;
|
||||
mAdapter.setData(mInstructions);
|
||||
}
|
||||
|
||||
@SuppressWarnings("deprecation")
|
||||
public static ArrayList<RemediationInstruction> getInstructionsCompat(Bundle bundle)
|
||||
{
|
||||
return bundle.getParcelableArrayList(RemediationInstructionsFragment.EXTRA_REMEDIATION_INSTRUCTIONS);
|
||||
}
|
||||
}
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
/*
|
||||
* Copyright (C) 2026 SHX VPN
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or modify it
|
||||
* under the terms of the GNU General Public License as published by the
|
||||
* Free Software Foundation; either version 2 of the License, or (at your
|
||||
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful, but
|
||||
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
||||
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
|
||||
* for more details.
|
||||
*/
|
||||
|
||||
package org.strongswan.android.ui
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.ui.Modifier
|
||||
import org.strongswan.android.ui.compose.RouteCheckMode
|
||||
import org.strongswan.android.ui.compose.RouteCheckScreen
|
||||
import org.strongswan.android.ui.compose.theme.LocalShxColors
|
||||
import org.strongswan.android.ui.compose.theme.ShxTheme
|
||||
|
||||
/** «How traffic goes»: route of a site, an address or an app. */
|
||||
class RouteCheckActivity : AppCompatActivity()
|
||||
{
|
||||
override fun onCreate(savedInstanceState: Bundle?)
|
||||
{
|
||||
super.onCreate(savedInstanceState)
|
||||
enableEdgeToEdge()
|
||||
val mode = RouteCheckMode.entries.getOrElse(intent.getIntExtra(EXTRA_MODE, 0)) { RouteCheckMode.SITE }
|
||||
val query = intent.getStringExtra(EXTRA_QUERY).orEmpty()
|
||||
setContent {
|
||||
ShxTheme {
|
||||
Box(Modifier.fillMaxSize().background(LocalShxColors.current.background))
|
||||
{
|
||||
RouteCheckScreen(onBack = { finish() }, initialMode = mode, initialQuery = query)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object
|
||||
{
|
||||
private const val EXTRA_MODE = "mode"
|
||||
private const val EXTRA_QUERY = "query"
|
||||
|
||||
fun intent(context: Context, mode: RouteCheckMode, query: String): Intent =
|
||||
Intent(context, RouteCheckActivity::class.java).putExtra(EXTRA_MODE, mode.ordinal).putExtra(EXTRA_QUERY, query)
|
||||
}
|
||||
}
|
||||
+737
@@ -0,0 +1,737 @@
|
||||
/*
|
||||
* Copyright (C) 2026 SHX VPN
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or modify it
|
||||
* under the terms of the GNU General Public License as published by the
|
||||
* Free Software Foundation; either version 2 of the License, or (at your
|
||||
* option) any later version.
|
||||
*/
|
||||
|
||||
package org.strongswan.android.ui
|
||||
|
||||
import android.Manifest
|
||||
import android.app.TimePickerDialog
|
||||
import android.app.usage.UsageStatsManager
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.os.Bundle
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.statusBarsPadding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.CheckboxDefaults
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.shadow
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.graphics.drawable.toBitmap
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.strongswan.android.R
|
||||
import org.strongswan.android.data.VpnProfile
|
||||
import org.strongswan.android.data.VpnProfileSource
|
||||
import org.strongswan.android.logic.autoconnect.AutoConnectManager
|
||||
import org.strongswan.android.logic.autoconnect.SsidReader
|
||||
import org.strongswan.android.logic.routing.AppTraffic
|
||||
import org.strongswan.android.logic.scenarios.Scenario
|
||||
import org.strongswan.android.logic.scenarios.ScenarioAction
|
||||
import org.strongswan.android.logic.scenarios.ScenarioEngine
|
||||
import org.strongswan.android.logic.scenarios.ScenarioTrigger
|
||||
import org.strongswan.android.ui.compose.design.ShxButton
|
||||
import org.strongswan.android.ui.compose.design.ShxButtonStyle
|
||||
import org.strongswan.android.ui.compose.design.ShxFormField
|
||||
import org.strongswan.android.ui.compose.design.ShxIcons
|
||||
import org.strongswan.android.ui.compose.design.ShxPageHeader
|
||||
import org.strongswan.android.ui.compose.design.ShxPanel
|
||||
import org.strongswan.android.ui.compose.design.ShxSectionHeader
|
||||
import org.strongswan.android.ui.compose.design.ShxSheet
|
||||
import org.strongswan.android.ui.compose.design.ShxSwitch
|
||||
import org.strongswan.android.ui.compose.theme.LocalShxColors
|
||||
import org.strongswan.android.ui.compose.theme.ShxTheme
|
||||
import org.strongswan.android.ui.compose.theme.ShxType
|
||||
import org.strongswan.android.utils.Constants
|
||||
|
||||
/** «Scenarios» (F_Automation): the built-in network rules, the user scenarios, trusted networks. */
|
||||
class ScenariosActivity : AppCompatActivity()
|
||||
{
|
||||
override fun onCreate(savedInstanceState: Bundle?)
|
||||
{
|
||||
super.onCreate(savedInstanceState)
|
||||
enableEdgeToEdge()
|
||||
setContent {
|
||||
ShxTheme {
|
||||
ScenariosScreen(onBack = { finish() })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadProfiles(context: Context): List<VpnProfile>
|
||||
{
|
||||
val source = VpnProfileSource(context)
|
||||
source.open()
|
||||
try
|
||||
{
|
||||
return source.allVpnProfiles
|
||||
}
|
||||
finally
|
||||
{
|
||||
source.close()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ScenariosScreen(onBack: () -> Unit)
|
||||
{
|
||||
val c = LocalShxColors.current
|
||||
val context = LocalContext.current
|
||||
val auto = remember { AutoConnectManager.getInstance(context) }
|
||||
var config by remember { mutableStateOf(auto.currentConfig) }
|
||||
val scenarios by remember { ScenarioEngine.flow(context) }.collectAsStateWithLifecycle()
|
||||
var editing by remember { mutableStateOf<Scenario?>(null) }
|
||||
var pickRulesServer by remember { mutableStateOf(false) }
|
||||
val orderChanges by ScenarioEngine.orderChanges.collectAsStateWithLifecycle()
|
||||
var creating by remember { mutableStateOf(false) }
|
||||
var resumes by remember { mutableIntStateOf(0) }
|
||||
val owner = LocalLifecycleOwner.current
|
||||
DisposableEffect(owner) {
|
||||
val observer = LifecycleEventObserver { _, event -> if (event == Lifecycle.Event.ON_RESUME) { resumes++; config = auto.currentConfig } }
|
||||
owner.lifecycle.addObserver(observer)
|
||||
onDispose { owner.lifecycle.removeObserver(observer) }
|
||||
}
|
||||
val profiles by produceState(emptyList<VpnProfile>(), resumes) { value = withContext(Dispatchers.IO) { loadProfiles(context) } }
|
||||
|
||||
fun updateAuto(transform: (AutoConnectManager.Config) -> AutoConnectManager.Config)
|
||||
{
|
||||
auto.updateConfig { current ->
|
||||
var next = transform(current)
|
||||
next = next.copy(enabled = next.parkOnTrusted || next.connectOnUntrusted)
|
||||
if (next.enabled && next.profileUuid == null)
|
||||
{ /* the rules need a server: the last used, else the first */
|
||||
val mru = androidx.preference.PreferenceManager.getDefaultSharedPreferences(context).getString(Constants.PREF_MRU_VPN_PROFILE, null)
|
||||
next = next.copy(profileUuid = mru ?: profiles.firstOrNull()?.getUUID()?.toString())
|
||||
}
|
||||
next
|
||||
}
|
||||
config = auto.currentConfig
|
||||
}
|
||||
/* «+ Add current network» needs the location permission to read the SSID */
|
||||
var pendingAdd by remember { mutableStateOf(false) }
|
||||
fun addCurrentNetwork()
|
||||
{
|
||||
val ssid = SsidReader.from(context)
|
||||
if (ssid != null)
|
||||
{
|
||||
updateAuto { it.copy(trustedSsids = it.trustedSsids + ssid) }
|
||||
}
|
||||
else
|
||||
{
|
||||
android.widget.Toast.makeText(context, R.string.scenarios_no_wifi, android.widget.Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
val permission = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) {
|
||||
if (pendingAdd)
|
||||
{
|
||||
pendingAdd = false
|
||||
addCurrentNetwork()
|
||||
}
|
||||
}
|
||||
|
||||
Box(Modifier.fillMaxSize().background(c.background))
|
||||
{
|
||||
Column(Modifier.fillMaxSize().statusBarsPadding().verticalScroll(rememberScrollState()).navigationBarsPadding().padding(bottom = 104.dp))
|
||||
{
|
||||
ShxPageHeader(stringResource(R.string.scenarios_title), onBack = onBack)
|
||||
Column(Modifier.padding(horizontal = 16.dp), verticalArrangement = Arrangement.spacedBy(10.dp))
|
||||
{
|
||||
Text(
|
||||
stringResource(R.string.scenarios_intro), style = ShxType.note.copy(fontSize = 14.sp, lineHeight = 20.sp, fontWeight = FontWeight.Normal),
|
||||
color = c.textSoft, modifier = Modifier.padding(start = 4.dp, end = 4.dp, bottom = 4.dp),
|
||||
)
|
||||
/* one order for the network rules and the scenarios: the top one that applies wins */
|
||||
val order = remember(scenarios, orderChanges) { ScenarioEngine.order(context) }
|
||||
order.forEach { id ->
|
||||
when (id)
|
||||
{
|
||||
org.strongswan.android.logic.scenarios.BUILTIN_TRUSTED ->
|
||||
RuleCard(ShxIcons.Wifi, c.accentContainer, c.onAccentContainer, stringResource(R.string.scenarios_rule_trusted), stringResource(R.string.scenario_do_off), config.enabled && config.parkOnTrusted, null)
|
||||
{ on -> updateAuto { it.copy(parkOnTrusted = on, connectOnUntrusted = if (on && !it.enabled) true else it.connectOnUntrusted) } }
|
||||
org.strongswan.android.logic.scenarios.BUILTIN_UNTRUSTED ->
|
||||
RuleCard(ShxIcons.Phone, c.accentContainer, c.onAccentContainer, stringResource(R.string.scenarios_rule_untrusted), stringResource(R.string.scenario_do_on), config.enabled && config.connectOnUntrusted, null)
|
||||
{ on -> updateAuto { it.copy(connectOnUntrusted = on, parkOnTrusted = if (on && !it.enabled) true else it.parkOnTrusted) } }
|
||||
else -> scenarios.firstOrNull { it.id == id }?.let { s ->
|
||||
val (icon, bg, fg) = triggerLook(s.trigger)
|
||||
RuleCard(icon, bg, fg, ScenarioEngine.describeWhen(context, s), stringResource(ScenarioEngine.actionLabel(s.action)), s.enabled, onClick = { editing = s })
|
||||
{ on -> ScenarioEngine.save(context, scenarios.map { if (it.id == s.id) it.copy(enabled = on) else it }) }
|
||||
}
|
||||
}
|
||||
}
|
||||
/* the server the two network rules connect (was the old «Trusted networks» screen) */
|
||||
if (config.enabled)
|
||||
{
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(18.dp)).background(c.card).border(1.dp, c.cardBorder, RoundedCornerShape(18.dp))
|
||||
.clickable(role = Role.Button) { pickRulesServer = true }.padding(horizontal = 14.dp, vertical = 12.dp),
|
||||
)
|
||||
{
|
||||
Text(stringResource(R.string.scenarios_rules_server), style = ShxType.body.copy(fontWeight = FontWeight.Bold), color = c.text, modifier = Modifier.weight(1f))
|
||||
Text(
|
||||
profiles.firstOrNull { it.getUUID().toString() == config.profileUuid }?.let { it.name ?: it.gateway } ?: stringResource(R.string.scenario_server_auto),
|
||||
style = ShxType.body, color = c.textSub, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f, fill = false),
|
||||
)
|
||||
Icon(ShxIcons.Chevron, contentDescription = null, tint = c.textSub, modifier = Modifier.size(18.dp))
|
||||
}
|
||||
}
|
||||
ShxSectionHeader(stringResource(R.string.scenarios_trusted).uppercase(), Modifier.padding(start = 6.dp, end = 6.dp, top = 10.dp))
|
||||
ShxPanel()
|
||||
{
|
||||
config.trustedSsids.sorted().forEach { ssid ->
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.fillMaxWidth().height(56.dp).padding(start = 16.dp, end = 8.dp))
|
||||
{
|
||||
Icon(ShxIcons.Wifi, contentDescription = null, tint = c.onAccentContainer, modifier = Modifier.size(20.dp))
|
||||
Text(ssid, style = ShxType.rowTitle.copy(fontSize = 15.sp), color = c.text, modifier = Modifier.weight(1f))
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier.size(40.dp).clip(CircleShape).clickable(role = Role.Button) { updateAuto { it.copy(trustedSsids = it.trustedSsids - ssid) } },
|
||||
)
|
||||
{
|
||||
Icon(ShxIcons.Close, contentDescription = stringResource(R.string.scenarios_remove_net), tint = c.iconMuted, modifier = Modifier.size(18.dp))
|
||||
}
|
||||
}
|
||||
Box(Modifier.fillMaxWidth().height(1.dp).background(c.tile))
|
||||
}
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier.fillMaxWidth().height(56.dp).clickable(role = Role.Button) {
|
||||
if (ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) != PackageManager.PERMISSION_GRANTED)
|
||||
{
|
||||
pendingAdd = true
|
||||
permission.launch(Manifest.permission.ACCESS_FINE_LOCATION)
|
||||
}
|
||||
else addCurrentNetwork()
|
||||
},
|
||||
)
|
||||
{
|
||||
Text(stringResource(R.string.scenarios_add_current), style = ShxType.body.copy(fontSize = 15.sp, fontWeight = FontWeight.Bold), color = c.accentText)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
/* «+ Scenario» */
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier.align(Alignment.BottomEnd).navigationBarsPadding().padding(end = 16.dp, bottom = 24.dp).height(60.dp)
|
||||
.shadow(10.dp, RoundedCornerShape(20.dp), ambientColor = c.accent, spotColor = c.accent)
|
||||
.clip(RoundedCornerShape(20.dp)).background(c.accent).clickable(role = Role.Button) { creating = true }.padding(horizontal = 22.dp),
|
||||
)
|
||||
{
|
||||
Text(stringResource(R.string.scenarios_add), style = ShxType.button.copy(fontSize = 16.sp), color = Color.White)
|
||||
}
|
||||
}
|
||||
|
||||
if (pickRulesServer)
|
||||
{
|
||||
ShxSheet(onDismiss = { pickRulesServer = false })
|
||||
{
|
||||
Text(stringResource(R.string.scenarios_rules_server_pick), style = ShxType.note.copy(fontWeight = FontWeight.Normal, lineHeight = 19.sp), color = c.textSoft, modifier = Modifier.padding(horizontal = 4.dp))
|
||||
ShxPanel()
|
||||
{
|
||||
profiles.forEachIndexed { i, p ->
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth().heightIn(min = 56.dp).clickable {
|
||||
updateAuto { it.copy(profileUuid = p.getUUID().toString()) }
|
||||
pickRulesServer = false
|
||||
}.padding(horizontal = 16.dp),
|
||||
)
|
||||
{
|
||||
Text(p.name ?: p.gateway, style = ShxType.rowTitle.copy(fontSize = 15.sp), color = c.text, modifier = Modifier.weight(1f))
|
||||
if (config.profileUuid == p.getUUID().toString())
|
||||
{
|
||||
Icon(ShxIcons.Check, contentDescription = null, tint = c.accent, modifier = Modifier.size(20.dp))
|
||||
}
|
||||
}
|
||||
if (i < profiles.lastIndex)
|
||||
{
|
||||
Box(Modifier.fillMaxWidth().height(1.dp).background(c.tile))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (creating || editing != null)
|
||||
{
|
||||
ScenarioSheet(
|
||||
initial = editing,
|
||||
profiles = profiles,
|
||||
onDismiss = { creating = false; editing = null },
|
||||
onSave = { saved ->
|
||||
val list = if (scenarios.any { it.id == saved.id }) scenarios.map { if (it.id == saved.id) saved else it } else scenarios + saved
|
||||
ScenarioEngine.save(context, list)
|
||||
creating = false
|
||||
editing = null
|
||||
},
|
||||
onDelete = editing?.let { e -> { ScenarioEngine.save(context, scenarios.filter { it.id != e.id }); editing = null } },
|
||||
onMove = editing?.let { e ->
|
||||
{ delta: Int ->
|
||||
ScenarioEngine.move(context, e.id, delta)
|
||||
editing = null
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun triggerLook(trigger: ScenarioTrigger): Triple<ImageVector, Color, Color>
|
||||
{
|
||||
val c = LocalShxColors.current
|
||||
return when (trigger)
|
||||
{
|
||||
ScenarioTrigger.WIFI -> Triple(ShxIcons.Wifi, c.accentContainer, c.onAccentContainer)
|
||||
ScenarioTrigger.MOBILE -> Triple(ShxIcons.Phone, c.accentContainer, c.onAccentContainer)
|
||||
ScenarioTrigger.APP -> Triple(ShxIcons.Apps, c.warningStrong, c.onWarning)
|
||||
ScenarioTrigger.TIME -> Triple(ShxIcons.Clock, c.tile, c.textSoft)
|
||||
ScenarioTrigger.ROAMING -> Triple(ShxIcons.Globe, c.accentContainer, c.onAccentContainer)
|
||||
ScenarioTrigger.WHITELIST -> Triple(ShxIcons.Alert, c.warningStrong, c.onWarning)
|
||||
}
|
||||
}
|
||||
|
||||
/** One rule of F_Automation: icon well, «when», «→ then», switch. */
|
||||
@Composable
|
||||
private fun RuleCard(icon: ImageVector, bg: Color, fg: Color, whenText: String, thenText: String, on: Boolean, onClick: (() -> Unit)?, onToggle: (Boolean) -> Unit)
|
||||
{
|
||||
val c = LocalShxColors.current
|
||||
val shape = RoundedCornerShape(24.dp)
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
modifier = Modifier.fillMaxWidth().clip(shape).background(c.card).border(1.dp, c.cardBorder, shape)
|
||||
.then(if (onClick != null) Modifier.clickable(role = Role.Button, onClick = onClick) else Modifier).padding(14.dp),
|
||||
)
|
||||
{
|
||||
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(44.dp).clip(RoundedCornerShape(14.dp)).background(bg))
|
||||
{
|
||||
Icon(icon, contentDescription = null, tint = fg, modifier = Modifier.size(22.dp))
|
||||
}
|
||||
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp))
|
||||
{
|
||||
Text(whenText, style = ShxType.sub.copy(fontWeight = FontWeight.SemiBold), color = c.textSub, maxLines = 2, overflow = TextOverflow.Ellipsis)
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp))
|
||||
{
|
||||
Icon(ShxIcons.Chevron, contentDescription = null, tint = c.accent, modifier = Modifier.size(16.dp))
|
||||
Text(thenText, style = ShxType.cardTitle, color = c.text)
|
||||
}
|
||||
}
|
||||
ShxSwitch(on, onToggle)
|
||||
}
|
||||
}
|
||||
|
||||
/** «New scenario» (F_ScenarioAdd): when, what, server, the summary. */
|
||||
@Composable
|
||||
private fun ScenarioSheet(
|
||||
initial: Scenario?,
|
||||
profiles: List<VpnProfile>,
|
||||
onDismiss: () -> Unit,
|
||||
onSave: (Scenario) -> Unit,
|
||||
onDelete: (() -> Unit)?,
|
||||
onMove: ((Int) -> Unit)?,
|
||||
)
|
||||
{
|
||||
val c = LocalShxColors.current
|
||||
val context = LocalContext.current
|
||||
var trigger by remember { mutableStateOf(initial?.trigger ?: ScenarioTrigger.APP) }
|
||||
var action by remember { mutableStateOf(initial?.action ?: ScenarioAction.VPN_ON) }
|
||||
var packages by remember { mutableStateOf(initial?.packages ?: emptySet()) }
|
||||
var ssids by remember { mutableStateOf(initial?.ssids?.joinToString(", ") ?: SsidReader.from(context).orEmpty()) }
|
||||
var start by remember { mutableIntStateOf(initial?.startMinute ?: 60) }
|
||||
var end by remember { mutableIntStateOf(initial?.endMinute ?: 7 * 60) }
|
||||
var profile by remember { mutableStateOf(initial?.profileUuid) }
|
||||
var picking by remember { mutableStateOf(false) }
|
||||
var pickProfile by remember { mutableStateOf(false) }
|
||||
val draft = Scenario(
|
||||
id = initial?.id ?: java.util.UUID.randomUUID().toString(),
|
||||
enabled = initial?.enabled ?: true,
|
||||
trigger = trigger,
|
||||
ssids = ssids.split(',').map { it.trim() }.filter { it.isNotEmpty() }.toSet(),
|
||||
packages = packages,
|
||||
startMinute = start,
|
||||
endMinute = end,
|
||||
action = action,
|
||||
profileUuid = profile?.takeIf { action == ScenarioAction.VPN_ON || action == ScenarioAction.SWITCH_SERVER },
|
||||
)
|
||||
val valid = when (trigger)
|
||||
{
|
||||
ScenarioTrigger.APP -> packages.isNotEmpty()
|
||||
ScenarioTrigger.TIME -> start != end
|
||||
ScenarioTrigger.WIFI -> true
|
||||
else -> true
|
||||
} && !(action == ScenarioAction.SWITCH_SERVER && profile == null)
|
||||
|
||||
ShxSheet(onDismiss = onDismiss)
|
||||
{
|
||||
Column(verticalArrangement = Arrangement.spacedBy(14.dp))
|
||||
{
|
||||
Text(stringResource(if (initial == null) R.string.scenario_new else R.string.scenario_edit), style = ShxType.sheetTitle, color = c.text, modifier = Modifier.padding(start = 4.dp, top = 4.dp))
|
||||
Text(stringResource(R.string.scenario_step_when), style = ShxType.section, color = c.accentText, modifier = Modifier.padding(horizontal = 4.dp))
|
||||
val whens = listOf(
|
||||
ScenarioTrigger.WIFI to R.string.scenario_t_wifi, ScenarioTrigger.MOBILE to R.string.scenario_t_mobile, ScenarioTrigger.APP to R.string.scenario_t_app,
|
||||
ScenarioTrigger.TIME to R.string.scenario_t_time, ScenarioTrigger.ROAMING to R.string.scenario_t_roaming, ScenarioTrigger.WHITELIST to R.string.scenario_t_whitelist,
|
||||
)
|
||||
whens.chunked(3).forEach { row ->
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp))
|
||||
{
|
||||
row.forEach { (t, label) ->
|
||||
val on = trigger == t
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp, Alignment.CenterVertically),
|
||||
modifier = Modifier.weight(1f).height(76.dp).clip(RoundedCornerShape(18.dp)).background(if (on) c.accentSoft else c.card)
|
||||
.border(if (on) 2.dp else 1.dp, if (on) c.accent else c.cardBorder, RoundedCornerShape(18.dp))
|
||||
.clickable(role = Role.RadioButton) { trigger = t }.padding(horizontal = 4.dp),
|
||||
)
|
||||
{
|
||||
Icon(triggerLook(t).first, contentDescription = null, tint = if (on) c.onAccentContainer else c.text, modifier = Modifier.size(20.dp))
|
||||
Text(stringResource(label), style = ShxType.meta.copy(fontWeight = FontWeight.Bold, lineHeight = 14.sp), color = if (on) c.onAccentContainer else c.text, textAlign = TextAlign.Center, maxLines = 2)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
when (trigger)
|
||||
{
|
||||
ScenarioTrigger.APP -> AppChips(packages, onPick = { picking = true }, onRemove = { packages = packages - it })
|
||||
ScenarioTrigger.WIFI -> ShxFormField(label = stringResource(R.string.scenario_ssids), value = ssids, onValueChange = { ssids = it }, placeholder = stringResource(R.string.scenario_ssids_hint))
|
||||
ScenarioTrigger.TIME -> Row(horizontalArrangement = Arrangement.spacedBy(10.dp))
|
||||
{
|
||||
TimeBox(stringResource(R.string.scenario_from), start, Modifier.weight(1f)) { start = it }
|
||||
TimeBox(stringResource(R.string.scenario_to), end, Modifier.weight(1f)) { end = it }
|
||||
}
|
||||
else -> Unit
|
||||
}
|
||||
Text(stringResource(R.string.scenario_step_what), style = ShxType.section, color = c.accentText, modifier = Modifier.padding(start = 4.dp, end = 4.dp, top = 6.dp))
|
||||
listOf(ScenarioAction.VPN_ON, ScenarioAction.VPN_OFF, ScenarioAction.PAUSE, ScenarioAction.SWITCH_SERVER).chunked(2).forEach { row ->
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp))
|
||||
{
|
||||
row.forEach { a ->
|
||||
val on = action == a
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier.weight(1f).height(52.dp).clip(RoundedCornerShape(16.dp)).background(if (on) c.accentSoft else c.card)
|
||||
.border(if (on) 2.dp else 1.dp, if (on) c.accent else c.cardBorder, RoundedCornerShape(16.dp))
|
||||
.clickable(role = Role.RadioButton) { action = a },
|
||||
)
|
||||
{
|
||||
Text(stringResource(ScenarioEngine.actionLabel(a)), style = ShxType.body.copy(fontWeight = FontWeight.Bold), color = if (on) c.onAccentContainer else c.text)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (action == ScenarioAction.VPN_ON || action == ScenarioAction.SWITCH_SERVER)
|
||||
{
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(18.dp)).background(c.card).border(1.dp, c.cardBorder, RoundedCornerShape(18.dp))
|
||||
.clickable(role = Role.Button) { pickProfile = true }.padding(horizontal = 14.dp, vertical = 12.dp),
|
||||
)
|
||||
{
|
||||
Text(stringResource(R.string.scenario_server), style = ShxType.body.copy(fontWeight = FontWeight.Bold), color = c.text, modifier = Modifier.weight(1f))
|
||||
Text(
|
||||
profiles.firstOrNull { it.getUUID().toString() == profile }?.let { it.name ?: it.gateway } ?: stringResource(R.string.scenario_server_auto),
|
||||
style = ShxType.body, color = c.textSub,
|
||||
)
|
||||
Icon(ShxIcons.Chevron, contentDescription = null, tint = c.textSub, modifier = Modifier.size(18.dp))
|
||||
}
|
||||
}
|
||||
Text(
|
||||
stringResource(R.string.scenario_summary, ScenarioEngine.describeWhen(context, draft),
|
||||
midSentence(stringResource(ScenarioEngine.actionLabel(action)))),
|
||||
style = ShxType.note.copy(fontSize = 14.sp, lineHeight = 20.sp, fontWeight = FontWeight.Normal), color = c.onAccentContainer,
|
||||
modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(18.dp)).background(c.accentSoft).padding(horizontal = 14.dp, vertical = 12.dp),
|
||||
)
|
||||
if (trigger == ScenarioTrigger.APP && !AppTraffic.hasAccess(context))
|
||||
{
|
||||
Text(stringResource(R.string.scenario_app_access), style = ShxType.meta.copy(fontWeight = FontWeight.Normal, lineHeight = 17.sp), color = c.onWarningContainer,
|
||||
modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(14.dp)).background(c.warningContainer).padding(12.dp))
|
||||
}
|
||||
if (onMove != null)
|
||||
{
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(10.dp))
|
||||
{
|
||||
ShxButton(stringResource(R.string.scenario_raise), { onMove(-1) }, style = ShxButtonStyle.SOFT, height = 48.dp, modifier = Modifier.weight(1f), icon = ShxIcons.ArrowUp)
|
||||
ShxButton(stringResource(R.string.scenario_lower), { onMove(1) }, style = ShxButtonStyle.SOFT, height = 48.dp, modifier = Modifier.weight(1f), icon = ShxIcons.ArrowDown)
|
||||
}
|
||||
}
|
||||
if (onDelete != null)
|
||||
{
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(10.dp))
|
||||
{
|
||||
onDelete?.let { ShxButton(stringResource(R.string.server_delete_confirm), it, style = ShxButtonStyle.DANGER, height = 48.dp, modifier = Modifier.weight(1f)) }
|
||||
}
|
||||
}
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(10.dp))
|
||||
{
|
||||
ShxButton(stringResource(android.R.string.cancel), onDismiss, style = ShxButtonStyle.OUTLINE, modifier = Modifier.weight(1f))
|
||||
ShxButton(stringResource(R.string.server_save), { if (valid) onSave(draft) }, modifier = Modifier.weight(2f), enabled = valid)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (picking)
|
||||
{
|
||||
AppPicker(packages, onDone = { packages = it; picking = false }, onDismiss = { picking = false })
|
||||
}
|
||||
if (pickProfile)
|
||||
{
|
||||
ShxSheet(onDismiss = { pickProfile = false })
|
||||
{
|
||||
ShxPanel()
|
||||
{
|
||||
(listOf<VpnProfile?>(null) + profiles).forEachIndexed { i, p ->
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth().heightIn(min = 56.dp).clickable { profile = p?.getUUID()?.toString(); pickProfile = false }.padding(horizontal = 16.dp),
|
||||
)
|
||||
{
|
||||
Text(p?.let { it.name ?: it.gateway } ?: stringResource(R.string.scenario_server_auto), style = ShxType.rowTitle.copy(fontSize = 15.sp), color = c.text, modifier = Modifier.weight(1f))
|
||||
if (profile == p?.getUUID()?.toString())
|
||||
{
|
||||
Icon(ShxIcons.Check, contentDescription = null, tint = c.accent, modifier = Modifier.size(20.dp))
|
||||
}
|
||||
}
|
||||
if (i < profiles.size)
|
||||
{
|
||||
Box(Modifier.fillMaxWidth().height(1.dp).background(c.tile))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TimeBox(label: String, minute: Int, modifier: Modifier, onPick: (Int) -> Unit)
|
||||
{
|
||||
val c = LocalShxColors.current
|
||||
val context = LocalContext.current
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||
modifier = modifier.clip(RoundedCornerShape(16.dp)).background(c.card).border(1.dp, c.cardBorder, RoundedCornerShape(16.dp))
|
||||
.clickable(role = Role.Button) {
|
||||
TimePickerDialog(context, { _, h, m -> onPick(h * 60 + m) }, minute / 60, minute % 60, true).show()
|
||||
}.padding(horizontal = 14.dp, vertical = 10.dp),
|
||||
)
|
||||
{
|
||||
Text(label, style = ShxType.meta, color = c.textSub)
|
||||
Text(String.format("%02d:%02d", minute / 60, minute % 60), style = ShxType.cardHeading, color = c.text)
|
||||
}
|
||||
}
|
||||
|
||||
/** Chosen apps as chips with icons, plus «+ Choose». */
|
||||
@Composable
|
||||
private fun AppChips(packages: Set<String>, onPick: () -> Unit, onRemove: (String) -> Unit)
|
||||
{
|
||||
val c = LocalShxColors.current
|
||||
val context = LocalContext.current
|
||||
val pm = context.packageManager
|
||||
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.padding(horizontal = 2.dp))
|
||||
{
|
||||
packages.forEach { pkg ->
|
||||
val info = remember(pkg) { runCatching { pm.getApplicationInfo(pkg, 0) }.getOrNull() }
|
||||
val label = remember(pkg) { info?.let { pm.getApplicationLabel(it).toString() } ?: pkg }
|
||||
val icon = remember(pkg) { info?.let { runCatching { pm.getApplicationIcon(it).toBitmap(72, 72).asImageBitmap() }.getOrNull() } }
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier.height(36.dp).clip(RoundedCornerShape(18.dp)).background(c.accentContainer).clickable { onRemove(pkg) }.padding(start = 6.dp, end = 12.dp),
|
||||
)
|
||||
{
|
||||
if (icon != null) Image(icon, contentDescription = null, modifier = Modifier.size(24.dp).clip(RoundedCornerShape(8.dp)))
|
||||
Text(label, style = ShxType.chip, color = c.onAccentContainer, maxLines = 1)
|
||||
}
|
||||
}
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier.height(36.dp).clip(RoundedCornerShape(18.dp)).border(1.dp, c.iconMuted, RoundedCornerShape(18.dp)).clickable(role = Role.Button, onClick = onPick).padding(horizontal = 12.dp),
|
||||
)
|
||||
{
|
||||
Text(stringResource(R.string.scenario_pick_apps), style = ShxType.chip, color = c.accentText)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class LaunchableApp(val pkg: String, val label: String, val icon: Drawable?)
|
||||
|
||||
/** Launchable apps, the most used of the last week first (with usage access). */
|
||||
private fun loadApps(context: Context): Pair<List<LaunchableApp>, Set<String>>
|
||||
{
|
||||
val pm = context.packageManager
|
||||
val intent = Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_LAUNCHER)
|
||||
val apps = pm.queryIntentActivities(intent, 0).map { it.activityInfo.packageName }.distinct()
|
||||
.filter { it != context.packageName }
|
||||
.map { pkg ->
|
||||
val info = runCatching { pm.getApplicationInfo(pkg, 0) }.getOrNull()
|
||||
LaunchableApp(pkg, info?.let { pm.getApplicationLabel(it).toString() } ?: pkg, info?.let { runCatching { pm.getApplicationIcon(it) }.getOrNull() })
|
||||
}
|
||||
val frequent = if (AppTraffic.hasAccess(context))
|
||||
{
|
||||
val usm = context.getSystemService(Context.USAGE_STATS_SERVICE) as? UsageStatsManager
|
||||
val now = System.currentTimeMillis()
|
||||
usm?.queryUsageStats(UsageStatsManager.INTERVAL_BEST, now - 7L * 24 * 60 * 60 * 1000, now)
|
||||
?.filter { it.totalTimeInForeground > 0 }?.sortedByDescending { it.totalTimeInForeground }
|
||||
?.map { it.packageName }?.filter { p -> apps.any { it.pkg == p } }?.distinct()?.take(7)?.toSet() ?: emptySet()
|
||||
}
|
||||
else emptySet()
|
||||
return apps.sortedBy { it.label.lowercase() } to frequent
|
||||
}
|
||||
|
||||
/** «Apps» (F_AppPicker): search, often opened first, checkboxes, Done. */
|
||||
@Composable
|
||||
private fun AppPicker(selected: Set<String>, onDone: (Set<String>) -> Unit, onDismiss: () -> Unit)
|
||||
{
|
||||
val c = LocalShxColors.current
|
||||
val context = LocalContext.current
|
||||
var chosen by remember { mutableStateOf(selected) }
|
||||
var query by remember { mutableStateOf("") }
|
||||
val data by produceState<Pair<List<LaunchableApp>, Set<String>>?>(null) { value = withContext(Dispatchers.IO) { loadApps(context) } }
|
||||
Dialog(onDismissRequest = onDismiss, properties = DialogProperties(usePlatformDefaultWidth = false, decorFitsSystemWindows = false))
|
||||
{
|
||||
Column(Modifier.fillMaxSize().background(c.background).statusBarsPadding().navigationBarsPadding())
|
||||
{
|
||||
ShxPageHeader(stringResource(R.string.stats_apps), onBack = onDismiss)
|
||||
{
|
||||
Text(stringResource(R.string.scenario_chosen, chosen.size), style = ShxType.body.copy(fontWeight = FontWeight.Bold), color = c.accentText, modifier = Modifier.padding(end = 8.dp))
|
||||
}
|
||||
Column(Modifier.weight(1f).padding(horizontal = 16.dp), verticalArrangement = Arrangement.spacedBy(12.dp))
|
||||
{
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
modifier = Modifier.fillMaxWidth().height(52.dp).clip(RoundedCornerShape(26.dp)).background(c.tile).padding(horizontal = 16.dp),
|
||||
)
|
||||
{
|
||||
Icon(ShxIcons.Search, contentDescription = null, tint = c.textSub, modifier = Modifier.size(18.dp))
|
||||
Box(Modifier.weight(1f))
|
||||
{
|
||||
if (query.isEmpty())
|
||||
{
|
||||
Text(stringResource(R.string.scenario_search_apps), style = ShxType.body.copy(fontSize = 16.sp, fontWeight = FontWeight.Medium), color = c.textSub)
|
||||
}
|
||||
BasicTextField(query, { query = it }, singleLine = true, textStyle = ShxType.body.copy(fontSize = 16.sp, fontWeight = FontWeight.Medium, color = c.text), cursorBrush = SolidColor(c.accent), modifier = Modifier.fillMaxWidth())
|
||||
}
|
||||
}
|
||||
val (apps, frequent) = data ?: (emptyList<LaunchableApp>() to emptySet())
|
||||
val shown = apps.filter { query.isBlank() || it.label.contains(query.trim(), ignoreCase = true) }
|
||||
val top = if (query.isBlank()) shown.filter { it.pkg in frequent }.sortedBy { frequent.indexOf(it.pkg) } else emptyList()
|
||||
val rest = shown - top.toSet()
|
||||
LazyColumn(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(8.dp))
|
||||
{
|
||||
if (top.isNotEmpty())
|
||||
{
|
||||
item { ShxSectionHeader(stringResource(R.string.scenario_often).uppercase(), Modifier.padding(horizontal = 6.dp)) }
|
||||
item { AppList(top, chosen) { pkg -> chosen = if (pkg in chosen) chosen - pkg else chosen + pkg } }
|
||||
item { ShxSectionHeader(stringResource(R.string.scenario_all_apps).uppercase(), Modifier.padding(start = 6.dp, end = 6.dp, top = 6.dp)) }
|
||||
}
|
||||
item { AppList(rest, chosen) { pkg -> chosen = if (pkg in chosen) chosen - pkg else chosen + pkg } }
|
||||
}
|
||||
ShxButton(stringResource(R.string.scenario_done), { onDone(chosen) }, modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AppList(apps: List<LaunchableApp>, chosen: Set<String>, onToggle: (String) -> Unit)
|
||||
{
|
||||
val c = LocalShxColors.current
|
||||
if (apps.isEmpty()) return
|
||||
ShxPanel()
|
||||
{
|
||||
apps.forEachIndexed { i, app ->
|
||||
val icon = remember(app.pkg) { app.icon?.let { runCatching { it.toBitmap(96, 96).asImageBitmap() }.getOrNull() } }
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
modifier = Modifier.fillMaxWidth().height(60.dp).clickable { onToggle(app.pkg) }.padding(start = 16.dp, end = 8.dp),
|
||||
)
|
||||
{
|
||||
Box(Modifier.size(36.dp).clip(RoundedCornerShape(10.dp)).background(c.tile), contentAlignment = Alignment.Center)
|
||||
{
|
||||
if (icon != null) Image(icon, contentDescription = null, modifier = Modifier.size(36.dp))
|
||||
else Text(app.label.take(1).uppercase(), style = ShxType.chip, color = c.textSoft)
|
||||
}
|
||||
Text(app.label, style = ShxType.rowTitle.copy(fontSize = 15.sp), color = c.text, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f))
|
||||
Checkbox(app.pkg in chosen, { onToggle(app.pkg) }, colors = CheckboxDefaults.colors(checkedColor = c.accent))
|
||||
}
|
||||
if (i < apps.lastIndex)
|
||||
{
|
||||
Box(Modifier.fillMaxWidth().height(1.dp).background(c.tile))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** An action label inside the summary sentence: lower case, unless German (nouns) or it starts with an acronym like «VPN». */
|
||||
private fun midSentence(label: String): String =
|
||||
if (java.util.Locale.getDefault().language == "de" || label.getOrNull(1)?.isUpperCase() == true) label
|
||||
else label.replaceFirstChar { it.lowercase() }
|
||||
-190
@@ -1,190 +0,0 @@
|
||||
/*
|
||||
* Copyright (C) 2026 SHX VPN
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or modify it
|
||||
* under the terms of the GNU General Public License as published by the
|
||||
* Free Software Foundation; either version 2 of the License, or (at your
|
||||
* option) any later version.
|
||||
*/
|
||||
|
||||
package org.strongswan.android.ui
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Bundle
|
||||
import androidx.activity.OnBackPressedCallback
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.core.graphics.drawable.toBitmap
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.strongswan.android.R
|
||||
import org.strongswan.android.data.VpnProfileDataSource
|
||||
import org.strongswan.android.ui.adapter.SelectedApplicationEntry
|
||||
import org.strongswan.android.ui.compose.SecondaryScaffold
|
||||
import org.strongswan.android.ui.compose.theme.ShxTheme
|
||||
import java.util.TreeSet
|
||||
|
||||
class SelectedApplicationsActivity : AppCompatActivity()
|
||||
{
|
||||
private val selection = TreeSet<String>()
|
||||
private var readOnly = false
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?)
|
||||
{
|
||||
super.onCreate(savedInstanceState)
|
||||
enableEdgeToEdge()
|
||||
readOnly = intent.getBooleanExtra(VpnProfileDataSource.KEY_READ_ONLY, false)
|
||||
val initial = savedInstanceState?.getStringArrayList(VpnProfileDataSource.KEY_SELECTED_APPS_LIST)
|
||||
?: intent.getStringArrayListExtra(VpnProfileDataSource.KEY_SELECTED_APPS_LIST)
|
||||
?: arrayListOf()
|
||||
selection.addAll(initial)
|
||||
onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true)
|
||||
{
|
||||
override fun handleOnBackPressed()
|
||||
{
|
||||
prepareResult()
|
||||
finish()
|
||||
}
|
||||
})
|
||||
setContent {
|
||||
ShxTheme {
|
||||
SelectedAppsScreen(
|
||||
initial = selection,
|
||||
readOnly = readOnly,
|
||||
onBack = {
|
||||
prepareResult()
|
||||
finish()
|
||||
},
|
||||
onChange = {
|
||||
selection.clear()
|
||||
selection.addAll(it)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onSaveInstanceState(outState: Bundle)
|
||||
{
|
||||
super.onSaveInstanceState(outState)
|
||||
outState.putStringArrayList(VpnProfileDataSource.KEY_SELECTED_APPS_LIST, ArrayList(selection))
|
||||
}
|
||||
|
||||
private fun prepareResult()
|
||||
{
|
||||
val data = Intent()
|
||||
data.putExtra(VpnProfileDataSource.KEY_SELECTED_APPS_LIST, ArrayList(selection))
|
||||
setResult(RESULT_OK, data)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SelectedAppsScreen(
|
||||
initial: Set<String>,
|
||||
readOnly: Boolean,
|
||||
onBack: () -> Unit,
|
||||
onChange: (Set<String>) -> Unit,
|
||||
)
|
||||
{
|
||||
val context = LocalContext.current
|
||||
var query by remember { mutableStateOf("") }
|
||||
var apps by remember { mutableStateOf<List<SelectedApplicationEntry>>(emptyList()) }
|
||||
var selected by remember { mutableStateOf(initial.toSet()) }
|
||||
var loading by remember { mutableStateOf(true) }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
val loaded = withContext(Dispatchers.IO) {
|
||||
val pm = context.packageManager
|
||||
val list = ArrayList<SelectedApplicationEntry>()
|
||||
for (info in pm.getInstalledApplications(PackageManager.GET_META_DATA))
|
||||
{
|
||||
if (pm.checkPermission(Manifest.permission.INTERNET, info.packageName) == PackageManager.PERMISSION_GRANTED)
|
||||
{
|
||||
val entry = SelectedApplicationEntry(pm, info)
|
||||
entry.isSelected = selected.contains(info.packageName)
|
||||
list.add(entry)
|
||||
}
|
||||
}
|
||||
list.sorted()
|
||||
}
|
||||
apps = loaded
|
||||
loading = false
|
||||
}
|
||||
|
||||
fun toggle(pkg: String)
|
||||
{
|
||||
if (readOnly) return
|
||||
selected = if (selected.contains(pkg)) selected - pkg else selected + pkg
|
||||
onChange(selected)
|
||||
}
|
||||
|
||||
val filtered = remember(apps, query) {
|
||||
if (query.isBlank()) apps
|
||||
else apps.filter { it.toString().contains(query, ignoreCase = true) || it.info.packageName.contains(query, ignoreCase = true) }
|
||||
}
|
||||
|
||||
SecondaryScaffold(title = stringResource(R.string.profile_select_apps), onBack = onBack) { padding ->
|
||||
Column(Modifier.fillMaxSize().padding(padding)) {
|
||||
OutlinedTextField(
|
||||
value = query,
|
||||
onValueChange = { query = it },
|
||||
label = { Text(stringResource(R.string.search)) },
|
||||
singleLine = true,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp).fillMaxWidth(),
|
||||
)
|
||||
if (loading)
|
||||
{
|
||||
CircularProgressIndicator(Modifier.padding(24.dp).size(32.dp))
|
||||
}
|
||||
else
|
||||
{
|
||||
LazyColumn(Modifier.fillMaxSize()) {
|
||||
items(filtered, key = { it.info.packageName }) { entry ->
|
||||
val pkg = entry.info.packageName
|
||||
val checked = selected.contains(pkg)
|
||||
ListItem(
|
||||
headlineContent = { Text(entry.toString()) },
|
||||
supportingContent = { Text(pkg) },
|
||||
leadingContent = {
|
||||
val bmp = remember(pkg) { entry.icon.toBitmap() }
|
||||
Image(bitmap = bmp.asImageBitmap(), contentDescription = null, modifier = Modifier.size(40.dp))
|
||||
},
|
||||
trailingContent = {
|
||||
Checkbox(checked = checked, onCheckedChange = { toggle(pkg) }, enabled = !readOnly)
|
||||
},
|
||||
modifier = Modifier.clickable(enabled = !readOnly) { toggle(pkg) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
-286
@@ -1,286 +0,0 @@
|
||||
/*
|
||||
* Copyright (C) 2017 Tobias Brunner
|
||||
*
|
||||
* Copyright (C) secunet Security Networks AG
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or modify it
|
||||
* under the terms of the GNU General Public License as published by the
|
||||
* Free Software Foundation; either version 2 of the License, or (at your
|
||||
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful, but
|
||||
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
||||
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
|
||||
* for more details.
|
||||
*/
|
||||
|
||||
package org.strongswan.android.ui;
|
||||
|
||||
import android.Manifest;
|
||||
import android.content.Context;
|
||||
import android.content.pm.ApplicationInfo;
|
||||
import android.content.pm.PackageManager;
|
||||
import android.os.Bundle;
|
||||
import android.text.TextUtils;
|
||||
import android.util.Pair;
|
||||
import android.view.Menu;
|
||||
import android.view.MenuInflater;
|
||||
import android.view.MenuItem;
|
||||
import android.view.View;
|
||||
import android.widget.Filter;
|
||||
import android.widget.ListView;
|
||||
|
||||
import org.strongswan.android.R;
|
||||
import org.strongswan.android.data.VpnProfileDataSource;
|
||||
import org.strongswan.android.ui.adapter.SelectedApplicationEntry;
|
||||
import org.strongswan.android.ui.adapter.SelectedApplicationsAdapter;
|
||||
import org.strongswan.android.utils.Utils;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.SortedSet;
|
||||
import java.util.TreeSet;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.appcompat.widget.SearchView;
|
||||
import androidx.core.view.MenuProvider;
|
||||
import androidx.fragment.app.ListFragment;
|
||||
import androidx.loader.app.LoaderManager;
|
||||
import androidx.loader.content.AsyncTaskLoader;
|
||||
import androidx.loader.content.Loader;
|
||||
|
||||
public class SelectedApplicationsListFragment extends ListFragment implements MenuProvider, LoaderManager.LoaderCallbacks<Pair<List<SelectedApplicationEntry>, List<String>>>, SearchView.OnQueryTextListener
|
||||
{
|
||||
private SelectedApplicationsAdapter mAdapter;
|
||||
private SortedSet<String> mSelection;
|
||||
|
||||
@Override
|
||||
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState)
|
||||
{
|
||||
super.onViewCreated(view, savedInstanceState);
|
||||
requireActivity().addMenuProvider(this, getViewLifecycleOwner());
|
||||
|
||||
getListView().setClipToPadding(false);
|
||||
Utils.applyWindowInsetsAsPaddingForLists(getListView());
|
||||
|
||||
final boolean readOnly = getActivity().getIntent().getBooleanExtra(VpnProfileDataSource.KEY_READ_ONLY, false);
|
||||
getListView().setChoiceMode(readOnly ? ListView.CHOICE_MODE_NONE : ListView.CHOICE_MODE_MULTIPLE);
|
||||
|
||||
mAdapter = new SelectedApplicationsAdapter(getActivity());
|
||||
mAdapter.setReadOnly(readOnly);
|
||||
setListAdapter(mAdapter);
|
||||
setListShown(false);
|
||||
|
||||
ArrayList<String> selection;
|
||||
if (savedInstanceState == null)
|
||||
{
|
||||
selection = getActivity().getIntent().getStringArrayListExtra(VpnProfileDataSource.KEY_SELECTED_APPS_LIST);
|
||||
}
|
||||
else
|
||||
{
|
||||
selection = savedInstanceState.getStringArrayList(VpnProfileDataSource.KEY_SELECTED_APPS_LIST);
|
||||
}
|
||||
mSelection = new TreeSet<>(selection);
|
||||
|
||||
LoaderManager.getInstance(this).initLoader(0, null, this);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onSaveInstanceState(Bundle outState)
|
||||
{
|
||||
super.onSaveInstanceState(outState);
|
||||
outState.putStringArrayList(VpnProfileDataSource.KEY_SELECTED_APPS_LIST, new ArrayList<>(mSelection));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the package names of all selected apps
|
||||
*/
|
||||
public ArrayList<String> getSelectedApplications()
|
||||
{
|
||||
return new ArrayList<>(mSelection);
|
||||
}
|
||||
|
||||
/**
|
||||
* Track check state as ListView is unable to do that when using filters
|
||||
*/
|
||||
@Override
|
||||
public void onListItemClick(ListView l, View v, int position, long id)
|
||||
{
|
||||
if (mAdapter.isReadOnly())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
super.onListItemClick(l, v, position, id);
|
||||
SelectedApplicationEntry item = (SelectedApplicationEntry)getListView().getItemAtPosition(position);
|
||||
item.setSelected(!item.isSelected());
|
||||
if (item.isSelected())
|
||||
{
|
||||
mSelection.add(item.getInfo().packageName);
|
||||
}
|
||||
else
|
||||
{
|
||||
mSelection.remove(item.getInfo().packageName);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Manually set the check state as ListView is unable to track that when using filters
|
||||
*/
|
||||
private void setCheckState()
|
||||
{
|
||||
for (int i = 0; i < getListView().getCount(); i++)
|
||||
{
|
||||
SelectedApplicationEntry item = mAdapter.getItem(i);
|
||||
getListView().setItemChecked(i, item.isSelected());
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCreateMenu(@NonNull Menu menu, @NonNull MenuInflater menuInflater)
|
||||
{
|
||||
MenuItem item = menu.add(R.string.search);
|
||||
item.setIcon(android.R.drawable.ic_menu_search);
|
||||
item.setShowAsAction(MenuItem.SHOW_AS_ACTION_ALWAYS);
|
||||
|
||||
SearchView sv = new SearchView(getActivity());
|
||||
sv.setOnQueryTextListener(this);
|
||||
item.setActionView(sv);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onMenuItemSelected(@NonNull MenuItem menuItem)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Loader<Pair<List<SelectedApplicationEntry>, List<String>>> onCreateLoader(int id, Bundle args)
|
||||
{
|
||||
return new InstalledPackagesLoader(getActivity(), mSelection);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onLoadFinished(Loader<Pair<List<SelectedApplicationEntry>, List<String>>> loader, Pair<List<SelectedApplicationEntry>, List<String>> data)
|
||||
{
|
||||
mAdapter.setData(data.first);
|
||||
mSelection.removeAll(data.second);
|
||||
setCheckState();
|
||||
|
||||
if (isResumed())
|
||||
{
|
||||
setListShown(true);
|
||||
}
|
||||
else
|
||||
{
|
||||
setListShownNoAnimation(true);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onLoaderReset(Loader<Pair<List<SelectedApplicationEntry>, List<String>>> loader)
|
||||
{
|
||||
mAdapter.setData(null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onQueryTextSubmit(String query)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onQueryTextChange(String newText)
|
||||
{
|
||||
String search = TextUtils.isEmpty(newText) ? null : newText;
|
||||
mAdapter.getFilter().filter(search, new Filter.FilterListener()
|
||||
{
|
||||
@Override
|
||||
public void onFilterComplete(int count)
|
||||
{
|
||||
setCheckState();
|
||||
}
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
public static class InstalledPackagesLoader extends AsyncTaskLoader<Pair<List<SelectedApplicationEntry>, List<String>>>
|
||||
{
|
||||
private final PackageManager mPackageManager;
|
||||
private final SortedSet<String> mSelection;
|
||||
private Pair<List<SelectedApplicationEntry>, List<String>> mData;
|
||||
|
||||
public InstalledPackagesLoader(Context context, SortedSet<String> selection)
|
||||
{
|
||||
super(context);
|
||||
mPackageManager = context.getPackageManager();
|
||||
mSelection = selection;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Pair<List<SelectedApplicationEntry>, List<String>> loadInBackground()
|
||||
{
|
||||
List<SelectedApplicationEntry> apps = new ArrayList<>();
|
||||
SortedSet<String> seen = new TreeSet<>();
|
||||
for (ApplicationInfo info : mPackageManager.getInstalledApplications(PackageManager.GET_META_DATA))
|
||||
{
|
||||
/* skip apps that can't access the network anyway */
|
||||
if (mPackageManager.checkPermission(Manifest.permission.INTERNET, info.packageName) == PackageManager.PERMISSION_GRANTED)
|
||||
{
|
||||
SelectedApplicationEntry entry = new SelectedApplicationEntry(mPackageManager, info);
|
||||
entry.setSelected(mSelection.contains(info.packageName));
|
||||
apps.add(entry);
|
||||
seen.add(info.packageName);
|
||||
}
|
||||
}
|
||||
Collections.sort(apps);
|
||||
/* check for selected packages that don't exist anymore */
|
||||
List<String> missing = new ArrayList<>();
|
||||
for (String pkg : mSelection)
|
||||
{
|
||||
if (!seen.contains(pkg))
|
||||
{
|
||||
missing.add(pkg);
|
||||
}
|
||||
}
|
||||
return new Pair<>(apps, missing);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onStartLoading()
|
||||
{
|
||||
if (mData != null)
|
||||
{ /* if we have data ready, deliver it directly */
|
||||
deliverResult(mData);
|
||||
}
|
||||
if (takeContentChanged() || mData == null)
|
||||
{
|
||||
forceLoad();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void deliverResult(Pair<List<SelectedApplicationEntry>, List<String>> data)
|
||||
{
|
||||
if (isReset())
|
||||
{
|
||||
return;
|
||||
}
|
||||
mData = data;
|
||||
if (isStarted())
|
||||
{ /* if it is started we deliver the data directly,
|
||||
* otherwise this is handled in onStartLoading */
|
||||
super.deliverResult(data);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onReset()
|
||||
{
|
||||
mData = null;
|
||||
super.onReset();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,818 @@
|
||||
/*
|
||||
* Copyright (C) 2026 SHX VPN
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or modify it
|
||||
* under the terms of the GNU General Public License as published by the
|
||||
* Free Software Foundation; either version 2 of the License, or (at your
|
||||
* option) any later version.
|
||||
*/
|
||||
|
||||
package org.strongswan.android.ui
|
||||
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.text.format.DateUtils
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.statusBarsPadding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.repeatOnLifecycle
|
||||
import androidx.localbroadcastmanager.content.LocalBroadcastManager
|
||||
import com.journeyapps.barcodescanner.ScanContract
|
||||
import com.journeyapps.barcodescanner.ScanOptions
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.strongswan.android.R
|
||||
import org.strongswan.android.ui.compose.design.ShxDialogButton
|
||||
import org.strongswan.android.data.VpnProfile
|
||||
import org.strongswan.android.data.VpnProfileDataSource
|
||||
import org.strongswan.android.data.VpnProfileSource
|
||||
import org.strongswan.android.data.VpnType
|
||||
import org.strongswan.android.logic.VpnStateService
|
||||
import org.strongswan.android.logic.connectivity.ConnectivityProbe
|
||||
import org.strongswan.android.logic.profiles.ProfileCheck
|
||||
import org.strongswan.android.logic.profiles.ProfileExtra
|
||||
import org.strongswan.android.logic.profiles.ProfileExtras
|
||||
import org.strongswan.android.logic.profiles.ProfileSourceType
|
||||
import org.strongswan.android.logic.profiles.ProfileUpdater
|
||||
import org.strongswan.android.logic.profiles.needsPassword
|
||||
import org.strongswan.android.logic.profiles.RESERVE_TIMEOUT_S
|
||||
import org.strongswan.android.ui.compose.ProfileQr
|
||||
import org.strongswan.android.ui.compose.ShareProfileScreen
|
||||
import org.strongswan.android.ui.compose.components.ShxSnackbarHost
|
||||
import org.strongswan.android.ui.compose.design.ShxAlertDialog
|
||||
import org.strongswan.android.ui.compose.design.ShxButton
|
||||
import org.strongswan.android.ui.compose.design.ShxButtonStyle
|
||||
import org.strongswan.android.ui.compose.design.ShxDialog
|
||||
import org.strongswan.android.ui.compose.design.ShxFormField
|
||||
import org.strongswan.android.ui.compose.design.ShxIcons
|
||||
import org.strongswan.android.ui.compose.design.ShxPanel
|
||||
import org.strongswan.android.ui.compose.design.ShxSectionHeader
|
||||
import org.strongswan.android.ui.compose.design.ShxSheet
|
||||
import org.strongswan.android.ui.compose.design.ShxSheetTitle
|
||||
import org.strongswan.android.ui.compose.design.ShxSwitch
|
||||
import org.strongswan.android.ui.compose.home.serverCode
|
||||
import org.strongswan.android.ui.compose.theme.LocalShxColors
|
||||
import org.strongswan.android.ui.compose.theme.ShxTheme
|
||||
import org.strongswan.android.ui.compose.theme.ShxType
|
||||
import org.strongswan.android.utils.Constants
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import java.util.UUID
|
||||
|
||||
/** One server (F_Profile): source and updates, server, resilience and network settings. */
|
||||
class ServerActivity : AppCompatActivity()
|
||||
{
|
||||
override fun onCreate(savedInstanceState: Bundle?)
|
||||
{
|
||||
super.onCreate(savedInstanceState)
|
||||
enableEdgeToEdge()
|
||||
val uuid = intent.getStringExtra(VpnProfileDataSource.KEY_UUID)
|
||||
if (uuid == null)
|
||||
{
|
||||
finish()
|
||||
return
|
||||
}
|
||||
val checkNow = intent.getBooleanExtra(EXTRA_CHECK_UPDATE, false)
|
||||
setContent {
|
||||
ShxTheme {
|
||||
ServerScreen(uuid, checkNow, onBack = { finish() })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object
|
||||
{
|
||||
private const val EXTRA_CHECK_UPDATE = "org.strongswan.android.extra.CHECK_UPDATE"
|
||||
|
||||
@JvmStatic
|
||||
@JvmOverloads
|
||||
fun intent(context: Context, uuid: String, checkUpdate: Boolean = false): Intent =
|
||||
Intent(context, ServerActivity::class.java)
|
||||
.putExtra(VpnProfileDataSource.KEY_UUID, uuid)
|
||||
.putExtra(EXTRA_CHECK_UPDATE, checkUpdate)
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadProfiles(context: Context): List<VpnProfile>
|
||||
{
|
||||
val source = VpnProfileSource(context)
|
||||
source.open()
|
||||
try
|
||||
{
|
||||
return source.allVpnProfiles
|
||||
}
|
||||
finally
|
||||
{
|
||||
source.close()
|
||||
}
|
||||
}
|
||||
|
||||
/* which picker sheet is open */
|
||||
private enum class Picker { RESERVE, BRIDGE }
|
||||
|
||||
@Composable
|
||||
private fun ServerScreen(uuid: String, checkOnOpen: Boolean, onBack: () -> Unit)
|
||||
{
|
||||
val c = LocalShxColors.current
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val snackbar = remember { SnackbarHostState() }
|
||||
var reloads by remember { mutableIntStateOf(0) }
|
||||
val owner = LocalLifecycleOwner.current
|
||||
DisposableEffect(owner) {
|
||||
val observer = LifecycleEventObserver { _, event -> if (event == Lifecycle.Event.ON_RESUME) reloads++ }
|
||||
owner.lifecycle.addObserver(observer)
|
||||
onDispose { owner.lifecycle.removeObserver(observer) }
|
||||
}
|
||||
val profiles by produceState<List<VpnProfile>?>(null, reloads) { value = withContext(Dispatchers.IO) { loadProfiles(context) } }
|
||||
val all = profiles ?: return
|
||||
val profile = all.firstOrNull { it.getUUID().toString() == uuid }
|
||||
if (profile == null)
|
||||
{
|
||||
LaunchedEffect(Unit) { onBack() }
|
||||
return
|
||||
}
|
||||
val extras by remember { ProfileExtras.flow(context) }.collectAsStateWithLifecycle()
|
||||
val extra = extras[uuid] ?: ProfileExtra(uuid)
|
||||
/* the live state of the tunnel and a direct ping of the server */
|
||||
val vpn by produceState<Pair<VpnStateService.State, String?>?>(null, owner) {
|
||||
/* only while the page is on screen */
|
||||
owner.lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
|
||||
while (true)
|
||||
{
|
||||
val service = VpnStateService.getLiveInstance()
|
||||
value = service?.let { it.state to it.profile?.getUUID()?.toString() }
|
||||
delay(1500)
|
||||
}
|
||||
}
|
||||
}
|
||||
val rtt by produceState<Long?>(null, profile.gateway, owner) {
|
||||
owner.lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
|
||||
while (true)
|
||||
{
|
||||
value = withContext(Dispatchers.IO) { ConnectivityProbe.pingServerDirect(context, profile.gateway, 1500)?.first }
|
||||
delay(30_000)
|
||||
}
|
||||
}
|
||||
}
|
||||
val active = vpn?.second == uuid
|
||||
val connected = active && vpn?.first == VpnStateService.State.CONNECTED
|
||||
|
||||
var checking by remember { mutableStateOf(false) }
|
||||
var check by remember { mutableStateOf<ProfileCheck?>(null) }
|
||||
var showShare by remember { mutableStateOf(false) }
|
||||
var showDelete by remember { mutableStateOf(false) }
|
||||
var showLink by remember { mutableStateOf(false) }
|
||||
var showPorts by remember { mutableStateOf(false) }
|
||||
var picker by remember { mutableStateOf<Picker?>(null) }
|
||||
val noChanges = stringResource(R.string.profile_check_none)
|
||||
val failed = stringResource(R.string.profile_check_failed)
|
||||
val updated = stringResource(R.string.profile_updated)
|
||||
|
||||
fun runCheck()
|
||||
{
|
||||
if (checking) return
|
||||
checking = true
|
||||
scope.launch {
|
||||
val result = withContext(Dispatchers.IO) { runCatching { ProfileUpdater.check(context, uuid) } }
|
||||
checking = false
|
||||
result.onSuccess { if (it.changes.isEmpty()) snackbar.showSnackbar(noChanges) else check = it }
|
||||
result.onFailure {
|
||||
ProfileExtras.update(context, uuid) { e -> e.copy(checkedAt = System.currentTimeMillis(), lastChanges = -1) }
|
||||
snackbar.showSnackbar(failed)
|
||||
}
|
||||
}
|
||||
}
|
||||
LaunchedEffect(checkOnOpen) { if (checkOnOpen && extra.source == ProfileSourceType.LINK) runCheck() }
|
||||
|
||||
fun edit() = context.startActivity(Intent(context, VpnProfileDetailActivity::class.java).putExtra(VpnProfileDataSource.KEY_UUID, uuid))
|
||||
fun export() = scope.launch {
|
||||
val file = withContext(Dispatchers.IO) { org.strongswan.android.logic.VpnProfileExporter.exportProfile(context, profile, false) }
|
||||
if (file == null)
|
||||
{
|
||||
snackbar.showSnackbar(context.getString(R.string.share_profile_failed))
|
||||
return@launch
|
||||
}
|
||||
val send = Intent(Intent.ACTION_SEND).setType(org.strongswan.android.data.ProfileExportProvider.MIME_TYPE).putExtra(Intent.EXTRA_STREAM, file)
|
||||
try
|
||||
{
|
||||
context.startActivity(Intent.createChooser(send, context.getString(R.string.share_profile)))
|
||||
}
|
||||
catch (_: ActivityNotFoundException)
|
||||
{
|
||||
snackbar.showSnackbar(context.getString(R.string.share_profile_failed))
|
||||
}
|
||||
}
|
||||
val scanner = rememberLauncherForActivityResult(ScanContract()) { result ->
|
||||
result.contents?.let { text -> ProfileQr.importIntent(context, text)?.let { context.startActivity(it) } }
|
||||
}
|
||||
|
||||
Box(Modifier.fillMaxSize().background(c.background))
|
||||
{
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
modifier = Modifier.fillMaxSize().statusBarsPadding().verticalScroll(rememberScrollState()).navigationBarsPadding()
|
||||
.padding(bottom = 24.dp),
|
||||
)
|
||||
{
|
||||
/* header: back, «Share» pill */
|
||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().padding(start = 12.dp, end = 12.dp, top = 16.dp, bottom = 8.dp))
|
||||
{
|
||||
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(44.dp).clip(CircleShape).clickable(role = Role.Button, onClick = onBack))
|
||||
{
|
||||
Icon(ShxIcons.Back, contentDescription = stringResource(R.string.back), tint = c.text, modifier = Modifier.size(22.dp))
|
||||
}
|
||||
Spacer(Modifier.weight(1f))
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier.height(40.dp).clip(RoundedCornerShape(20.dp)).background(c.accentContainer)
|
||||
.clickable(role = Role.Button) { showShare = true }.padding(horizontal = 14.dp),
|
||||
)
|
||||
{
|
||||
Icon(ShxIcons.Scan, contentDescription = null, tint = c.onAccentContainer, modifier = Modifier.size(18.dp))
|
||||
Text(stringResource(R.string.server_share), style = ShxType.chip.copy(fontWeight = FontWeight.ExtraBold), color = c.onAccentContainer)
|
||||
}
|
||||
}
|
||||
Column(Modifier.padding(horizontal = 16.dp), verticalArrangement = Arrangement.spacedBy(12.dp))
|
||||
{
|
||||
/* who: avatar, name, state */
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(14.dp), modifier = Modifier.padding(start = 4.dp, end = 4.dp, bottom = 6.dp))
|
||||
{
|
||||
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(56.dp).clip(CircleShape).background(c.accent))
|
||||
{
|
||||
Text(serverCode(profile, null), style = ShxType.cardHeading.copy(fontSize = 18.sp), color = Color.White)
|
||||
}
|
||||
Column(verticalArrangement = Arrangement.spacedBy(3.dp))
|
||||
{
|
||||
Text(profile.name ?: profile.gateway, style = ShxType.screenTitle, color = c.text)
|
||||
val (dot, textColor, label) = when
|
||||
{
|
||||
connected -> Triple(c.success, c.onSuccess, rtt?.let { stringResource(R.string.server_state_connected_ms, it.toInt()) } ?: stringResource(R.string.server_state_connected))
|
||||
active && vpn?.first == VpnStateService.State.CONNECTING -> Triple(c.accent, c.accentText, stringResource(R.string.server_state_connecting))
|
||||
rtt != null -> Triple(c.textSub, c.textSub, stringResource(R.string.server_state_answers, rtt!!.toInt()))
|
||||
else -> Triple(c.warning, c.onWarningContainer, stringResource(R.string.server_state_no_answer))
|
||||
}
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp))
|
||||
{
|
||||
Box(Modifier.size(8.dp).clip(CircleShape).background(dot))
|
||||
Text(label, style = ShxType.chip, color = textColor)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SourceSection(
|
||||
extra = extra,
|
||||
checking = checking,
|
||||
onEditLink = { showLink = true },
|
||||
onAuto = { on -> ProfileExtras.update(context, uuid) { it.copy(autoUpdate = on) } },
|
||||
onCheck = ::runCheck,
|
||||
onFile = { context.startActivity(Intent(context, VpnProfileImportActivity::class.java)) },
|
||||
onQr = { scanner.launch(ScanOptions().setDesiredBarcodeFormats(ScanOptions.QR_CODE).setBeepEnabled(false).setOrientationLocked(true).setPrompt("")) },
|
||||
)
|
||||
|
||||
val others = all.filter { it.getUUID().toString() != uuid }
|
||||
fun nameOf(other: String?) = others.firstOrNull { it.getUUID().toString() == other }?.let { it.name ?: it.gateway }
|
||||
/* a reserve or bridge that signs in with an unsaved password cannot be switched to */
|
||||
fun lacksPassword(other: String?) = others.firstOrNull { it.getUUID().toString() == other }?.needsPassword() ?: false
|
||||
Group(stringResource(R.string.server_group_server))
|
||||
{
|
||||
ValueRow(stringResource(R.string.server_addr), profile.gateway + (profile.port?.let { ":$it" } ?: ""), onClick = ::edit, divider = true)
|
||||
ValueRow(stringResource(R.string.server_login), stringResource(loginLabel(profile.vpnType)), onClick = ::edit)
|
||||
}
|
||||
Group(stringResource(R.string.server_group_resilience))
|
||||
{
|
||||
ValueRow(
|
||||
stringResource(R.string.server_ports), extra.backupPorts.joinToString(", ").ifEmpty { stringResource(R.string.server_none) },
|
||||
sub = stringResource(R.string.server_ports_sub), onClick = { showPorts = true }, divider = true,
|
||||
)
|
||||
ValueRow(
|
||||
stringResource(R.string.server_reserve), nameOf(extra.reserveUuid) ?: stringResource(R.string.server_none),
|
||||
sub = stringResource(R.string.server_reserve_sub, RESERVE_TIMEOUT_S), onClick = { picker = Picker.RESERVE },
|
||||
divider = !lacksPassword(extra.reserveUuid),
|
||||
)
|
||||
if (lacksPassword(extra.reserveUuid))
|
||||
{
|
||||
PasswordWarning(divider = true)
|
||||
}
|
||||
val bridgeName = nameOf(extra.bridgeUuid)
|
||||
ToggleRow(
|
||||
stringResource(R.string.server_bridge),
|
||||
if (bridgeName != null) stringResource(R.string.server_bridge_sub, bridgeName) else stringResource(R.string.server_bridge_pick),
|
||||
extra.bridgeEnabled && bridgeName != null,
|
||||
) { on ->
|
||||
if (on && bridgeName == null) picker = Picker.BRIDGE
|
||||
else ProfileExtras.update(context, uuid) { it.copy(bridgeEnabled = on) }
|
||||
}
|
||||
if (extra.bridgeEnabled && lacksPassword(extra.bridgeUuid))
|
||||
{
|
||||
PasswordWarning(divider = false)
|
||||
}
|
||||
}
|
||||
Group(stringResource(R.string.server_group_network))
|
||||
{
|
||||
ValueRow("DNS", profile.dnsServers?.takeIf { it.isNotBlank() }?.replace(" ", ", ") ?: stringResource(R.string.server_dns_default), onClick = ::edit, divider = true)
|
||||
ValueRow("MTU", profile.mtu?.toString() ?: stringResource(R.string.server_auto_value), onClick = ::edit, divider = true)
|
||||
ToggleRow(stringResource(R.string.server_fragmentation), null, extra.ikeFragmentation) { on ->
|
||||
ProfileExtras.update(context, uuid) { it.copy(ikeFragmentation = on) }
|
||||
}
|
||||
}
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.padding(top = 4.dp))
|
||||
{
|
||||
ShxButton(stringResource(R.string.server_export), { export() }, style = ShxButtonStyle.OUTLINE, height = 52.dp, modifier = Modifier.weight(1f))
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier.weight(1f).height(52.dp).clip(RoundedCornerShape(18.dp)).border(1.dp, c.errorBorder, RoundedCornerShape(18.dp))
|
||||
.clickable(role = Role.Button) { showDelete = true },
|
||||
)
|
||||
{
|
||||
Text(stringResource(R.string.server_delete_confirm), style = ShxType.body.copy(fontWeight = FontWeight.Bold), color = c.error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
ShxSnackbarHost(snackbar, Modifier.align(Alignment.BottomCenter).navigationBarsPadding())
|
||||
}
|
||||
|
||||
if (showShare)
|
||||
{
|
||||
ShareProfileScreen(profile, onDismiss = { showShare = false }, onSendFile = { export() })
|
||||
}
|
||||
check?.let { found ->
|
||||
ProfileUpdateSheet(
|
||||
name = profile.name ?: profile.gateway,
|
||||
check = found,
|
||||
onLater = { check = null },
|
||||
onApply = {
|
||||
check = null
|
||||
scope.launch {
|
||||
withContext(Dispatchers.IO) { ProfileUpdater.apply(context, found) }
|
||||
reloads++
|
||||
snackbar.showSnackbar(updated)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
if (showDelete)
|
||||
{
|
||||
ShxDialog(
|
||||
title = stringResource(R.string.server_delete_title, profile.name ?: profile.gateway),
|
||||
paragraphs = listOfNotNull(
|
||||
if (active && vpn?.first != VpnStateService.State.DISABLED) stringResource(R.string.server_delete_current) else null,
|
||||
stringResource(R.string.server_delete_restore),
|
||||
),
|
||||
confirm = stringResource(R.string.server_delete_confirm),
|
||||
dismiss = stringResource(android.R.string.cancel),
|
||||
onConfirm = {
|
||||
showDelete = false
|
||||
scope.launch {
|
||||
withContext(Dispatchers.IO) {
|
||||
if (active) VpnStateService.getLiveInstance()?.disconnect()
|
||||
val source = VpnProfileSource(context)
|
||||
source.open()
|
||||
try { source.deleteVpnProfile(profile) } finally { source.close() }
|
||||
}
|
||||
val intent = Intent(Constants.VPN_PROFILES_CHANGED).putExtra(Constants.VPN_PROFILES_MULTIPLE, arrayOf(uuid))
|
||||
LocalBroadcastManager.getInstance(context).sendBroadcast(intent)
|
||||
onBack()
|
||||
}
|
||||
},
|
||||
onDismiss = { showDelete = false },
|
||||
icon = ShxIcons.Trash,
|
||||
danger = true,
|
||||
)
|
||||
}
|
||||
if (showLink)
|
||||
{
|
||||
TextDialog(
|
||||
title = stringResource(R.string.server_link_title),
|
||||
label = stringResource(R.string.server_link_label),
|
||||
initial = extra.sourceValue?.takeIf { extra.source == ProfileSourceType.LINK } ?: "https://",
|
||||
keyboard = KeyboardType.Uri,
|
||||
validate = { it.startsWith("https://") && it.length > 10 },
|
||||
onDone = { link ->
|
||||
showLink = false
|
||||
ProfileExtras.update(context, uuid) { it.copy(source = ProfileSourceType.LINK, sourceValue = link.trim(), autoUpdate = true) }
|
||||
runCheck()
|
||||
},
|
||||
onDismiss = { showLink = false },
|
||||
)
|
||||
}
|
||||
if (showPorts)
|
||||
{
|
||||
TextDialog(
|
||||
title = stringResource(R.string.server_ports),
|
||||
label = stringResource(R.string.server_ports_label),
|
||||
initial = extra.backupPorts.joinToString(", "),
|
||||
keyboard = KeyboardType.Number,
|
||||
validate = { text -> parsePorts(text) != null },
|
||||
onDone = { text ->
|
||||
showPorts = false
|
||||
ProfileExtras.update(context, uuid) { it.copy(backupPorts = parsePorts(text).orEmpty()) }
|
||||
},
|
||||
onDismiss = { showPorts = false },
|
||||
)
|
||||
}
|
||||
picker?.let { which ->
|
||||
val others = all.filter { it.getUUID().toString() != uuid }
|
||||
ShxSheet(onDismiss = { picker = null })
|
||||
{
|
||||
ShxSheetTitle(stringResource(if (which == Picker.RESERVE) R.string.server_reserve else R.string.server_bridge_title))
|
||||
Text(
|
||||
stringResource(if (which == Picker.RESERVE) R.string.server_reserve_pick_note else R.string.server_bridge_pick_note),
|
||||
style = ShxType.note.copy(fontWeight = FontWeight.Normal, lineHeight = 19.sp), color = c.textSoft, modifier = Modifier.padding(horizontal = 4.dp),
|
||||
)
|
||||
ShxPanel()
|
||||
{
|
||||
val current = if (which == Picker.RESERVE) extra.reserveUuid else extra.bridgeUuid
|
||||
val rows = listOf<VpnProfile?>(null) + others
|
||||
rows.forEachIndexed { i, other ->
|
||||
val id = other?.getUUID()?.toString()
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
modifier = Modifier.fillMaxWidth().heightIn(min = 56.dp).clickable(role = Role.Button) {
|
||||
picker = null
|
||||
ProfileExtras.update(context, uuid) {
|
||||
if (which == Picker.RESERVE) it.copy(reserveUuid = id) else it.copy(bridgeUuid = id, bridgeEnabled = id != null)
|
||||
}
|
||||
}.padding(horizontal = 16.dp),
|
||||
)
|
||||
{
|
||||
Text(other?.let { it.name ?: it.gateway } ?: stringResource(R.string.server_none), style = ShxType.rowTitle.copy(fontSize = 15.sp), color = c.text, modifier = Modifier.weight(1f))
|
||||
if (current == id)
|
||||
{
|
||||
Icon(ShxIcons.Check, contentDescription = null, tint = c.accent, modifier = Modifier.size(20.dp))
|
||||
}
|
||||
}
|
||||
if (i < rows.lastIndex)
|
||||
{
|
||||
Box(Modifier.fillMaxWidth().height(1.dp).background(c.tile))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** «No saved password» note under a reserve / bridge row. */
|
||||
@Composable
|
||||
private fun PasswordWarning(divider: Boolean)
|
||||
{
|
||||
val c = LocalShxColors.current
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier.fillMaxWidth().padding(start = 16.dp, end = 14.dp, bottom = 12.dp),
|
||||
)
|
||||
{
|
||||
Icon(ShxIcons.Alert, contentDescription = null, tint = c.onWarningContainer, modifier = Modifier.size(16.dp))
|
||||
Text(stringResource(R.string.server_needs_password), style = ShxType.meta.copy(fontWeight = FontWeight.SemiBold, lineHeight = 16.sp), color = c.onWarningContainer)
|
||||
}
|
||||
if (divider)
|
||||
{
|
||||
Divider()
|
||||
}
|
||||
}
|
||||
|
||||
/** «443, 53» → [443, 53]; null when something is not a port. */
|
||||
internal fun parsePorts(text: String): List<Int>?
|
||||
{
|
||||
val parts = text.split(',', ' ', ';').map { it.trim() }.filter { it.isNotEmpty() }
|
||||
val ports = parts.map { it.toIntOrNull()?.takeIf { p -> p in 1..65535 } ?: return null }
|
||||
return ports.distinct()
|
||||
}
|
||||
|
||||
private fun loginLabel(type: VpnType): Int = when (type)
|
||||
{
|
||||
VpnType.IKEV2_CERT, VpnType.IKEV2_EAP_TLS -> R.string.server_login_cert
|
||||
VpnType.IKEV2_CERT_EAP -> R.string.server_login_both
|
||||
else -> R.string.server_login_password
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SourceSection(
|
||||
extra: ProfileExtra,
|
||||
checking: Boolean,
|
||||
onEditLink: () -> Unit,
|
||||
onAuto: (Boolean) -> Unit,
|
||||
onCheck: () -> Unit,
|
||||
onFile: () -> Unit,
|
||||
onQr: () -> Unit,
|
||||
)
|
||||
{
|
||||
val c = LocalShxColors.current
|
||||
val date = if (extra.addedAt > 0) SimpleDateFormat("d MMM", Locale.getDefault()).format(Date(extra.addedAt)) else null
|
||||
val type = if (extra.source == ProfileSourceType.UNKNOWN) ProfileSourceType.FILE else extra.source
|
||||
ShxSectionHeader(stringResource(R.string.server_source).uppercase(), Modifier.padding(start = 6.dp, end = 6.dp, top = 6.dp))
|
||||
ShxPanel()
|
||||
{
|
||||
val (icon, label, value) = when (type)
|
||||
{
|
||||
ProfileSourceType.LINK -> Triple(ShxIcons.Globe, stringResource(R.string.server_source_link), extra.sourceValue.orEmpty().removePrefix("https://"))
|
||||
ProfileSourceType.QR -> Triple(ShxIcons.Scan, stringResource(R.string.server_source_qr), date?.let { stringResource(R.string.server_source_scanned, it) } ?: "")
|
||||
ProfileSourceType.MANUAL -> Triple(ShxIcons.Sliders, stringResource(R.string.server_source_manual), date?.let { stringResource(R.string.server_source_created, it) } ?: "")
|
||||
else -> Triple(
|
||||
ShxIcons.Doc, stringResource(R.string.server_source_file),
|
||||
listOfNotNull(extra.sourceValue, date?.let { stringResource(R.string.server_source_added, it) }).joinToString(" · ")
|
||||
.ifEmpty { stringResource(R.string.server_source_earlier) },
|
||||
)
|
||||
}
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
.then(if (type == ProfileSourceType.LINK) Modifier.clickable(role = Role.Button, onClick = onEditLink) else Modifier)
|
||||
.padding(start = 16.dp, end = 14.dp, top = 12.dp, bottom = 12.dp),
|
||||
)
|
||||
{
|
||||
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(36.dp).clip(RoundedCornerShape(12.dp)).background(c.tile))
|
||||
{
|
||||
Icon(icon, contentDescription = null, tint = c.onAccentContainer, modifier = Modifier.size(18.dp))
|
||||
}
|
||||
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp))
|
||||
{
|
||||
Text(label, style = ShxType.chip, color = c.textSub)
|
||||
Text(
|
||||
value, maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||
style = ShxType.body.copy(fontWeight = FontWeight.Bold, fontFamily = if (type == ProfileSourceType.LINK) FontFamily.Monospace else ShxType.body.fontFamily),
|
||||
color = c.text,
|
||||
)
|
||||
}
|
||||
if (type == ProfileSourceType.LINK)
|
||||
{
|
||||
Icon(ShxIcons.Sliders, contentDescription = stringResource(R.string.server_link_title), tint = c.textSub, modifier = Modifier.size(18.dp))
|
||||
}
|
||||
}
|
||||
Divider()
|
||||
if (type == ProfileSourceType.LINK)
|
||||
{
|
||||
val now = System.currentTimeMillis()
|
||||
val checked = when
|
||||
{
|
||||
extra.checkedAt <= 0 -> null
|
||||
now - extra.checkedAt < DateUtils.MINUTE_IN_MILLIS -> stringResource(R.string.server_just_now)
|
||||
else -> DateUtils.getRelativeTimeSpanString(extra.checkedAt, now, DateUtils.MINUTE_IN_MILLIS).toString()
|
||||
}
|
||||
val result = when
|
||||
{
|
||||
extra.lastChanges < 0 -> stringResource(R.string.server_auto_failed)
|
||||
extra.lastChanges > 0 -> stringResource(R.string.server_auto_changes, extra.lastChanges)
|
||||
else -> stringResource(R.string.server_auto_same)
|
||||
}
|
||||
ToggleRow(
|
||||
stringResource(R.string.server_auto),
|
||||
listOfNotNull(stringResource(R.string.server_auto_daily), checked?.let { stringResource(R.string.server_auto_checked, it) }, if (checked != null) result else null).joinToString(" · "),
|
||||
extra.autoUpdate, onAuto,
|
||||
)
|
||||
Divider()
|
||||
}
|
||||
else
|
||||
{
|
||||
Text(
|
||||
stringResource(
|
||||
when (type)
|
||||
{
|
||||
ProfileSourceType.QR -> R.string.server_note_qr
|
||||
ProfileSourceType.MANUAL -> R.string.server_note_manual
|
||||
else -> R.string.server_note_file
|
||||
}
|
||||
),
|
||||
style = ShxType.meta.copy(fontWeight = FontWeight.Normal, lineHeight = 17.sp), color = c.textSub,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp),
|
||||
)
|
||||
if (type != ProfileSourceType.MANUAL)
|
||||
{
|
||||
Divider()
|
||||
}
|
||||
}
|
||||
if (type != ProfileSourceType.MANUAL)
|
||||
{
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.height(androidx.compose.foundation.layout.IntrinsicSize.Min).padding(horizontal = 14.dp, vertical = 12.dp))
|
||||
{
|
||||
when (type)
|
||||
{
|
||||
ProfileSourceType.LINK -> SoftButton(stringResource(if (checking) R.string.server_checking else R.string.server_check_now), onCheck, Modifier.weight(1f))
|
||||
ProfileSourceType.QR -> SoftButton(stringResource(R.string.server_replace_qr), onQr, Modifier.weight(1f))
|
||||
else ->
|
||||
{
|
||||
SoftButton(stringResource(R.string.server_replace_file), onFile, Modifier.weight(1f))
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier.weight(1f).heightIn(min = 44.dp).clip(RoundedCornerShape(14.dp)).border(1.dp, c.outline, RoundedCornerShape(14.dp))
|
||||
.clickable(role = Role.Button, onClick = onEditLink).padding(horizontal = 10.dp, vertical = 6.dp),
|
||||
)
|
||||
{
|
||||
Text(stringResource(R.string.server_set_link), style = ShxType.body.copy(fontWeight = FontWeight.Bold), color = c.text)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SoftButton(text: String, onClick: () -> Unit, modifier: Modifier)
|
||||
{
|
||||
val c = LocalShxColors.current
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = modifier.heightIn(min = 44.dp).clip(RoundedCornerShape(14.dp)).background(c.accentMuted).clickable(role = Role.Button, onClick = onClick)
|
||||
.padding(horizontal = 10.dp, vertical = 6.dp),
|
||||
)
|
||||
{
|
||||
Text(text, style = ShxType.body.copy(fontWeight = FontWeight.ExtraBold), color = c.textTonal, maxLines = 2, textAlign = androidx.compose.ui.text.style.TextAlign.Center)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Divider()
|
||||
{
|
||||
Box(Modifier.fillMaxWidth().height(1.dp).background(LocalShxColors.current.tile))
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Group(title: String, content: @Composable ColumnScope.() -> Unit)
|
||||
{
|
||||
Column(verticalArrangement = Arrangement.spacedBy(6.dp))
|
||||
{
|
||||
ShxSectionHeader(title.uppercase(), Modifier.padding(start = 6.dp, end = 6.dp, top = 6.dp))
|
||||
ShxPanel(content = content)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ValueRow(title: String, value: String, sub: String? = null, onClick: () -> Unit, divider: Boolean = false)
|
||||
{
|
||||
val c = LocalShxColors.current
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
modifier = Modifier.fillMaxWidth().heightIn(min = 58.dp).clickable(role = Role.Button, onClick = onClick).padding(start = 16.dp, end = 14.dp, top = 8.dp, bottom = 8.dp),
|
||||
)
|
||||
{
|
||||
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp))
|
||||
{
|
||||
Text(title, style = ShxType.rowTitle.copy(fontSize = 15.sp), color = c.text)
|
||||
sub?.let { Text(it, style = ShxType.meta.copy(fontWeight = FontWeight.Normal, lineHeight = 16.sp), color = c.textSub) }
|
||||
}
|
||||
Text(value, style = ShxType.body, color = c.textSub, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(0.8f, fill = false))
|
||||
Icon(ShxIcons.Chevron, contentDescription = null, tint = c.textSub, modifier = Modifier.size(18.dp))
|
||||
}
|
||||
if (divider)
|
||||
{
|
||||
Divider()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ToggleRow(title: String, sub: String?, checked: Boolean, onChange: (Boolean) -> Unit)
|
||||
{
|
||||
val c = LocalShxColors.current
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
modifier = Modifier.fillMaxWidth().heightIn(min = 58.dp).padding(start = 16.dp, end = 14.dp, top = 8.dp, bottom = 8.dp),
|
||||
)
|
||||
{
|
||||
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp))
|
||||
{
|
||||
Text(title, style = ShxType.rowTitle.copy(fontSize = 15.sp), color = c.text)
|
||||
sub?.let { Text(it, style = ShxType.meta.copy(fontWeight = FontWeight.Normal, lineHeight = 16.sp), color = c.textSub) }
|
||||
}
|
||||
ShxSwitch(checked, onChange)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TextDialog(
|
||||
title: String,
|
||||
label: String,
|
||||
initial: String,
|
||||
keyboard: KeyboardType,
|
||||
validate: (String) -> Boolean,
|
||||
onDone: (String) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
)
|
||||
{
|
||||
var text by remember { mutableStateOf(initial) }
|
||||
ShxAlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(title) },
|
||||
text = {
|
||||
ShxFormField(label = label, value = text, onValueChange = { text = it }, keyboardOptions = KeyboardOptions(keyboardType = keyboard))
|
||||
},
|
||||
confirmButton = { ShxDialogButton(primary = true, onClick = { onDone(text) }, enabled = validate(text.trim())) { Text(stringResource(R.string.server_save)) } },
|
||||
dismissButton = { ShxDialogButton(onClick = onDismiss) { Text(stringResource(android.R.string.cancel)) } },
|
||||
)
|
||||
}
|
||||
|
||||
/** «The profile has changes» (F_ProfileUpdate): old → new per field, «Later» / «Apply and reconnect». */
|
||||
@Composable
|
||||
private fun ProfileUpdateSheet(name: String, check: ProfileCheck, onLater: () -> Unit, onApply: () -> Unit)
|
||||
{
|
||||
val c = LocalShxColors.current
|
||||
ShxSheet(onDismiss = onLater)
|
||||
{
|
||||
Column(verticalArrangement = Arrangement.spacedBy(14.dp))
|
||||
{
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp), modifier = Modifier.padding(horizontal = 4.dp))
|
||||
{
|
||||
Text(stringResource(R.string.profile_update_title), style = ShxType.sheetTitle, color = c.text)
|
||||
Text(stringResource(R.string.profile_update_sub, name), style = ShxType.body.copy(fontWeight = FontWeight.Normal), color = c.textSub)
|
||||
}
|
||||
ShxPanel()
|
||||
{
|
||||
check.changes.forEachIndexed { i, change ->
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp), modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp))
|
||||
{
|
||||
Text(change.label, style = ShxType.chip, color = c.textSub)
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp))
|
||||
{
|
||||
val mono = ShxType.body.copy(fontWeight = FontWeight.Bold, fontFamily = FontFamily.Monospace)
|
||||
Text(change.old, style = mono.copy(textDecoration = TextDecoration.LineThrough), color = c.iconMuted, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f, fill = false))
|
||||
Icon(ShxIcons.Chevron, contentDescription = null, tint = c.textSub, modifier = Modifier.size(16.dp))
|
||||
Text(change.new, style = mono, color = c.text, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f, fill = false))
|
||||
}
|
||||
}
|
||||
if (i < check.changes.lastIndex)
|
||||
{
|
||||
Divider()
|
||||
}
|
||||
}
|
||||
}
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(18.dp)).background(c.successContainer).padding(horizontal = 14.dp, vertical = 12.dp),
|
||||
)
|
||||
{
|
||||
Icon(ShxIcons.Shield, contentDescription = null, tint = c.onSuccess, modifier = Modifier.size(20.dp))
|
||||
Text(stringResource(R.string.profile_update_keep), style = ShxType.note.copy(lineHeight = 19.sp, fontWeight = FontWeight.Normal), color = c.onSuccess)
|
||||
}
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(10.dp))
|
||||
{
|
||||
ShxButton(stringResource(R.string.cert_later), onLater, style = ShxButtonStyle.OUTLINE, modifier = Modifier.weight(1f))
|
||||
ShxButton(stringResource(R.string.profile_update_apply), onApply, modifier = Modifier.weight(2f))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+20
-3
@@ -15,9 +15,14 @@ import androidx.activity.enableEdgeToEdge
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.strongswan.android.R
|
||||
import org.strongswan.android.data.VpnProfile
|
||||
import org.strongswan.android.data.VpnProfileSource
|
||||
import org.strongswan.android.ui.compose.SecondaryScaffold
|
||||
import org.strongswan.android.ui.compose.SettingsScreen
|
||||
@@ -29,11 +34,23 @@ class SettingsActivity : AppCompatActivity()
|
||||
{
|
||||
super.onCreate(savedInstanceState)
|
||||
enableEdgeToEdge()
|
||||
val source = VpnProfileSource(this).open()
|
||||
val profiles = source.allVpnProfiles
|
||||
source.close()
|
||||
setContent {
|
||||
ShxTheme {
|
||||
/* the profile list is only needed for the default-profile picker;
|
||||
* load it off the main thread */
|
||||
val profiles by produceState<List<VpnProfile>>(emptyList(), this@SettingsActivity) {
|
||||
value = withContext(Dispatchers.IO) {
|
||||
val source = VpnProfileSource(this@SettingsActivity).open()
|
||||
try
|
||||
{
|
||||
source.allVpnProfiles
|
||||
}
|
||||
finally
|
||||
{
|
||||
source.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
SecondaryScaffold(title = stringResource(R.string.pref_title), onBack = { finish() }) { padding ->
|
||||
Box(Modifier.padding(padding)) {
|
||||
SettingsScreen(profiles = profiles, showTitle = false)
|
||||
|
||||
-155
@@ -1,155 +0,0 @@
|
||||
/*
|
||||
* Copyright (C) 2018 Tobias Brunner
|
||||
*
|
||||
* Copyright (C) secunet Security Networks AG
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or modify it
|
||||
* under the terms of the GNU General Public License as published by the
|
||||
* Free Software Foundation; either version 2 of the License, or (at your
|
||||
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful, but
|
||||
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
||||
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
|
||||
* for more details.
|
||||
*/
|
||||
|
||||
package org.strongswan.android.ui;
|
||||
|
||||
import static org.strongswan.android.utils.Constants.PREF_DEFAULT_VPN_PROFILE;
|
||||
import static org.strongswan.android.utils.Constants.PREF_DEFAULT_VPN_PROFILE_MRU;
|
||||
import static org.strongswan.android.utils.Constants.PREF_IGNORE_POWER_WHITELIST;
|
||||
|
||||
import android.content.SharedPreferences;
|
||||
import android.os.Build;
|
||||
import android.os.Bundle;
|
||||
|
||||
import org.strongswan.android.R;
|
||||
import org.strongswan.android.data.ManagedConfiguration;
|
||||
import org.strongswan.android.data.ManagedConfigurationService;
|
||||
import org.strongswan.android.data.VpnProfile;
|
||||
import org.strongswan.android.data.VpnProfileDataSource;
|
||||
import org.strongswan.android.data.VpnProfileSource;
|
||||
import org.strongswan.android.logic.StrongSwanApplication;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
|
||||
import androidx.preference.ListPreference;
|
||||
import androidx.preference.Preference;
|
||||
import androidx.preference.PreferenceFragmentCompat;
|
||||
import androidx.preference.PreferenceManager;
|
||||
import androidx.preference.SwitchPreference;
|
||||
|
||||
public class SettingsFragment extends PreferenceFragmentCompat implements Preference.OnPreferenceChangeListener
|
||||
{
|
||||
private ManagedConfigurationService mManagedConfigurationService;
|
||||
|
||||
private ListPreference mDefaultVPNProfile;
|
||||
private SwitchPreference mIgnorePowerWhitelist;
|
||||
|
||||
@Override
|
||||
public void onCreatePreferences(Bundle bundle, String s)
|
||||
{
|
||||
mManagedConfigurationService = StrongSwanApplication.getInstance().getManagedConfigurationService();
|
||||
mManagedConfigurationService.updateSettings();
|
||||
|
||||
setPreferencesFromResource(R.xml.settings, s);
|
||||
|
||||
mDefaultVPNProfile = findPreference(PREF_DEFAULT_VPN_PROFILE);
|
||||
mDefaultVPNProfile.setOnPreferenceChangeListener(this);
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N)
|
||||
{
|
||||
mDefaultVPNProfile.setEnabled(false);
|
||||
}
|
||||
|
||||
mIgnorePowerWhitelist = findPreference(PREF_IGNORE_POWER_WHITELIST);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onResume()
|
||||
{
|
||||
super.onResume();
|
||||
|
||||
VpnProfileDataSource profiles = new VpnProfileSource(getActivity());
|
||||
profiles.open();
|
||||
|
||||
List<VpnProfile> all = profiles.getAllVpnProfiles();
|
||||
Collections.sort(all, new Comparator<VpnProfile>()
|
||||
{
|
||||
@Override
|
||||
public int compare(VpnProfile lhs, VpnProfile rhs)
|
||||
{
|
||||
return lhs.getName().compareToIgnoreCase(rhs.getName());
|
||||
}
|
||||
});
|
||||
|
||||
ArrayList<CharSequence> entries = new ArrayList<>();
|
||||
ArrayList<CharSequence> entryvalues = new ArrayList<>();
|
||||
|
||||
entries.add(getString(R.string.pref_default_vpn_profile_mru));
|
||||
entryvalues.add(PREF_DEFAULT_VPN_PROFILE_MRU);
|
||||
|
||||
for (VpnProfile profile : all)
|
||||
{
|
||||
entries.add(profile.getName());
|
||||
entryvalues.add(profile.getUUID().toString());
|
||||
}
|
||||
profiles.close();
|
||||
|
||||
final ManagedConfiguration managedConfiguration = mManagedConfigurationService.getManagedConfiguration();
|
||||
if (entries.size() <= 1 || !managedConfiguration.isAllowSettingsAccess())
|
||||
{
|
||||
mDefaultVPNProfile.setEnabled(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
mDefaultVPNProfile.setEnabled(true);
|
||||
mDefaultVPNProfile.setEntries(entries.toArray(new CharSequence[0]));
|
||||
mDefaultVPNProfile.setEntryValues(entryvalues.toArray(new CharSequence[0]));
|
||||
}
|
||||
if (!managedConfiguration.isAllowSettingsAccess())
|
||||
{
|
||||
mIgnorePowerWhitelist.setEnabled(false);
|
||||
}
|
||||
|
||||
SharedPreferences pref = PreferenceManager.getDefaultSharedPreferences(getActivity());
|
||||
setCurrentProfileName(pref.getString(PREF_DEFAULT_VPN_PROFILE, PREF_DEFAULT_VPN_PROFILE_MRU));
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onPreferenceChange(Preference preference, Object newValue)
|
||||
{
|
||||
if (preference == mDefaultVPNProfile)
|
||||
{
|
||||
setCurrentProfileName((String)newValue);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private void setCurrentProfileName(String uuid)
|
||||
{
|
||||
VpnProfileDataSource profiles = new VpnProfileSource(getActivity());
|
||||
profiles.open();
|
||||
|
||||
if (!uuid.equals(PREF_DEFAULT_VPN_PROFILE_MRU))
|
||||
{
|
||||
VpnProfile current = profiles.getVpnProfile(uuid);
|
||||
if (current != null)
|
||||
{
|
||||
mDefaultVPNProfile.setSummary(current.getName());
|
||||
}
|
||||
else
|
||||
{
|
||||
mDefaultVPNProfile.setSummary(R.string.profile_not_found);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
mDefaultVPNProfile.setSummary(R.string.pref_default_vpn_profile_mru);
|
||||
}
|
||||
profiles.close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,370 @@
|
||||
/*
|
||||
* Copyright (C) 2026 SHX VPN
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or modify it
|
||||
* under the terms of the GNU General Public License as published by the
|
||||
* Free Software Foundation; either version 2 of the License, or (at your
|
||||
* option) any later version.
|
||||
*/
|
||||
|
||||
package org.strongswan.android.ui
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.provider.Settings
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.wrapContentWidth
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.drawBehind
|
||||
import androidx.compose.ui.geometry.CornerRadius
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.PathEffect
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.core.graphics.drawable.toBitmap
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.strongswan.android.R
|
||||
import org.strongswan.android.logic.routing.AppTraffic
|
||||
import org.strongswan.android.logic.routing.RoutingManager
|
||||
import org.strongswan.android.logic.stats.DayTraffic
|
||||
import org.strongswan.android.logic.stats.TrafficHistory
|
||||
import org.strongswan.android.ui.compose.SecondaryScaffold
|
||||
import org.strongswan.android.ui.compose.design.ShxIcons
|
||||
import org.strongswan.android.ui.compose.design.ShxTabs
|
||||
import org.strongswan.android.ui.compose.formatBytes
|
||||
import org.strongswan.android.ui.compose.theme.LocalShxColors
|
||||
import org.strongswan.android.ui.compose.theme.ShxTheme
|
||||
import org.strongswan.android.ui.compose.theme.ShxType
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Calendar
|
||||
import java.util.Locale
|
||||
|
||||
/** «Statistics» (F_Stats / F_StatsEmpty): traffic per day, VPN vs direct, top apps. */
|
||||
class StatsActivity : AppCompatActivity()
|
||||
{
|
||||
override fun onCreate(savedInstanceState: Bundle?)
|
||||
{
|
||||
super.onCreate(savedInstanceState)
|
||||
enableEdgeToEdge()
|
||||
setContent {
|
||||
ShxTheme {
|
||||
StatsScreen(onBack = { finish() })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private enum class Period(val label: Int) { DAY(R.string.stats_day), WEEK(R.string.stats_week), MONTH(R.string.stats_month) }
|
||||
|
||||
/** One app row: label, icon, bytes and whether it goes direct. */
|
||||
private class AppRow(val label: String, val icon: android.graphics.drawable.Drawable?, val bytes: Long, val direct: Boolean)
|
||||
|
||||
private class StatsData(val days: List<DayTraffic>, val title: String, val labels: List<String>, val highlight: Int, val first: String?, val apps: List<AppRow>, val usage: Boolean)
|
||||
|
||||
private fun startOfDay(ms: Long): Long = Calendar.getInstance().apply {
|
||||
timeInMillis = ms; set(Calendar.HOUR_OF_DAY, 0); set(Calendar.MINUTE, 0); set(Calendar.SECOND, 0); set(Calendar.MILLISECOND, 0)
|
||||
}.timeInMillis
|
||||
|
||||
private fun load(context: Context, period: Period): StatsData
|
||||
{
|
||||
val now = System.currentTimeMillis()
|
||||
val day = 24L * 60 * 60 * 1000
|
||||
val cal = Calendar.getInstance()
|
||||
val (from, to) = when (period)
|
||||
{
|
||||
/* the day view still shows the last week, today highlighted */
|
||||
Period.DAY -> startOfDay(now) - 6 * day to now
|
||||
Period.WEEK ->
|
||||
{
|
||||
cal.timeInMillis = startOfDay(now)
|
||||
cal.firstDayOfWeek = Calendar.MONDAY
|
||||
cal.set(Calendar.DAY_OF_WEEK, Calendar.MONDAY)
|
||||
if (cal.timeInMillis > now) cal.add(Calendar.WEEK_OF_YEAR, -1)
|
||||
cal.timeInMillis to cal.timeInMillis + 6 * day
|
||||
}
|
||||
Period.MONTH ->
|
||||
{
|
||||
cal.timeInMillis = startOfDay(now)
|
||||
cal.set(Calendar.DAY_OF_MONTH, 1)
|
||||
val start = cal.timeInMillis
|
||||
start to start + (cal.getActualMaximum(Calendar.DAY_OF_MONTH) - 1) * day
|
||||
}
|
||||
}
|
||||
val days = TrafficHistory.range(context, from, to)
|
||||
val dow = SimpleDateFormat("EEEEEE", Locale.getDefault())
|
||||
val parse = SimpleDateFormat("yyyy-MM-dd", Locale.US)
|
||||
val labels = days.mapIndexed { i, d ->
|
||||
val date = parse.parse(d.day)!!
|
||||
when (period)
|
||||
{
|
||||
Period.MONTH -> if (i % 5 == 0) (i + 1).toString() else ""
|
||||
else -> dow.format(date).replaceFirstChar { it.uppercase() }
|
||||
}
|
||||
}
|
||||
val today = TrafficHistory.dayKey(now)
|
||||
val highlight = days.indexOfFirst { it.day == today }
|
||||
val title = when (period)
|
||||
{
|
||||
Period.DAY -> context.getString(R.string.stats_today)
|
||||
Period.WEEK ->
|
||||
{
|
||||
val f = SimpleDateFormat("d MMMM", Locale.getDefault())
|
||||
val a = parse.parse(days.first().day)!!
|
||||
val b = parse.parse(days.last().day)!!
|
||||
SimpleDateFormat("d", Locale.getDefault()).format(a) + " – " + f.format(b)
|
||||
}
|
||||
Period.MONTH -> SimpleDateFormat("LLLL yyyy", Locale.getDefault()).format(parse.parse(days.first().day)!!).replaceFirstChar { it.uppercase() }
|
||||
}
|
||||
/* apps: system counters over the same period (needs usage access) */
|
||||
val usage = AppTraffic.hasAccess(context)
|
||||
val since = if (period == Period.DAY) startOfDay(now) else from
|
||||
val direct = runCatching { RoutingManager.getInstance(context).computeExcludedPackages() }.getOrDefault(emptySet())
|
||||
val pm = context.packageManager
|
||||
val apps = if (!usage) emptyList() else AppTraffic.since(context, since).entries.sortedByDescending { it.value }.take(5).map { (pkg, bytes) ->
|
||||
val info = runCatching { pm.getApplicationInfo(pkg, 0) }.getOrNull()
|
||||
AppRow(info?.let { pm.getApplicationLabel(it).toString() } ?: pkg, info?.let { runCatching { pm.getApplicationIcon(it) }.getOrNull() }, bytes, pkg in direct)
|
||||
}
|
||||
return StatsData(days, title, labels, highlight, TrafficHistory.firstDay(context), apps, usage)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StatsScreen(onBack: () -> Unit)
|
||||
{
|
||||
val c = LocalShxColors.current
|
||||
val context = LocalContext.current
|
||||
var period by rememberSaveable { mutableIntStateOf(Period.WEEK.ordinal) }
|
||||
val changes by TrafficHistory.changes.collectAsStateWithLifecycle()
|
||||
val data by produceState<StatsData?>(null, period, changes) {
|
||||
value = withContext(Dispatchers.IO) { load(context, Period.entries[period]) }
|
||||
}
|
||||
SecondaryScaffold(title = stringResource(R.string.stats_title), onBack = onBack) { padding ->
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(14.dp),
|
||||
modifier = Modifier.fillMaxSize().padding(padding).verticalScroll(rememberScrollState()).padding(start = 16.dp, end = 16.dp, top = 4.dp, bottom = 24.dp),
|
||||
)
|
||||
{
|
||||
ShxTabs(Period.entries.map { stringResource(it.label) }, period, { period = it })
|
||||
val d = data ?: return@Column
|
||||
val today = TrafficHistory.dayKey(System.currentTimeMillis())
|
||||
/* F_StatsEmpty: nothing older than today yet */
|
||||
val fresh = d.first == null || d.first >= today
|
||||
val shown = if (Period.entries[period] == Period.DAY) d.days.filter { it.day == today } else d.days
|
||||
val vpn = shown.sumOf { it.vpn }
|
||||
val direct = shown.sumOf { it.direct }
|
||||
val shape = RoundedCornerShape(24.dp)
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(14.dp),
|
||||
modifier = Modifier.fillMaxWidth().clip(shape).background(c.card).border(1.dp, c.cardBorder, shape).padding(18.dp),
|
||||
)
|
||||
{
|
||||
Column(verticalArrangement = Arrangement.spacedBy(2.dp))
|
||||
{
|
||||
Text(d.title, style = ShxType.sub.copy(fontWeight = FontWeight.SemiBold), color = c.textSub)
|
||||
Text(formatBytes(vpn + direct), style = ShxType.number.copy(fontSize = 32.sp, lineHeight = 36.sp), color = c.text)
|
||||
}
|
||||
Bars(d, fresh, today)
|
||||
if (!fresh)
|
||||
{
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(10.dp))
|
||||
{
|
||||
Tile(stringResource(R.string.stats_vpn), formatBytes(vpn), c.accentContainer, c.onAccentContainer, Modifier.weight(1f))
|
||||
Tile(stringResource(R.string.stats_direct), formatBytes(direct), c.tile, c.textSoft, Modifier.weight(1f))
|
||||
}
|
||||
}
|
||||
}
|
||||
if (fresh)
|
||||
{
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(22.dp)).background(c.accentSoft).padding(16.dp),
|
||||
)
|
||||
{
|
||||
Icon(ShxIcons.Stats, contentDescription = null, tint = c.onAccentContainer, modifier = Modifier.size(22.dp))
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp))
|
||||
{
|
||||
Text(stringResource(R.string.stats_fresh_title), style = ShxType.cardTitle, color = c.text)
|
||||
Text(stringResource(R.string.stats_fresh_text), style = ShxType.note.copy(lineHeight = 19.sp, fontWeight = FontWeight.Normal), color = c.onAccentContainer)
|
||||
}
|
||||
}
|
||||
}
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp))
|
||||
{
|
||||
Text(stringResource(R.string.stats_apps), style = ShxType.cardTitle, color = c.text, modifier = Modifier.padding(horizontal = 4.dp))
|
||||
val panel = RoundedCornerShape(22.dp)
|
||||
Column(Modifier.fillMaxWidth().clip(panel).background(c.card).border(1.dp, c.cardBorder, panel).padding(horizontal = 14.dp, vertical = 4.dp))
|
||||
{
|
||||
when
|
||||
{
|
||||
!d.usage -> Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
modifier = Modifier.fillMaxWidth().clickable(role = Role.Button) {
|
||||
if (runCatching { context.startActivity(Intent(Settings.ACTION_USAGE_ACCESS_SETTINGS, Uri.fromParts("package", context.packageName, null))) }.isFailure)
|
||||
{
|
||||
runCatching { context.startActivity(Intent(Settings.ACTION_USAGE_ACCESS_SETTINGS)) }
|
||||
}
|
||||
}.padding(vertical = 14.dp),
|
||||
)
|
||||
{
|
||||
Text(stringResource(R.string.stats_apps_access), style = ShxType.note.copy(fontWeight = FontWeight.Normal), color = c.textSoft, modifier = Modifier.weight(1f))
|
||||
Text(stringResource(R.string.perm_allow), style = ShxType.chip.copy(fontWeight = FontWeight.ExtraBold), color = c.accentText)
|
||||
}
|
||||
d.apps.isEmpty() -> Text(stringResource(R.string.stats_apps_none), style = ShxType.note, color = c.textSub, modifier = Modifier.padding(vertical = 14.dp))
|
||||
else ->
|
||||
{
|
||||
val max = d.apps.maxOf { it.bytes }.coerceAtLeast(1)
|
||||
d.apps.forEach { AppLine(it, it.bytes.toFloat() / max) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Bars(d: StatsData, fresh: Boolean, today: String)
|
||||
{
|
||||
val c = LocalShxColors.current
|
||||
val max = d.days.maxOf { it.total }.coerceAtLeast(1)
|
||||
val month = d.days.size > 7
|
||||
val outline = c.outline
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(if (month) 3.dp else 10.dp),
|
||||
verticalAlignment = Alignment.Bottom,
|
||||
modifier = Modifier.fillMaxWidth().height(if (fresh) 140.dp else 170.dp),
|
||||
)
|
||||
{
|
||||
d.days.forEachIndexed { i, day ->
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.weight(1f))
|
||||
{
|
||||
Column(Modifier.weight(1f).fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(2.dp, Alignment.Bottom))
|
||||
{
|
||||
val before = d.first == null || day.day < d.first
|
||||
if (day.day > today)
|
||||
{ /* days still ahead: nothing to draw */
|
||||
}
|
||||
else if (before)
|
||||
{ /* no history yet: a dashed placeholder as on F_StatsEmpty */
|
||||
Box(
|
||||
Modifier.fillMaxWidth().height(34.dp).drawBehind {
|
||||
drawRoundRect(
|
||||
outline, cornerRadius = CornerRadius(6.dp.toPx()),
|
||||
style = Stroke(1.5.dp.toPx(), pathEffect = PathEffect.dashPathEffect(floatArrayOf(6f, 6f))),
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
else
|
||||
{
|
||||
val full = 150f
|
||||
val directH = (day.direct.toFloat() / max * full).coerceAtLeast(if (day.direct > 0) 3f else 0f)
|
||||
val vpnH = (day.vpn.toFloat() / max * full).coerceAtLeast(if (day.vpn > 0 || day.direct == 0L) 4f else 0f)
|
||||
if (directH > 0f)
|
||||
{
|
||||
Box(Modifier.fillMaxWidth().height(directH.dp).clip(RoundedCornerShape(topStart = 6.dp, topEnd = 6.dp, bottomStart = 2.dp, bottomEnd = 2.dp)).background(outline))
|
||||
}
|
||||
Box(
|
||||
Modifier.fillMaxWidth().height(vpnH.dp)
|
||||
.clip(if (directH > 0f) RoundedCornerShape(topStart = 2.dp, topEnd = 2.dp, bottomStart = 6.dp, bottomEnd = 6.dp) else RoundedCornerShape(6.dp))
|
||||
.background(if (fresh && day.day == today) c.accent else if (fresh) c.tile else c.accent),
|
||||
)
|
||||
}
|
||||
}
|
||||
Text(
|
||||
d.labels[i], style = ShxType.meta.copy(fontWeight = if (i == d.highlight) FontWeight.ExtraBold else FontWeight.SemiBold),
|
||||
color = if (i == d.highlight) c.text else c.textSub, maxLines = 1, softWrap = false,
|
||||
/* two-digit dates of the month view are wider than their thin bar */
|
||||
modifier = Modifier.wrapContentWidth(unbounded = true),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Tile(label: String, value: String, bg: Color, fg: Color, modifier: Modifier)
|
||||
{
|
||||
val c = LocalShxColors.current
|
||||
Column(verticalArrangement = Arrangement.spacedBy(2.dp), modifier = modifier.clip(RoundedCornerShape(16.dp)).background(bg).padding(12.dp))
|
||||
{
|
||||
Text(label, style = ShxType.meta.copy(fontWeight = FontWeight.Bold), color = fg)
|
||||
Text(value, style = ShxType.cardHeading.copy(fontSize = 20.sp), color = c.text)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AppLine(app: AppRow, share: Float)
|
||||
{
|
||||
val c = LocalShxColors.current
|
||||
val icon = remember(app) { app.icon?.toBitmap(96, 96)?.asImageBitmap() }
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.fillMaxWidth().padding(vertical = 10.dp))
|
||||
{
|
||||
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(36.dp).clip(RoundedCornerShape(10.dp)).background(c.tile))
|
||||
{
|
||||
if (icon != null)
|
||||
{
|
||||
Image(icon, contentDescription = null, modifier = Modifier.size(36.dp))
|
||||
}
|
||||
else
|
||||
{
|
||||
Text(app.label.take(1).uppercase(), style = ShxType.chip.copy(fontWeight = FontWeight.ExtraBold), color = c.textSoft)
|
||||
}
|
||||
}
|
||||
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(6.dp))
|
||||
{
|
||||
Row()
|
||||
{
|
||||
Text(app.label, style = ShxType.body.copy(fontWeight = FontWeight.Bold), color = c.text, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f))
|
||||
Spacer(Modifier.size(8.dp))
|
||||
Text(formatBytes(app.bytes), style = ShxType.body.copy(fontWeight = FontWeight.Bold), color = c.text)
|
||||
}
|
||||
Box(Modifier.fillMaxWidth().height(6.dp).clip(RoundedCornerShape(3.dp)).background(c.tile))
|
||||
{
|
||||
/* purple: through the VPN, grey: goes direct */
|
||||
Box(Modifier.fillMaxWidth(share.coerceIn(0.02f, 1f)).height(6.dp).clip(RoundedCornerShape(3.dp)).background(if (app.direct) c.textSub else c.accent))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
-248
@@ -1,248 +0,0 @@
|
||||
/*
|
||||
* Copyright (C) 2014 Tobias Brunner
|
||||
*
|
||||
* Copyright (C) secunet Security Networks AG
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or modify it
|
||||
* under the terms of the GNU General Public License as published by the
|
||||
* Free Software Foundation; either version 2 of the License, or (at your
|
||||
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful, but
|
||||
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
||||
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
|
||||
* for more details.
|
||||
*/
|
||||
|
||||
package org.strongswan.android.ui;
|
||||
|
||||
import android.app.Dialog;
|
||||
import android.content.ActivityNotFoundException;
|
||||
import android.content.DialogInterface;
|
||||
import android.content.Intent;
|
||||
import android.net.Uri;
|
||||
import android.os.Build;
|
||||
import android.os.Bundle;
|
||||
import android.widget.Toast;
|
||||
|
||||
import org.strongswan.android.R;
|
||||
import org.strongswan.android.data.VpnProfileDataSource;
|
||||
import org.strongswan.android.logic.TrustedCertificateManager;
|
||||
|
||||
import java.io.FileNotFoundException;
|
||||
import java.io.InputStream;
|
||||
import java.security.KeyStore;
|
||||
import java.security.cert.CertificateException;
|
||||
import java.security.cert.CertificateFactory;
|
||||
import java.security.cert.X509Certificate;
|
||||
|
||||
import androidx.activity.result.ActivityResultLauncher;
|
||||
import androidx.activity.result.contract.ActivityResultContracts;
|
||||
import androidx.appcompat.app.AlertDialog;
|
||||
import androidx.appcompat.app.AppCompatActivity;
|
||||
import androidx.appcompat.app.AppCompatDialogFragment;
|
||||
import androidx.fragment.app.FragmentTransaction;
|
||||
|
||||
public class TrustedCertificateImportActivity extends AppCompatActivity
|
||||
{
|
||||
private static final String DIALOG_TAG = "Dialog";
|
||||
private Uri mCertificateUri;
|
||||
|
||||
private final ActivityResultLauncher<Intent> mOpenDocument = registerForActivityResult(
|
||||
new ActivityResultContracts.StartActivityForResult(),
|
||||
result -> {
|
||||
if (result.getResultCode() == RESULT_OK && result.getData() != null)
|
||||
{
|
||||
mCertificateUri = result.getData().getData();
|
||||
return;
|
||||
}
|
||||
finish();
|
||||
}
|
||||
);
|
||||
|
||||
@Override
|
||||
public void onCreate(Bundle savedInstanceState)
|
||||
{
|
||||
super.onCreate(savedInstanceState);
|
||||
|
||||
if (savedInstanceState != null)
|
||||
{ /* do nothing when we are restoring */
|
||||
return;
|
||||
}
|
||||
|
||||
Intent intent = getIntent();
|
||||
String action = intent.getAction();
|
||||
if (Intent.ACTION_VIEW.equals(action))
|
||||
{
|
||||
importCertificate(intent.getData());
|
||||
}
|
||||
else
|
||||
{
|
||||
Intent openIntent = new Intent(Intent.ACTION_OPEN_DOCUMENT);
|
||||
openIntent.setType("*/*");
|
||||
try
|
||||
{
|
||||
mOpenDocument.launch(openIntent);
|
||||
}
|
||||
catch (ActivityNotFoundException e)
|
||||
{ /* some devices are unable to browse for files */
|
||||
finish();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onPostResume()
|
||||
{
|
||||
super.onPostResume();
|
||||
if (mCertificateUri != null)
|
||||
{
|
||||
importCertificate(mCertificateUri);
|
||||
mCertificateUri = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Import the file pointed to by the given URI as a certificate.
|
||||
*
|
||||
* @param uri
|
||||
*/
|
||||
private void importCertificate(Uri uri)
|
||||
{
|
||||
X509Certificate certificate = parseCertificate(uri);
|
||||
if (certificate == null)
|
||||
{
|
||||
Toast.makeText(this, R.string.cert_import_failed, Toast.LENGTH_LONG).show();
|
||||
finish();
|
||||
return;
|
||||
}
|
||||
/* Ask the user whether to import the certificate. This is particularly
|
||||
* necessary because the import activity can be triggered by any app on
|
||||
* the system. Also, if our app is the only one that is registered to
|
||||
* open certificate files by MIME type the user would have no idea really
|
||||
* where the file was imported just by reading the Toast we display. */
|
||||
ConfirmImportDialog dialog = new ConfirmImportDialog();
|
||||
Bundle args = new Bundle();
|
||||
args.putSerializable(VpnProfileDataSource.KEY_CERTIFICATE, certificate);
|
||||
dialog.setArguments(args);
|
||||
FragmentTransaction ft = getSupportFragmentManager().beginTransaction();
|
||||
ft.add(dialog, DIALOG_TAG);
|
||||
ft.commit();
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the file from the given URI and try to parse it as X.509 certificate.
|
||||
*
|
||||
* @param uri
|
||||
* @return certificate or null
|
||||
*/
|
||||
private X509Certificate parseCertificate(Uri uri)
|
||||
{
|
||||
X509Certificate certificate = null;
|
||||
try
|
||||
{
|
||||
CertificateFactory factory = CertificateFactory.getInstance("X.509");
|
||||
InputStream in = getContentResolver().openInputStream(uri);
|
||||
certificate = (X509Certificate)factory.generateCertificate(in);
|
||||
/* we don't check whether it's actually a CA certificate or not */
|
||||
}
|
||||
catch (CertificateException e)
|
||||
{
|
||||
e.printStackTrace();
|
||||
}
|
||||
catch (FileNotFoundException e)
|
||||
{
|
||||
e.printStackTrace();
|
||||
}
|
||||
return certificate;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Try to store the given certificate in the KeyStore.
|
||||
*
|
||||
* @param certificate
|
||||
* @return whether it was successfully stored
|
||||
*/
|
||||
private boolean storeCertificate(X509Certificate certificate)
|
||||
{
|
||||
try
|
||||
{
|
||||
KeyStore store = KeyStore.getInstance("LocalCertificateStore");
|
||||
store.load(null, null);
|
||||
store.setCertificateEntry(null, certificate);
|
||||
TrustedCertificateManager.getInstance().reset();
|
||||
return true;
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
e.printStackTrace();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Class that displays a confirmation dialog when a certificate should get
|
||||
* imported. If the user confirms the import we try to store it.
|
||||
*/
|
||||
public static class ConfirmImportDialog extends AppCompatDialogFragment
|
||||
{
|
||||
@Override
|
||||
public Dialog onCreateDialog(Bundle savedInstanceState)
|
||||
{
|
||||
final X509Certificate certificate;
|
||||
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU)
|
||||
{
|
||||
certificate = getCertificateCompat(getArguments());
|
||||
}
|
||||
else
|
||||
{
|
||||
certificate = getArguments().getSerializable(VpnProfileDataSource.KEY_CERTIFICATE, X509Certificate.class);
|
||||
}
|
||||
|
||||
return new AlertDialog.Builder(getActivity())
|
||||
.setIcon(R.mipmap.ic_app)
|
||||
.setTitle(R.string.import_certificate)
|
||||
.setMessage(certificate.getSubjectDN().toString())
|
||||
.setPositiveButton(R.string.import_certificate, new DialogInterface.OnClickListener()
|
||||
{
|
||||
@Override
|
||||
public void onClick(DialogInterface dialog, int whichButton)
|
||||
{
|
||||
TrustedCertificateImportActivity activity = (TrustedCertificateImportActivity)getActivity();
|
||||
if (activity.storeCertificate(certificate))
|
||||
{
|
||||
Toast.makeText(getActivity(), R.string.cert_imported_successfully, Toast.LENGTH_LONG).show();
|
||||
getActivity().setResult(RESULT_OK);
|
||||
}
|
||||
else
|
||||
{
|
||||
Toast.makeText(getActivity(), R.string.cert_import_failed, Toast.LENGTH_LONG).show();
|
||||
}
|
||||
getActivity().finish();
|
||||
}
|
||||
})
|
||||
.setNegativeButton(android.R.string.cancel, new DialogInterface.OnClickListener()
|
||||
{
|
||||
@Override
|
||||
public void onClick(DialogInterface dialog, int which)
|
||||
{
|
||||
getActivity().finish();
|
||||
}
|
||||
}).create();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCancel(DialogInterface dialog)
|
||||
{
|
||||
getActivity().finish();
|
||||
}
|
||||
|
||||
@SuppressWarnings("deprecation")
|
||||
private static X509Certificate getCertificateCompat(Bundle bundle)
|
||||
{
|
||||
return (X509Certificate)bundle.getSerializable(VpnProfileDataSource.KEY_CERTIFICATE);
|
||||
}
|
||||
}
|
||||
}
|
||||
+205
@@ -0,0 +1,205 @@
|
||||
/*
|
||||
* Copyright (C) 2014 Tobias Brunner
|
||||
*
|
||||
* Copyright (C) secunet Security Networks AG
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or modify it
|
||||
* under the terms of the GNU General Public License as published by the
|
||||
* Free Software Foundation; either version 2 of the License, or (at your
|
||||
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful, but
|
||||
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
||||
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
|
||||
* for more details.
|
||||
*/
|
||||
|
||||
package org.strongswan.android.ui
|
||||
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.widget.Toast
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import org.strongswan.android.ui.compose.design.ShxAlertDialog
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import java.io.FileNotFoundException
|
||||
import java.io.InputStream
|
||||
import java.security.KeyStore
|
||||
import java.security.cert.CertificateException
|
||||
import java.security.cert.CertificateFactory
|
||||
import java.security.cert.X509Certificate
|
||||
import org.strongswan.android.R
|
||||
import org.strongswan.android.ui.compose.design.ShxDialogButton
|
||||
import org.strongswan.android.logic.TrustedCertificateManager
|
||||
import org.strongswan.android.ui.compose.theme.ShxTheme
|
||||
|
||||
class TrustedCertificateImportActivity : AppCompatActivity()
|
||||
{
|
||||
private var certificateUri: Uri? = null
|
||||
private var certificate by mutableStateOf<X509Certificate?>(null)
|
||||
|
||||
private val mOpenDocument = registerForActivityResult(
|
||||
ActivityResultContracts.StartActivityForResult()
|
||||
) { result ->
|
||||
if (result.resultCode == RESULT_OK && result.data != null)
|
||||
{
|
||||
certificateUri = result.data!!.data
|
||||
return@registerForActivityResult
|
||||
}
|
||||
finish()
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?)
|
||||
{
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
if (savedInstanceState != null)
|
||||
{ /* do nothing when we are restoring */
|
||||
return
|
||||
}
|
||||
setContent {
|
||||
ShxTheme {
|
||||
certificate?.let { cert -> ConfirmImportUi(cert) }
|
||||
}
|
||||
}
|
||||
|
||||
val intent = getIntent()
|
||||
val action = intent.getAction()
|
||||
if (Intent.ACTION_VIEW == action)
|
||||
{
|
||||
intent.getData()?.let { importCertificate(it) }
|
||||
}
|
||||
else
|
||||
{
|
||||
val openIntent = Intent(Intent.ACTION_OPEN_DOCUMENT)
|
||||
openIntent.setType("*/*")
|
||||
try
|
||||
{
|
||||
mOpenDocument.launch(openIntent)
|
||||
}
|
||||
catch (e: ActivityNotFoundException)
|
||||
{ /* some devices are unable to browse for files */
|
||||
finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPostResume()
|
||||
{
|
||||
super.onPostResume()
|
||||
certificateUri?.let {
|
||||
importCertificate(it)
|
||||
certificateUri = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Import the file pointed to by the given URI as a certificate.
|
||||
*/
|
||||
private fun importCertificate(uri: Uri)
|
||||
{
|
||||
val certificate = parseCertificate(uri)
|
||||
if (certificate == null)
|
||||
{
|
||||
Toast.makeText(this, R.string.cert_import_failed, Toast.LENGTH_LONG).show()
|
||||
finish()
|
||||
return
|
||||
}
|
||||
/* Ask the user whether to import the certificate. This is particularly
|
||||
* necessary because the import activity can be triggered by any app on
|
||||
* the system. Also, if our app is the only one that is registered to
|
||||
* open certificate files by MIME type the user would have no idea really
|
||||
* where the file was imported just by reading the Toast we display. */
|
||||
this.certificate = certificate
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the file from the given URI and try to parse it as X.509 certificate.
|
||||
*
|
||||
* @return certificate or null
|
||||
*/
|
||||
private fun parseCertificate(uri: Uri): X509Certificate?
|
||||
{
|
||||
var certificate: X509Certificate? = null
|
||||
try
|
||||
{
|
||||
val factory = CertificateFactory.getInstance("X.509")
|
||||
val input = getContentResolver().openInputStream(uri)
|
||||
certificate = factory.generateCertificate(input) as X509Certificate
|
||||
/* we don't check whether it's actually a CA certificate or not */
|
||||
}
|
||||
catch (e: CertificateException)
|
||||
{
|
||||
e.printStackTrace()
|
||||
}
|
||||
catch (e: FileNotFoundException)
|
||||
{
|
||||
e.printStackTrace()
|
||||
}
|
||||
return certificate
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to store the given certificate in the KeyStore.
|
||||
*
|
||||
* @return whether it was successfully stored
|
||||
*/
|
||||
private fun storeCertificate(certificate: X509Certificate): Boolean
|
||||
{
|
||||
return try
|
||||
{
|
||||
val store = KeyStore.getInstance("LocalCertificateStore")
|
||||
store.load(null, null)
|
||||
store.setCertificateEntry(null, certificate)
|
||||
TrustedCertificateManager.getInstance().reset()
|
||||
true
|
||||
}
|
||||
catch (e: Exception)
|
||||
{
|
||||
e.printStackTrace()
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirmation dialog shown before a certificate gets imported.
|
||||
*/
|
||||
@androidx.compose.runtime.Composable
|
||||
private fun ConfirmImportUi(certificate: X509Certificate)
|
||||
{
|
||||
ShxAlertDialog(
|
||||
onDismissRequest = { finish() },
|
||||
title = { Text(stringResource(R.string.import_certificate)) },
|
||||
text = { Text(certificate.getSubjectDN().toString()) },
|
||||
confirmButton = {
|
||||
ShxDialogButton(primary = true, onClick = {
|
||||
if (storeCertificate(certificate))
|
||||
{
|
||||
Toast.makeText(this, R.string.cert_imported_successfully, Toast.LENGTH_LONG).show()
|
||||
setResult(RESULT_OK)
|
||||
}
|
||||
else
|
||||
{
|
||||
Toast.makeText(this, R.string.cert_import_failed, Toast.LENGTH_LONG).show()
|
||||
}
|
||||
finish()
|
||||
}) { Text(stringResource(R.string.import_certificate)) }
|
||||
},
|
||||
dismissButton = {
|
||||
ShxDialogButton(onClick = { finish() }) {
|
||||
Text(stringResource(android.R.string.cancel))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private fun stringResource(res: Int): String = getString(res)
|
||||
}
|
||||
-285
@@ -1,285 +0,0 @@
|
||||
/*
|
||||
* Copyright (C) 2012-2015 Tobias Brunner
|
||||
*
|
||||
* Copyright (C) secunet Security Networks AG
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or modify it
|
||||
* under the terms of the GNU General Public License as published by the
|
||||
* Free Software Foundation; either version 2 of the License, or (at your
|
||||
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful, but
|
||||
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
||||
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
|
||||
* for more details.
|
||||
*/
|
||||
|
||||
package org.strongswan.android.ui;
|
||||
|
||||
import android.content.Context;
|
||||
import android.os.Build;
|
||||
import android.os.Bundle;
|
||||
import android.text.TextUtils;
|
||||
import android.view.Menu;
|
||||
import android.view.MenuInflater;
|
||||
import android.view.MenuItem;
|
||||
import android.view.View;
|
||||
import android.widget.ListView;
|
||||
import android.widget.SearchView;
|
||||
import android.widget.SearchView.OnQueryTextListener;
|
||||
|
||||
import org.strongswan.android.R;
|
||||
import org.strongswan.android.logic.TrustedCertificateManager;
|
||||
import org.strongswan.android.logic.TrustedCertificateManager.TrustedCertificateSource;
|
||||
import org.strongswan.android.security.TrustedCertificateEntry;
|
||||
import org.strongswan.android.ui.adapter.TrustedCertificateAdapter;
|
||||
|
||||
import java.beans.PropertyChangeEvent;
|
||||
import java.beans.PropertyChangeListener;
|
||||
import java.security.cert.X509Certificate;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.Hashtable;
|
||||
import java.util.List;
|
||||
import java.util.Map.Entry;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.core.view.MenuProvider;
|
||||
import androidx.fragment.app.ListFragment;
|
||||
import androidx.lifecycle.Lifecycle;
|
||||
import androidx.loader.app.LoaderManager;
|
||||
import androidx.loader.app.LoaderManager.LoaderCallbacks;
|
||||
import androidx.loader.content.AsyncTaskLoader;
|
||||
import androidx.loader.content.Loader;
|
||||
|
||||
public class TrustedCertificateListFragment extends ListFragment implements MenuProvider, LoaderCallbacks<List<TrustedCertificateEntry>>, OnQueryTextListener
|
||||
{
|
||||
public static final String EXTRA_CERTIFICATE_SOURCE = "certificate_source";
|
||||
private OnTrustedCertificateSelectedListener mListener;
|
||||
private TrustedCertificateAdapter mAdapter;
|
||||
private TrustedCertificateSource mSource = TrustedCertificateSource.SYSTEM;
|
||||
|
||||
/**
|
||||
* The activity containing this fragment should implement this interface
|
||||
*/
|
||||
public interface OnTrustedCertificateSelectedListener
|
||||
{
|
||||
public void onTrustedCertificateSelected(TrustedCertificateEntry selected);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState)
|
||||
{
|
||||
super.onViewCreated(view, savedInstanceState);
|
||||
requireActivity().addMenuProvider(this, getViewLifecycleOwner(), Lifecycle.State.RESUMED);
|
||||
|
||||
setEmptyText(getString(R.string.no_certificates));
|
||||
|
||||
mAdapter = new TrustedCertificateAdapter(getActivity());
|
||||
setListAdapter(mAdapter);
|
||||
|
||||
setListShown(false);
|
||||
|
||||
Bundle arguments = getArguments();
|
||||
if (arguments != null)
|
||||
{
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU)
|
||||
{
|
||||
mSource = getCertificateSourceCompat(arguments);
|
||||
}
|
||||
else
|
||||
{
|
||||
mSource = arguments.getSerializable(EXTRA_CERTIFICATE_SOURCE, TrustedCertificateSource.class);
|
||||
}
|
||||
}
|
||||
|
||||
LoaderManager.getInstance(this).initLoader(0, null, this);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDestroy()
|
||||
{
|
||||
super.onDestroy();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onAttach(Context context)
|
||||
{
|
||||
super.onAttach(context);
|
||||
|
||||
if (context instanceof OnTrustedCertificateSelectedListener)
|
||||
{
|
||||
mListener = (OnTrustedCertificateSelectedListener)context;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCreateMenu(@NonNull Menu menu, @NonNull MenuInflater menuInflater)
|
||||
{
|
||||
MenuItem item = menu.add(R.string.search);
|
||||
item.setIcon(android.R.drawable.ic_menu_search);
|
||||
item.setShowAsAction(MenuItem.SHOW_AS_ACTION_ALWAYS);
|
||||
|
||||
SearchView sv = new SearchView(getActivity());
|
||||
sv.setOnQueryTextListener(this);
|
||||
item.setActionView(sv);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onMenuItemSelected(@NonNull MenuItem menuItem)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onQueryTextSubmit(String query)
|
||||
{ /* already handled when the text changes */
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onQueryTextChange(String newText)
|
||||
{
|
||||
String search = TextUtils.isEmpty(newText) ? null : newText;
|
||||
mAdapter.getFilter().filter(search);
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Loader<List<TrustedCertificateEntry>> onCreateLoader(int id, Bundle args)
|
||||
{ /* we don't need the id as we have only one loader */
|
||||
return new CertificateListLoader(getActivity(), mSource);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onLoadFinished(Loader<List<TrustedCertificateEntry>> loader, List<TrustedCertificateEntry> data)
|
||||
{
|
||||
mAdapter.setData(data);
|
||||
|
||||
if (isResumed())
|
||||
{
|
||||
setListShown(true);
|
||||
}
|
||||
else
|
||||
{
|
||||
setListShownNoAnimation(true);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onLoaderReset(Loader<List<TrustedCertificateEntry>> loader)
|
||||
{
|
||||
mAdapter.setData(null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onListItemClick(ListView l, View v, int position, long id)
|
||||
{
|
||||
if (mListener != null)
|
||||
{
|
||||
mListener.onTrustedCertificateSelected(mAdapter.getItem(position));
|
||||
}
|
||||
}
|
||||
|
||||
public static class CertificateListLoader extends AsyncTaskLoader<List<TrustedCertificateEntry>>
|
||||
{
|
||||
private List<TrustedCertificateEntry> mData;
|
||||
private final TrustedCertificateSource mSource;
|
||||
private TrustedCertificateManagerObserver mObserver;
|
||||
|
||||
public CertificateListLoader(Context context, TrustedCertificateSource source)
|
||||
{
|
||||
super(context);
|
||||
mSource = source;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<TrustedCertificateEntry> loadInBackground()
|
||||
{
|
||||
TrustedCertificateManager certman = TrustedCertificateManager.getInstance().load();
|
||||
Hashtable<String, X509Certificate> certificates = certman.getCACertificates(mSource);
|
||||
List<TrustedCertificateEntry> selected;
|
||||
|
||||
selected = new ArrayList<TrustedCertificateEntry>();
|
||||
for (Entry<String, X509Certificate> entry : certificates.entrySet())
|
||||
{
|
||||
selected.add(new TrustedCertificateEntry(entry.getKey(), entry.getValue()));
|
||||
}
|
||||
Collections.sort(selected);
|
||||
return selected;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onStartLoading()
|
||||
{
|
||||
if (mData != null)
|
||||
{ /* if we have data ready, deliver it directly */
|
||||
deliverResult(mData);
|
||||
}
|
||||
if (takeContentChanged() || mData == null)
|
||||
{
|
||||
forceLoad();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void deliverResult(List<TrustedCertificateEntry> data)
|
||||
{
|
||||
if (isReset())
|
||||
{
|
||||
return;
|
||||
}
|
||||
mData = data;
|
||||
if (isStarted())
|
||||
{ /* if it is started we deliver the data directly,
|
||||
* otherwise this is handled in onStartLoading */
|
||||
if (mObserver == null)
|
||||
{
|
||||
mObserver = new TrustedCertificateManagerObserver();
|
||||
TrustedCertificateManager.getInstance().addObserver(mObserver);
|
||||
}
|
||||
super.deliverResult(data);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onReset()
|
||||
{
|
||||
if (mObserver != null)
|
||||
{
|
||||
TrustedCertificateManager.getInstance().deleteObserver(mObserver);
|
||||
mObserver = null;
|
||||
}
|
||||
mData = null;
|
||||
super.onReset();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onAbandon()
|
||||
{
|
||||
if (mObserver != null)
|
||||
{
|
||||
TrustedCertificateManager.getInstance().deleteObserver(mObserver);
|
||||
mObserver = null;
|
||||
}
|
||||
}
|
||||
|
||||
private class TrustedCertificateManagerObserver implements PropertyChangeListener
|
||||
{
|
||||
private ForceLoadContentObserver mContentObserver = new ForceLoadContentObserver();
|
||||
|
||||
@Override
|
||||
public void propertyChange(PropertyChangeEvent evt)
|
||||
{
|
||||
mContentObserver.onChange(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressWarnings("deprecation")
|
||||
private static TrustedCertificateSource getCertificateSourceCompat(Bundle bundle)
|
||||
{
|
||||
return (TrustedCertificateSource)bundle.getSerializable(EXTRA_CERTIFICATE_SOURCE);
|
||||
}
|
||||
}
|
||||
+217
-91
@@ -9,6 +9,7 @@
|
||||
|
||||
package org.strongswan.android.ui
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
@@ -16,24 +17,23 @@ import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.SecondaryTabRow
|
||||
import androidx.compose.material3.Tab
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
@@ -42,22 +42,40 @@ import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.strongswan.android.R
|
||||
import org.strongswan.android.data.VpnProfileDataSource
|
||||
import org.strongswan.android.data.VpnProfileSource
|
||||
import org.strongswan.android.logic.StrongSwanApplication
|
||||
import org.strongswan.android.logic.TrustedCertificateManager
|
||||
import org.strongswan.android.logic.TrustedCertificateManager.TrustedCertificateSource
|
||||
import org.strongswan.android.security.TrustedCertificateEntry
|
||||
import org.strongswan.android.ui.compose.SecondaryScaffold
|
||||
import org.strongswan.android.ui.compose.design.ShxDialog
|
||||
import org.strongswan.android.ui.compose.design.ShxIcons
|
||||
import org.strongswan.android.ui.compose.design.ShxPanel
|
||||
import org.strongswan.android.ui.compose.design.ShxTabs
|
||||
import org.strongswan.android.ui.compose.theme.LocalShxColors
|
||||
import org.strongswan.android.ui.compose.theme.ShxTheme
|
||||
import org.strongswan.android.ui.compose.theme.ShxType
|
||||
import java.beans.PropertyChangeListener
|
||||
import java.security.KeyStore
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class TrustedCertificatesActivity : AppCompatActivity()
|
||||
{
|
||||
@@ -86,7 +104,43 @@ class TrustedCertificatesActivity : AppCompatActivity()
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
/** A CA certificate as a row of F_Certs: who uses it and how long it is valid. */
|
||||
private data class CertRow(val entry: TrustedCertificateEntry, val usedBy: List<String>)
|
||||
|
||||
/* the three tabs of F_Certs */
|
||||
private enum class CertTab { PROFILES, ADDED, SYSTEM }
|
||||
|
||||
/** Days a certificate counts as «expires soon» (amber). */
|
||||
internal const val CERT_SOON_DAYS = 30L
|
||||
|
||||
/**
|
||||
* The CA certificates the profiles point to, with the names of those
|
||||
* profiles. Profiles that pick the CA automatically are not listed.
|
||||
*/
|
||||
internal fun profileCertificates(context: Context): Map<String, List<String>>
|
||||
{
|
||||
val source = VpnProfileSource(context)
|
||||
source.open()
|
||||
try
|
||||
{
|
||||
return source.allVpnProfiles.mapNotNull { p -> p.certificateAlias?.let { it to (p.name ?: p.gateway) } }
|
||||
.groupBy({ it.first }, { it.second })
|
||||
}
|
||||
finally
|
||||
{
|
||||
source.close()
|
||||
}
|
||||
}
|
||||
|
||||
/** For the Settings list: how many CAs come from profiles and how many of them expire soon. */
|
||||
internal fun certificateSummary(context: Context): Pair<Int, Int>
|
||||
{
|
||||
val manager = TrustedCertificateManager.getInstance().load()
|
||||
val certs = profileCertificates(context).keys.mapNotNull { manager.getCACertificateFromAlias(it) }
|
||||
val soon = certs.count { TimeUnit.MILLISECONDS.toDays(it.notAfter.time - System.currentTimeMillis()) <= CERT_SOON_DAYS }
|
||||
return certs.size to soon
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CertificatesScreen(
|
||||
selectMode: Boolean,
|
||||
@@ -95,28 +149,28 @@ private fun CertificatesScreen(
|
||||
)
|
||||
{
|
||||
val context = LocalContext.current
|
||||
val sources = remember {
|
||||
listOf(
|
||||
TrustedCertificateSource.SYSTEM to context.getString(R.string.system_tab),
|
||||
TrustedCertificateSource.USER to context.getString(R.string.user_tab),
|
||||
TrustedCertificateSource.LOCAL to context.getString(R.string.local_tab),
|
||||
)
|
||||
}
|
||||
val c = LocalShxColors.current
|
||||
var tab by remember { mutableIntStateOf(0) }
|
||||
var entries by remember { mutableStateOf(listOf<TrustedCertificateEntry>()) }
|
||||
var rows by remember { mutableStateOf(listOf<CertRow>()) }
|
||||
var profileCount by remember { mutableIntStateOf(0) }
|
||||
var pendingDelete by remember { mutableStateOf<TrustedCertificateEntry?>(null) }
|
||||
val scope = rememberCoroutineScope()
|
||||
val allowImport = StrongSwanApplication.getInstance().managedConfigurationService.managedConfiguration.isAllowCertificateImport
|
||||
val importLauncher = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) {
|
||||
if (it.resultCode == android.app.Activity.RESULT_OK)
|
||||
{
|
||||
scope.launch { reload(sources[tab].first) { entries = it } }
|
||||
}
|
||||
}
|
||||
|
||||
fun load()
|
||||
{
|
||||
scope.launch { reload(sources[tab].first) { entries = it } }
|
||||
scope.launch {
|
||||
val (list, count) = withContext(Dispatchers.IO) { loadRows(context, CertTab.entries[tab]) }
|
||||
rows = list
|
||||
profileCount = count
|
||||
}
|
||||
}
|
||||
|
||||
val importLauncher = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) {
|
||||
if (it.resultCode == android.app.Activity.RESULT_OK)
|
||||
{
|
||||
load()
|
||||
}
|
||||
}
|
||||
|
||||
DisposableEffect(tab) {
|
||||
@@ -126,83 +180,155 @@ private fun CertificatesScreen(
|
||||
onDispose { TrustedCertificateManager.getInstance().deleteObserver(observer) }
|
||||
}
|
||||
|
||||
SecondaryScaffold(
|
||||
title = stringResource(R.string.trusted_certs_title),
|
||||
onBack = onBack,
|
||||
actions = {
|
||||
SecondaryScaffold(title = stringResource(R.string.trusted_certs_title), onBack = onBack) { padding ->
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
modifier = Modifier.fillMaxSize().padding(padding).verticalScroll(rememberScrollState())
|
||||
.padding(start = 16.dp, end = 16.dp, bottom = 24.dp),
|
||||
)
|
||||
{
|
||||
Text(
|
||||
stringResource(R.string.certs_intro), style = ShxType.note.copy(fontSize = 14.sp, lineHeight = 20.sp, fontWeight = FontWeight.Normal),
|
||||
color = c.textSoft, modifier = Modifier.padding(horizontal = 4.dp),
|
||||
)
|
||||
ShxTabs(
|
||||
listOf(
|
||||
stringResource(R.string.certs_tab_profiles, profileCount),
|
||||
stringResource(R.string.certs_tab_added),
|
||||
stringResource(R.string.certs_tab_system),
|
||||
),
|
||||
tab, { tab = it },
|
||||
)
|
||||
if (rows.isEmpty())
|
||||
{
|
||||
Text(
|
||||
stringResource(if (tab == 0) R.string.certs_none_profiles else R.string.certs_none),
|
||||
style = ShxType.sub, color = c.textSub, modifier = Modifier.padding(horizontal = 6.dp, vertical = 8.dp),
|
||||
)
|
||||
}
|
||||
else
|
||||
{
|
||||
ShxPanel()
|
||||
{
|
||||
rows.forEachIndexed { i, row ->
|
||||
CertItem(row, onClick = {
|
||||
when
|
||||
{
|
||||
selectMode -> onPicked(row.entry.alias)
|
||||
CertTab.entries[tab] == CertTab.ADDED -> pendingDelete = row.entry
|
||||
}
|
||||
})
|
||||
if (i < rows.lastIndex)
|
||||
{
|
||||
Box(Modifier.fillMaxWidth().height(1.dp).background(c.tile))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (allowImport)
|
||||
{
|
||||
IconButton(onClick = {
|
||||
importLauncher.launch(Intent(context, TrustedCertificateImportActivity::class.java))
|
||||
}) { Icon(Icons.Default.Add, contentDescription = stringResource(R.string.import_certificate)) }
|
||||
}
|
||||
IconButton(onClick = {
|
||||
scope.launch {
|
||||
withContext(Dispatchers.IO) {
|
||||
TrustedCertificateManager.getInstance().reset().load()
|
||||
}
|
||||
reload(sources[tab].first) { entries = it }
|
||||
}
|
||||
}) { Icon(Icons.Default.Refresh, contentDescription = stringResource(R.string.reload_trusted_certs)) }
|
||||
},
|
||||
) { padding ->
|
||||
Column(Modifier.fillMaxSize().padding(padding)) {
|
||||
SecondaryTabRow(selectedTabIndex = tab) {
|
||||
sources.forEachIndexed { index, pair ->
|
||||
Tab(selected = tab == index, onClick = { tab = index }, text = { Text(pair.second) })
|
||||
}
|
||||
}
|
||||
LazyColumn(Modifier.fillMaxSize()) {
|
||||
items(entries, key = { it.alias }) { entry ->
|
||||
ListItem(
|
||||
headlineContent = { Text(entry.subjectPrimary) },
|
||||
supportingContent = { Text(entry.subjectSecondary.ifEmpty { entry.alias }) },
|
||||
modifier = Modifier.clickable {
|
||||
if (selectMode) onPicked(entry.alias)
|
||||
else if (sources[tab].first == TrustedCertificateSource.LOCAL) pendingDelete = entry
|
||||
},
|
||||
)
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally),
|
||||
modifier = Modifier.fillMaxWidth().height(52.dp).clip(RoundedCornerShape(18.dp))
|
||||
.border(1.dp, c.outline, RoundedCornerShape(18.dp))
|
||||
.clickable(role = Role.Button) { importLauncher.launch(Intent(context, TrustedCertificateImportActivity::class.java)) },
|
||||
)
|
||||
{
|
||||
Icon(ShxIcons.Plus, contentDescription = null, tint = c.text, modifier = Modifier.size(18.dp))
|
||||
Text(stringResource(R.string.certs_add), style = ShxType.body.copy(fontWeight = FontWeight.Bold), color = c.text)
|
||||
}
|
||||
}
|
||||
Text(
|
||||
stringResource(R.string.certs_system_note), style = ShxType.meta.copy(fontWeight = FontWeight.Normal, lineHeight = 17.sp),
|
||||
color = c.textSub, modifier = Modifier.padding(horizontal = 6.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
pendingDelete?.let { entry ->
|
||||
AlertDialog(
|
||||
onDismissRequest = { pendingDelete = null },
|
||||
title = { Text(stringResource(R.string.delete_certificate_question)) },
|
||||
text = { Text(entry.subjectPrimary) },
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
try
|
||||
{
|
||||
val store = KeyStore.getInstance("LocalCertificateStore")
|
||||
store.load(null, null)
|
||||
store.deleteEntry(entry.alias)
|
||||
TrustedCertificateManager.getInstance().reset()
|
||||
}
|
||||
catch (_: Exception)
|
||||
{
|
||||
}
|
||||
pendingDelete = null
|
||||
load()
|
||||
}) { Text(stringResource(R.string.delete_profile)) }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { pendingDelete = null }) {
|
||||
Text(stringResource(android.R.string.cancel))
|
||||
ShxDialog(
|
||||
title = stringResource(R.string.delete_certificate_question),
|
||||
paragraphs = listOf(entry.subjectPrimary),
|
||||
confirm = stringResource(R.string.server_delete_confirm),
|
||||
dismiss = stringResource(android.R.string.cancel),
|
||||
onConfirm = {
|
||||
try
|
||||
{
|
||||
val store = KeyStore.getInstance("LocalCertificateStore")
|
||||
store.load(null, null)
|
||||
store.deleteEntry(entry.alias)
|
||||
TrustedCertificateManager.getInstance().reset()
|
||||
}
|
||||
catch (_: Exception)
|
||||
{
|
||||
}
|
||||
pendingDelete = null
|
||||
load()
|
||||
},
|
||||
onDismiss = { pendingDelete = null },
|
||||
icon = ShxIcons.Trash,
|
||||
danger = true,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun reload(source: TrustedCertificateSource, onResult: (List<TrustedCertificateEntry>) -> Unit)
|
||||
@Composable
|
||||
private fun CertItem(row: CertRow, onClick: () -> Unit)
|
||||
{
|
||||
val list = withContext(Dispatchers.IO) {
|
||||
val manager = TrustedCertificateManager.getInstance().load()
|
||||
manager.getCACertificates(source).map { (alias, cert) -> TrustedCertificateEntry(alias, cert) }
|
||||
.sorted()
|
||||
val c = LocalShxColors.current
|
||||
val notAfter = row.entry.certificate.notAfter
|
||||
val days = TimeUnit.MILLISECONDS.toDays(notAfter.time - System.currentTimeMillis())
|
||||
val date = SimpleDateFormat("dd.MM.yyyy", Locale.getDefault()).format(notAfter)
|
||||
/* green while valid, amber when it expires soon, red when expired */
|
||||
val (bg, fg, expColor, expText) = when
|
||||
{
|
||||
days < 0 -> Look(c.errorContainer, c.error, c.error, stringResource(R.string.certs_expired, date))
|
||||
days <= CERT_SOON_DAYS -> Look(c.warningStrong, c.onWarning, c.warning, stringResource(R.string.certs_expires_in, days.toInt(), date))
|
||||
else -> Look(c.successContainer, c.onSuccess, c.onSuccess, stringResource(R.string.certs_valid_until, SimpleDateFormat("MM.yyyy", Locale.getDefault()).format(notAfter)))
|
||||
}
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
modifier = Modifier.fillMaxWidth().clickable(onClick = onClick).padding(start = 16.dp, end = 14.dp, top = 12.dp, bottom = 12.dp),
|
||||
)
|
||||
{
|
||||
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(40.dp).clip(RoundedCornerShape(14.dp)).background(bg))
|
||||
{
|
||||
Icon(ShxIcons.Cert, contentDescription = null, tint = fg, modifier = Modifier.size(20.dp))
|
||||
}
|
||||
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp))
|
||||
{
|
||||
Text(row.entry.subjectPrimary, style = ShxType.rowTitle.copy(fontSize = 15.sp), color = c.text)
|
||||
val used = if (row.usedBy.isNotEmpty()) stringResource(R.string.certs_used_by, row.usedBy.joinToString(", "))
|
||||
else row.entry.subjectSecondary
|
||||
if (used.isNotEmpty())
|
||||
{
|
||||
Text(used, style = ShxType.meta.copy(fontWeight = FontWeight.Normal), color = c.textSub)
|
||||
}
|
||||
Text(expText, style = ShxType.meta.copy(fontWeight = FontWeight.Bold), color = expColor)
|
||||
}
|
||||
Icon(ShxIcons.Chevron, contentDescription = null, tint = c.textSub, modifier = Modifier.size(18.dp))
|
||||
}
|
||||
onResult(list)
|
||||
}
|
||||
|
||||
private data class Look(val bg: Color, val fg: Color, val exp: Color, val text: String)
|
||||
|
||||
/** Rows of [tab] and the number of CAs used by profiles (shown on the first tab). */
|
||||
private fun loadRows(context: Context, tab: CertTab): Pair<List<CertRow>, Int>
|
||||
{
|
||||
val manager = TrustedCertificateManager.getInstance().load()
|
||||
val usage = profileCertificates(context)
|
||||
val fromProfiles = usage.mapNotNull { (alias, names) ->
|
||||
manager.getCACertificateFromAlias(alias)?.let { CertRow(TrustedCertificateEntry(alias, it), names) }
|
||||
}.sortedBy { it.entry }
|
||||
val rows = when (tab)
|
||||
{
|
||||
CertTab.PROFILES -> fromProfiles
|
||||
CertTab.ADDED -> manager.getCACertificates(TrustedCertificateSource.LOCAL)
|
||||
.map { (alias, cert) -> CertRow(TrustedCertificateEntry(alias, cert), usage[alias].orEmpty()) }.sortedBy { it.entry }
|
||||
CertTab.SYSTEM -> (manager.getCACertificates(TrustedCertificateSource.SYSTEM) + manager.getCACertificates(TrustedCertificateSource.USER))
|
||||
.map { (alias, cert) -> CertRow(TrustedCertificateEntry(alias, cert), usage[alias].orEmpty()) }.sortedBy { it.entry }
|
||||
}
|
||||
return rows to fromProfiles.size
|
||||
}
|
||||
+226
-288
@@ -17,12 +17,10 @@
|
||||
package org.strongswan.android.ui;
|
||||
|
||||
import android.Manifest;
|
||||
import android.app.Dialog;
|
||||
import android.app.Service;
|
||||
import android.content.ActivityNotFoundException;
|
||||
import android.content.ComponentName;
|
||||
import android.content.Context;
|
||||
import android.content.DialogInterface;
|
||||
import android.content.Intent;
|
||||
import android.content.ServiceConnection;
|
||||
import android.content.SharedPreferences;
|
||||
@@ -34,11 +32,7 @@ import android.os.Bundle;
|
||||
import android.os.IBinder;
|
||||
import android.os.PowerManager;
|
||||
import android.provider.Settings;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.View;
|
||||
import android.widget.EditText;
|
||||
import android.widget.Toast;
|
||||
|
||||
import org.strongswan.android.R;
|
||||
import org.strongswan.android.data.VpnProfile;
|
||||
import org.strongswan.android.data.VpnProfileDataSource;
|
||||
@@ -46,25 +40,33 @@ import org.strongswan.android.data.VpnProfileSource;
|
||||
import org.strongswan.android.data.VpnType.VpnTypeFeature;
|
||||
import org.strongswan.android.logic.VpnStateService;
|
||||
import org.strongswan.android.logic.VpnStateService.State;
|
||||
import org.strongswan.android.ui.compose.VpnDialog;
|
||||
import org.strongswan.android.ui.compose.VpnDialogBus;
|
||||
import org.strongswan.android.ui.compose.VpnDialogListener;
|
||||
import org.strongswan.android.ui.compose.VpnDialogsKt;
|
||||
import org.strongswan.android.utils.Constants;
|
||||
import org.strongswan.android.utils.DefaultVpnProfile;
|
||||
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
|
||||
import androidx.activity.result.ActivityResultLauncher;
|
||||
import androidx.activity.result.contract.ActivityResultContracts;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.appcompat.app.AlertDialog;
|
||||
import androidx.appcompat.app.AppCompatActivity;
|
||||
import androidx.appcompat.app.AppCompatDialogFragment;
|
||||
import androidx.core.content.ContextCompat;
|
||||
import androidx.fragment.app.Fragment;
|
||||
import androidx.fragment.app.FragmentManager;
|
||||
import androidx.fragment.app.FragmentTransaction;
|
||||
import androidx.preference.PreferenceManager;
|
||||
|
||||
public class VpnProfileControlActivity extends AppCompatActivity
|
||||
public class VpnProfileControlActivity extends AppCompatActivity implements VpnDialogListener
|
||||
{
|
||||
public static final String START_PROFILE = "one.shx.strongswan.ext.action.START_PROFILE";
|
||||
public static final String DISCONNECT = "one.shx.strongswan.ext.action.DISCONNECT";
|
||||
public static final String TOGGLE_VPN = "one.shx.strongswan.ext.action.TOGGLE_VPN";
|
||||
public static final String EXTRA_VPN_PROFILE_UUID = "one.shx.strongswan.ext.VPN_PROFILE_UUID";
|
||||
/**
|
||||
* If set to true, no confirmation dialogs are shown when replacing or
|
||||
* disconnecting an active connection (used by automation tools)
|
||||
*/
|
||||
public static final String EXTRA_SILENT = "one.shx.strongswan.ext.SILENT";
|
||||
private static final String EXTRA_VPN_PROFILE_ID = "one.shx.strongswan.ext.VPN_PROFILE_ID";
|
||||
|
||||
private static final String WAITING_FOR_RESULT = "WAITING_FOR_RESULT";
|
||||
@@ -73,11 +75,13 @@ public class VpnProfileControlActivity extends AppCompatActivity
|
||||
private static final String PROFILE_CERTIFICATE_MISSING = "CERTIFICATE_MISSING";
|
||||
private static final String PROFILE_RECONNECT = "RECONNECT";
|
||||
private static final String PROFILE_DISCONNECT = "DISCONNECT";
|
||||
private static final String DIALOG_TAG = "Dialog";
|
||||
|
||||
private Bundle mProfileInfo;
|
||||
private boolean mWaitingForResult;
|
||||
private VpnStateService mService;
|
||||
private boolean mBound;
|
||||
/* profile lookups are SQLite queries and must not block the main thread */
|
||||
private final ExecutorService mProfileExecutor = Executors.newSingleThreadExecutor();
|
||||
private final ServiceConnection mServiceConnection = new ServiceConnection()
|
||||
{
|
||||
@Override
|
||||
@@ -98,13 +102,14 @@ public class VpnProfileControlActivity extends AppCompatActivity
|
||||
new ActivityResultContracts.StartActivityForResult(),
|
||||
result -> {
|
||||
mWaitingForResult = false;
|
||||
VpnDialogBus.INSTANCE.setConsentHint(false);
|
||||
if (result.getResultCode() == RESULT_OK && mProfileInfo != null)
|
||||
{
|
||||
onVpnServicePrepared();
|
||||
}
|
||||
else
|
||||
{ /* this happens if the always-on VPN feature is activated by a different app or the user declined */
|
||||
VpnNotSupportedError.showWithMessage(this, R.string.vpn_not_supported_no_permission);
|
||||
showNotSupported(R.string.vpn_not_supported_no_permission);
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -137,8 +142,10 @@ public class VpnProfileControlActivity extends AppCompatActivity
|
||||
{
|
||||
mWaitingForResult = savedInstanceState.getBoolean(WAITING_FOR_RESULT, false);
|
||||
}
|
||||
this.bindService(new Intent(this, VpnStateService.class),
|
||||
mServiceConnection, Service.BIND_AUTO_CREATE);
|
||||
mBound = this.bindService(new Intent(this, VpnStateService.class),
|
||||
mServiceConnection, Service.BIND_AUTO_CREATE);
|
||||
VpnDialogBus.INSTANCE.setListener(this);
|
||||
VpnDialogsKt.installVpnDialogHost(this);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -152,10 +159,14 @@ public class VpnProfileControlActivity extends AppCompatActivity
|
||||
protected void onDestroy()
|
||||
{
|
||||
super.onDestroy();
|
||||
if (mService != null)
|
||||
VpnDialogBus.INSTANCE.setListener(null);
|
||||
mProfileExecutor.shutdown();
|
||||
if (mBound)
|
||||
{
|
||||
this.unbindService(mServiceConnection);
|
||||
mBound = false;
|
||||
}
|
||||
mService = null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -198,13 +209,13 @@ public class VpnProfileControlActivity extends AppCompatActivity
|
||||
catch (IllegalStateException ex)
|
||||
{
|
||||
/* this happens if the always-on VPN feature (Android 4.2+) is activated */
|
||||
VpnNotSupportedError.showWithMessage(this, R.string.vpn_not_supported_during_lockdown);
|
||||
showNotSupported(R.string.vpn_not_supported_during_lockdown);
|
||||
return;
|
||||
}
|
||||
catch (NullPointerException ex)
|
||||
{
|
||||
/* not sure when this happens exactly, but apparently it does */
|
||||
VpnNotSupportedError.showWithMessage(this, R.string.vpn_not_supported);
|
||||
showNotSupported(R.string.vpn_not_supported);
|
||||
return;
|
||||
}
|
||||
/* store profile info until the user grants us permission */
|
||||
@@ -214,6 +225,7 @@ public class VpnProfileControlActivity extends AppCompatActivity
|
||||
try
|
||||
{
|
||||
mWaitingForResult = true;
|
||||
VpnDialogBus.INSTANCE.setConsentHint(true);
|
||||
mPrepareVpnService.launch(intent);
|
||||
}
|
||||
catch (ActivityNotFoundException ex)
|
||||
@@ -222,7 +234,8 @@ public class VpnProfileControlActivity extends AppCompatActivity
|
||||
* don't have the VPN components built into the system image.
|
||||
* com.android.vpndialogs/com.android.vpndialogs.ConfirmDialog
|
||||
* will not be found then */
|
||||
VpnNotSupportedError.showWithMessage(this, R.string.vpn_not_supported);
|
||||
VpnDialogBus.INSTANCE.setConsentHint(false);
|
||||
showNotSupported(R.string.vpn_not_supported);
|
||||
mWaitingForResult = false;
|
||||
}
|
||||
}
|
||||
@@ -289,14 +302,7 @@ public class VpnProfileControlActivity extends AppCompatActivity
|
||||
if (!pm.isIgnoringBatteryOptimizations(this.getPackageName()) &&
|
||||
!pref.getBoolean(Constants.PREF_IGNORE_POWER_WHITELIST, false))
|
||||
{
|
||||
if (getSupportFragmentManager().isStateSaved())
|
||||
{ /* we might get called via service connection and manual onActivityResult()
|
||||
* call when the activity is not active anymore and fragment transactions
|
||||
* would cause an illegalStateException */
|
||||
return false;
|
||||
}
|
||||
PowerWhitelistRequired whitelist = new PowerWhitelistRequired();
|
||||
whitelist.show(getSupportFragmentManager(), DIALOG_TAG);
|
||||
VpnDialogBus.INSTANCE.show(VpnDialog.PowerWhitelist.INSTANCE);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -337,20 +343,57 @@ public class VpnProfileControlActivity extends AppCompatActivity
|
||||
profile.getUserCertificateAlias() == null);
|
||||
profileInfo.putString(PROFILE_NAME, profile.getName());
|
||||
|
||||
removeFragmentByTag(DIALOG_TAG);
|
||||
VpnDialogBus.INSTANCE.dismiss();
|
||||
|
||||
if (isConnected())
|
||||
{
|
||||
if (getIntent().getBooleanExtra(EXTRA_SILENT, false))
|
||||
{ /* automation tools replace the active connection without asking */
|
||||
startVpnProfile(profileInfo);
|
||||
return;
|
||||
}
|
||||
profileInfo.putBoolean(PROFILE_RECONNECT, mService.getProfile().getUUID().equals(profile.getUUID()));
|
||||
|
||||
ConfirmationDialog dialog = new ConfirmationDialog();
|
||||
dialog.setArguments(profileInfo);
|
||||
dialog.show(this.getSupportFragmentManager(), DIALOG_TAG);
|
||||
showConfirmationDialog(profileInfo);
|
||||
return;
|
||||
}
|
||||
startVpnProfile(profileInfo);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build and show the confirmation dialog for the given profile data.
|
||||
*
|
||||
* @param profileInfo data about the profile; PROFILE_RECONNECT and
|
||||
* PROFILE_DISCONNECT select the dialog variant
|
||||
*/
|
||||
private void showConfirmationDialog(Bundle profileInfo)
|
||||
{
|
||||
boolean reconnect = profileInfo.getBoolean(PROFILE_RECONNECT);
|
||||
boolean disconnect = profileInfo.getBoolean(PROFILE_DISCONNECT);
|
||||
|
||||
String title;
|
||||
int message;
|
||||
int button;
|
||||
if (reconnect)
|
||||
{
|
||||
title = getString(R.string.vpn_connected);
|
||||
message = R.string.vpn_profile_connected;
|
||||
button = R.string.reconnect;
|
||||
}
|
||||
else if (disconnect)
|
||||
{
|
||||
title = getString(R.string.disconnect_question);
|
||||
message = R.string.disconnect_active_connection;
|
||||
button = R.string.disconnect;
|
||||
}
|
||||
else
|
||||
{
|
||||
title = getString(R.string.connect_profile_question, profileInfo.getString(PROFILE_NAME));
|
||||
message = R.string.replaces_active_connection;
|
||||
button = R.string.connect;
|
||||
}
|
||||
VpnDialogBus.INSTANCE.show(new VpnDialog.Confirmation(profileInfo, title, message, button, reconnect, disconnect));
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the given VPN profile asking the user for a password if required.
|
||||
*
|
||||
@@ -360,17 +403,15 @@ public class VpnProfileControlActivity extends AppCompatActivity
|
||||
{
|
||||
if (profileInfo.getBoolean(PROFILE_CERTIFICATE_MISSING))
|
||||
{
|
||||
CertificateRequiredDialog dialog = new CertificateRequiredDialog();
|
||||
dialog.setArguments(profileInfo);
|
||||
dialog.show(getSupportFragmentManager(), DIALOG_TAG);
|
||||
VpnDialogBus.INSTANCE.show(new VpnDialog.CertificateRequired(
|
||||
profileInfo, profileInfo.getString(VpnProfileDataSource.KEY_UUID)));
|
||||
return;
|
||||
}
|
||||
if (profileInfo.getBoolean(PROFILE_REQUIRES_PASSWORD) &&
|
||||
profileInfo.getString(VpnProfileDataSource.KEY_PASSWORD) == null)
|
||||
{
|
||||
LoginDialog login = new LoginDialog();
|
||||
login.setArguments(profileInfo);
|
||||
login.show(getSupportFragmentManager(), DIALOG_TAG);
|
||||
VpnDialogBus.INSTANCE.show(new VpnDialog.Login(
|
||||
profileInfo, profileInfo.getString(VpnProfileDataSource.KEY_USERNAME)));
|
||||
return;
|
||||
}
|
||||
prepareVpnService(profileInfo);
|
||||
@@ -384,30 +425,50 @@ public class VpnProfileControlActivity extends AppCompatActivity
|
||||
*/
|
||||
private void startVpnProfile(Intent intent)
|
||||
{
|
||||
VpnProfile profile = null;
|
||||
|
||||
VpnProfileDataSource dataSource = new VpnProfileSource(this);
|
||||
dataSource.open();
|
||||
String profileUUID = intent.getStringExtra(EXTRA_VPN_PROFILE_UUID);
|
||||
if (profileUUID == null)
|
||||
{
|
||||
profileUUID = intent.getStringExtra(EXTRA_VPN_PROFILE_ID);
|
||||
}
|
||||
if (profileUUID != null)
|
||||
{
|
||||
profile = dataSource.getVpnProfile(profileUUID);
|
||||
}
|
||||
dataSource.close();
|
||||
final String uuid = profileUUID != null ? profileUUID
|
||||
: DefaultVpnProfile.getDefaultProfileUuid(this);
|
||||
mProfileExecutor.execute(() -> {
|
||||
VpnProfile profile = loadProfile(uuid);
|
||||
runOnUiThread(() -> {
|
||||
if (isFinishing() || isDestroyed())
|
||||
{ /* rotated or finished while the query ran */
|
||||
return;
|
||||
}
|
||||
if (profile != null)
|
||||
{
|
||||
startVpnProfile(profile);
|
||||
}
|
||||
else
|
||||
{
|
||||
Toast.makeText(this, R.string.profile_not_found, Toast.LENGTH_LONG).show();
|
||||
finish();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (profile != null)
|
||||
/**
|
||||
* Load a profile from the database. Blocking - run on a background thread.
|
||||
*
|
||||
* @param uuid UUID of the profile, may be null
|
||||
* @return the profile or null if not found
|
||||
*/
|
||||
private VpnProfile loadProfile(String uuid)
|
||||
{
|
||||
if (uuid == null)
|
||||
{
|
||||
startVpnProfile(profile);
|
||||
}
|
||||
else
|
||||
{
|
||||
Toast.makeText(this, R.string.profile_not_found, Toast.LENGTH_LONG).show();
|
||||
finish();
|
||||
return null;
|
||||
}
|
||||
VpnProfileDataSource dataSource = new VpnProfileSource(this);
|
||||
dataSource.open();
|
||||
VpnProfile profile = dataSource.getVpnProfile(uuid);
|
||||
dataSource.close();
|
||||
return profile;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -417,29 +478,38 @@ public class VpnProfileControlActivity extends AppCompatActivity
|
||||
*/
|
||||
private void disconnect(Intent intent)
|
||||
{
|
||||
VpnProfile profile = null;
|
||||
|
||||
removeFragmentByTag(DIALOG_TAG);
|
||||
VpnDialogBus.INSTANCE.dismiss();
|
||||
|
||||
String profileUUID = intent.getStringExtra(EXTRA_VPN_PROFILE_UUID);
|
||||
if (profileUUID == null)
|
||||
{
|
||||
profileUUID = intent.getStringExtra(EXTRA_VPN_PROFILE_ID);
|
||||
}
|
||||
if (profileUUID != null)
|
||||
{
|
||||
VpnProfileDataSource dataSource = new VpnProfileSource(this);
|
||||
dataSource.open();
|
||||
profile = dataSource.getVpnProfile(profileUUID);
|
||||
dataSource.close();
|
||||
}
|
||||
final String uuid = profileUUID;
|
||||
final Intent startIntent = intent;
|
||||
mProfileExecutor.execute(() -> {
|
||||
VpnProfile profile = loadProfile(uuid);
|
||||
runOnUiThread(() -> {
|
||||
if (!isFinishing() && !isDestroyed())
|
||||
{
|
||||
handleDisconnect(startIntent, profile);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Continue the disconnect flow on the main thread once the profile is loaded.
|
||||
*/
|
||||
private void handleDisconnect(Intent intent, VpnProfile profile)
|
||||
{
|
||||
if (mService != null)
|
||||
{
|
||||
if (mService.getState() == State.CONNECTED ||
|
||||
mService.getState() == State.CONNECTING)
|
||||
{
|
||||
if (profile != null && profile.equals(mService.getProfile()))
|
||||
if (intent.getBooleanExtra(EXTRA_SILENT, false) ||
|
||||
(profile != null && profile.equals(mService.getProfile())))
|
||||
{ /* allow explicit termination without confirmation */
|
||||
mService.disconnect();
|
||||
finish();
|
||||
@@ -447,10 +517,7 @@ public class VpnProfileControlActivity extends AppCompatActivity
|
||||
}
|
||||
Bundle args = new Bundle();
|
||||
args.putBoolean(PROFILE_DISCONNECT, true);
|
||||
|
||||
ConfirmationDialog dialog = new ConfirmationDialog();
|
||||
dialog.setArguments(args);
|
||||
dialog.show(this.getSupportFragmentManager(), DIALOG_TAG);
|
||||
showConfirmationDialog(args);
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -466,268 +533,139 @@ public class VpnProfileControlActivity extends AppCompatActivity
|
||||
{
|
||||
Intent intent = getIntent();
|
||||
|
||||
if (START_PROFILE.equals(intent.getAction()))
|
||||
{
|
||||
String action = normalizeAction(intent);
|
||||
|
||||
if (START_PROFILE.equals(action))
|
||||
{ /* app, tile, widget, shortcut: a hand action wins over a scenario */
|
||||
org.strongswan.android.logic.scenarios.ScenarioEngine.onManual(this);
|
||||
startVpnProfile(intent);
|
||||
}
|
||||
else if (DISCONNECT.equals(intent.getAction()))
|
||||
else if (DISCONNECT.equals(action))
|
||||
{
|
||||
org.strongswan.android.logic.scenarios.ScenarioEngine.onManual(this);
|
||||
disconnect(intent);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Dismiss dialog if shown
|
||||
*/
|
||||
public void removeFragmentByTag(String tag)
|
||||
{
|
||||
FragmentManager fm = getSupportFragmentManager();
|
||||
Fragment login = fm.findFragmentByTag(tag);
|
||||
if (login != null)
|
||||
else if (TOGGLE_VPN.equals(action))
|
||||
{
|
||||
FragmentTransaction ft = fm.beginTransaction();
|
||||
ft.remove(login);
|
||||
ft.commit();
|
||||
toggleVpn(intent);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Class that displays a confirmation dialog if a VPN profile is already connected
|
||||
* and then initiates the selected VPN profile if the user confirms the dialog.
|
||||
* Map the shxvpn:// deep links (connect/disconnect/toggle) to the actions
|
||||
* above, carrying over the optional profile parameter of the URI
|
||||
*
|
||||
* @param intent Intent that caused us to start this
|
||||
* @return the action to execute
|
||||
*/
|
||||
public static class ConfirmationDialog extends AppCompatDialogFragment
|
||||
private String normalizeAction(Intent intent)
|
||||
{
|
||||
@Override
|
||||
public Dialog onCreateDialog(Bundle savedInstanceState)
|
||||
Uri data = intent.getData();
|
||||
if (data == null || !"shxvpn".equals(data.getScheme()))
|
||||
{
|
||||
final Bundle profileInfo = getArguments();
|
||||
int icon = android.R.drawable.ic_dialog_alert;
|
||||
String title = String.format(getString(R.string.connect_profile_question), profileInfo.getString(PROFILE_NAME));
|
||||
int message = R.string.replaces_active_connection;
|
||||
int button = R.string.connect;
|
||||
|
||||
if (profileInfo.getBoolean(PROFILE_RECONNECT))
|
||||
return intent.getAction();
|
||||
}
|
||||
switch (data.getHost())
|
||||
{
|
||||
case "connect":
|
||||
{
|
||||
icon = android.R.drawable.ic_dialog_info;
|
||||
title = getString(R.string.vpn_connected);
|
||||
message = R.string.vpn_profile_connected;
|
||||
button = R.string.reconnect;
|
||||
}
|
||||
else if (profileInfo.getBoolean(PROFILE_DISCONNECT))
|
||||
{
|
||||
title = getString(R.string.disconnect_question);
|
||||
message = R.string.disconnect_active_connection;
|
||||
button = R.string.disconnect;
|
||||
}
|
||||
|
||||
DialogInterface.OnClickListener connectListener = new DialogInterface.OnClickListener()
|
||||
{
|
||||
@Override
|
||||
public void onClick(DialogInterface dialog, int which)
|
||||
String profile = data.getQueryParameter("profile");
|
||||
if (profile != null)
|
||||
{
|
||||
VpnProfileControlActivity activity = (VpnProfileControlActivity)getActivity();
|
||||
activity.startVpnProfile(profileInfo);
|
||||
intent.putExtra(EXTRA_VPN_PROFILE_UUID, profile);
|
||||
}
|
||||
};
|
||||
DialogInterface.OnClickListener disconnectListener = new DialogInterface.OnClickListener()
|
||||
{
|
||||
@Override
|
||||
public void onClick(DialogInterface dialog, int which)
|
||||
{
|
||||
VpnProfileControlActivity activity = (VpnProfileControlActivity)getActivity();
|
||||
if (activity.mService != null)
|
||||
{
|
||||
activity.mService.disconnect();
|
||||
}
|
||||
activity.finish();
|
||||
}
|
||||
};
|
||||
DialogInterface.OnClickListener cancelListener = new DialogInterface.OnClickListener()
|
||||
{
|
||||
@Override
|
||||
public void onClick(DialogInterface dialog, int which)
|
||||
{
|
||||
getActivity().finish();
|
||||
}
|
||||
};
|
||||
|
||||
AlertDialog.Builder builder = new AlertDialog.Builder(getActivity())
|
||||
.setIcon(icon)
|
||||
.setTitle(title)
|
||||
.setMessage(message);
|
||||
|
||||
if (profileInfo.getBoolean(PROFILE_DISCONNECT))
|
||||
{
|
||||
builder.setPositiveButton(button, disconnectListener);
|
||||
return START_PROFILE;
|
||||
}
|
||||
else
|
||||
{
|
||||
builder.setPositiveButton(button, connectListener);
|
||||
}
|
||||
|
||||
if (profileInfo.getBoolean(PROFILE_RECONNECT))
|
||||
{
|
||||
builder.setNegativeButton(R.string.disconnect, disconnectListener);
|
||||
builder.setNeutralButton(android.R.string.cancel, cancelListener);
|
||||
}
|
||||
else
|
||||
{
|
||||
builder.setNegativeButton(android.R.string.cancel, cancelListener);
|
||||
}
|
||||
return builder.create();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCancel(DialogInterface dialog)
|
||||
{
|
||||
getActivity().finish();
|
||||
case "disconnect":
|
||||
return DISCONNECT;
|
||||
case "toggle":
|
||||
return TOGGLE_VPN;
|
||||
default:
|
||||
return intent.getAction();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Class that displays a login dialog and initiates the selected VPN
|
||||
* profile if the user confirms the dialog.
|
||||
* Toggle the VPN connection: disconnect it if it is active (without asking),
|
||||
* otherwise connect to the given or the default/most recently used profile
|
||||
*
|
||||
* @param intent Intent that caused us to start this
|
||||
*/
|
||||
public static class LoginDialog extends AppCompatDialogFragment
|
||||
private void toggleVpn(Intent intent)
|
||||
{
|
||||
@Override
|
||||
public Dialog onCreateDialog(Bundle savedInstanceState)
|
||||
intent.putExtra(EXTRA_SILENT, true);
|
||||
if (mService != null && (mService.getState() == State.CONNECTED ||
|
||||
mService.getState() == State.CONNECTING))
|
||||
{
|
||||
final Bundle profileInfo = getArguments();
|
||||
LayoutInflater inflater = getActivity().getLayoutInflater();
|
||||
View view = inflater.inflate(R.layout.login_dialog, null);
|
||||
EditText username = view.findViewById(R.id.username);
|
||||
username.setText(profileInfo.getString(VpnProfileDataSource.KEY_USERNAME));
|
||||
final EditText password = view.findViewById(R.id.password);
|
||||
|
||||
AlertDialog.Builder adb = new AlertDialog.Builder(getActivity());
|
||||
adb.setView(view);
|
||||
adb.setTitle(getString(R.string.login_title));
|
||||
adb.setPositiveButton(R.string.login_confirm, new DialogInterface.OnClickListener()
|
||||
{
|
||||
@Override
|
||||
public void onClick(DialogInterface dialog, int whichButton)
|
||||
{
|
||||
VpnProfileControlActivity activity = (VpnProfileControlActivity)getActivity();
|
||||
profileInfo.putString(VpnProfileDataSource.KEY_PASSWORD, password.getText().toString().trim());
|
||||
activity.prepareVpnService(profileInfo);
|
||||
}
|
||||
});
|
||||
adb.setNegativeButton(android.R.string.cancel, new DialogInterface.OnClickListener()
|
||||
{
|
||||
@Override
|
||||
public void onClick(DialogInterface dialog, int which)
|
||||
{
|
||||
getActivity().finish();
|
||||
}
|
||||
});
|
||||
return adb.create();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCancel(DialogInterface dialog)
|
||||
{
|
||||
getActivity().finish();
|
||||
disconnect(intent);
|
||||
return;
|
||||
}
|
||||
startVpnProfile(intent);
|
||||
}
|
||||
|
||||
/**
|
||||
* Class that displays a dialog warning about a missing certificate.
|
||||
* The confirmation dialog was confirmed: start the profile or disconnect.
|
||||
*/
|
||||
public static class CertificateRequiredDialog extends AppCompatDialogFragment
|
||||
@Override
|
||||
public void onVpnDialogConfirm(Bundle profileInfo)
|
||||
{
|
||||
@Override
|
||||
public Dialog onCreateDialog(Bundle savedInstanceState)
|
||||
{
|
||||
final Bundle profileInfo = getArguments();
|
||||
startVpnProfile(profileInfo);
|
||||
}
|
||||
|
||||
AlertDialog.Builder builder = new AlertDialog.Builder(getActivity())
|
||||
.setTitle(R.string.certificate_required_title)
|
||||
.setMessage(R.string.certificate_required_text)
|
||||
.setNegativeButton(android.R.string.cancel, (dialog, which) -> getActivity().finish())
|
||||
.setPositiveButton(R.string.edit_profile, (dialog, which) -> {
|
||||
Intent editIntent = new Intent(getActivity(), VpnProfileDetailActivity.class);
|
||||
editIntent.putExtra(VpnProfileDataSource.KEY_UUID, profileInfo.getString(VpnProfileDataSource.KEY_UUID));
|
||||
startActivity(editIntent);
|
||||
getActivity().finish();
|
||||
});
|
||||
return builder.create();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCancel(DialogInterface dialog)
|
||||
@Override
|
||||
public void onVpnDialogDisconnect()
|
||||
{
|
||||
if (mService != null)
|
||||
{
|
||||
getActivity().finish();
|
||||
mService.disconnect();
|
||||
}
|
||||
finish();
|
||||
}
|
||||
|
||||
/**
|
||||
* Class that displays a warning before asking the user to add the app to the
|
||||
* device's power whitelist.
|
||||
* The login dialog provided the missing password.
|
||||
*/
|
||||
public static class PowerWhitelistRequired extends AppCompatDialogFragment
|
||||
@Override
|
||||
public void onVpnDialogLogin(Bundle profileInfo, String password)
|
||||
{
|
||||
@Override
|
||||
public Dialog onCreateDialog(Bundle savedInstanceState)
|
||||
{
|
||||
return new AlertDialog.Builder(getActivity())
|
||||
.setTitle(R.string.power_whitelist_title)
|
||||
.setMessage(R.string.power_whitelist_text)
|
||||
.setPositiveButton(android.R.string.ok, (dialog, id) -> {
|
||||
VpnProfileControlActivity activity = (VpnProfileControlActivity)getActivity();
|
||||
activity.mWaitingForResult = true;
|
||||
Intent intent = new Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS,
|
||||
Uri.parse("package:" + activity.getPackageName()));
|
||||
activity.mAddToPowerWhitelist.launch(intent);
|
||||
}).create();
|
||||
}
|
||||
profileInfo.putString(VpnProfileDataSource.KEY_PASSWORD, password != null ? password.trim() : "");
|
||||
prepareVpnService(profileInfo);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCancel(@NonNull DialogInterface dialog)
|
||||
{
|
||||
getActivity().finish();
|
||||
}
|
||||
@Override
|
||||
public void onVpnDialogEditProfile(String uuid)
|
||||
{
|
||||
Intent editIntent = new Intent(this, VpnProfileDetailActivity.class);
|
||||
editIntent.putExtra(VpnProfileDataSource.KEY_UUID, uuid);
|
||||
startActivity(editIntent);
|
||||
finish();
|
||||
}
|
||||
|
||||
/**
|
||||
* Class representing an error message which is displayed if VpnService is
|
||||
* not supported on the current device.
|
||||
* The user agreed to be added to the power whitelist.
|
||||
*/
|
||||
public static class VpnNotSupportedError extends AppCompatDialogFragment
|
||||
@Override
|
||||
public void onVpnDialogPowerWhitelist()
|
||||
{
|
||||
static final String ERROR_MESSAGE_ID = "one.shx.strongswan.ext.VpnNotSupportedError.MessageId";
|
||||
mWaitingForResult = true;
|
||||
Intent intent = new Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS,
|
||||
Uri.parse("package:" + getPackageName()));
|
||||
mAddToPowerWhitelist.launch(intent);
|
||||
}
|
||||
|
||||
public static void showWithMessage(AppCompatActivity activity, int messageId)
|
||||
{
|
||||
Bundle bundle = new Bundle();
|
||||
bundle.putInt(ERROR_MESSAGE_ID, messageId);
|
||||
VpnNotSupportedError dialog = new VpnNotSupportedError();
|
||||
dialog.setArguments(bundle);
|
||||
dialog.show(activity.getSupportFragmentManager(), DIALOG_TAG);
|
||||
}
|
||||
/**
|
||||
* Show the "VPN not supported" error dialog with the given message.
|
||||
*
|
||||
* @param messageId resource of the error message
|
||||
*/
|
||||
private void showNotSupported(int messageId)
|
||||
{
|
||||
VpnDialogBus.INSTANCE.show(new VpnDialog.NotSupported(messageId));
|
||||
}
|
||||
|
||||
@Override
|
||||
public Dialog onCreateDialog(Bundle savedInstanceState)
|
||||
{
|
||||
final Bundle arguments = getArguments();
|
||||
final int messageId = arguments.getInt(ERROR_MESSAGE_ID);
|
||||
return new AlertDialog.Builder(getActivity())
|
||||
.setTitle(R.string.vpn_not_supported_title)
|
||||
.setMessage(messageId)
|
||||
.setPositiveButton(android.R.string.ok, new DialogInterface.OnClickListener()
|
||||
{
|
||||
@Override
|
||||
public void onClick(DialogInterface dialog, int id)
|
||||
{
|
||||
getActivity().finish();
|
||||
}
|
||||
}).create();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCancel(DialogInterface dialog)
|
||||
{
|
||||
getActivity().finish();
|
||||
}
|
||||
@Override
|
||||
public void onVpnDialogCancel()
|
||||
{
|
||||
finish();
|
||||
}
|
||||
}
|
||||
+29
-8
@@ -14,17 +14,20 @@ import android.os.Bundle
|
||||
import android.widget.Toast
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.activity.viewModels
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.localbroadcastmanager.content.LocalBroadcastManager
|
||||
import org.strongswan.android.R
|
||||
import org.strongswan.android.data.VpnProfile
|
||||
import org.strongswan.android.data.VpnProfileDataSource
|
||||
import org.strongswan.android.data.VpnProfileSource
|
||||
import org.strongswan.android.ui.compose.ProfileFormScreen
|
||||
import org.strongswan.android.ui.compose.ProfileFormState
|
||||
import org.strongswan.android.ui.compose.isValid
|
||||
import org.strongswan.android.ui.compose.toFormState
|
||||
import org.strongswan.android.ui.compose.toProfile
|
||||
@@ -32,11 +35,28 @@ import org.strongswan.android.ui.compose.validate
|
||||
import org.strongswan.android.ui.compose.theme.ShxTheme
|
||||
import org.strongswan.android.utils.Constants
|
||||
|
||||
/** Holds the form state so entered values survive activity recreation (rotation). */
|
||||
class ProfileFormViewModel(initial: ProfileFormState) : ViewModel()
|
||||
{
|
||||
var state by mutableStateOf(initial)
|
||||
}
|
||||
|
||||
class VpnProfileDetailActivity : AppCompatActivity()
|
||||
{
|
||||
private lateinit var dataSource: VpnProfileDataSource
|
||||
private var existing: VpnProfile? = null
|
||||
|
||||
private val formViewModel: ProfileFormViewModel by viewModels {
|
||||
object : ViewModelProvider.Factory
|
||||
{
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>): T =
|
||||
ProfileFormViewModel(existing?.toFormState() ?: ProfileFormState()) as T
|
||||
}
|
||||
}
|
||||
|
||||
private val messages = kotlinx.coroutines.flow.MutableSharedFlow<Int>(extraBufferCapacity = 1)
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?)
|
||||
{
|
||||
super.onCreate(savedInstanceState)
|
||||
@@ -53,28 +73,29 @@ class VpnProfileDetailActivity : AppCompatActivity()
|
||||
return
|
||||
}
|
||||
}
|
||||
val initial = existing?.toFormState() ?: org.strongswan.android.ui.compose.ProfileFormState()
|
||||
val title = if (existing != null) existing!!.name ?: getString(R.string.add_profile)
|
||||
else getString(R.string.add_profile)
|
||||
setContent {
|
||||
ShxTheme {
|
||||
var state by remember { mutableStateOf(initial) }
|
||||
ProfileFormScreen(
|
||||
title = title,
|
||||
state = state,
|
||||
onChange = { state = it },
|
||||
state = formViewModel.state,
|
||||
onChange = { formViewModel.state = it },
|
||||
onCancel = { finish() },
|
||||
messages = messages,
|
||||
onSave = {
|
||||
val state = formViewModel.state
|
||||
val checked = state.validate(this)
|
||||
state = checked
|
||||
formViewModel.state = checked
|
||||
if (!checked.isValid())
|
||||
{
|
||||
Toast.makeText(this, R.string.alert_text_no_input_gateway, Toast.LENGTH_SHORT).show()
|
||||
messages.tryEmit(R.string.alert_text_no_input_gateway)
|
||||
return@ProfileFormScreen
|
||||
}
|
||||
val profile = checked.toProfile(existing)
|
||||
if (existing != null) dataSource.updateVpnProfile(profile)
|
||||
else dataSource.insertProfile(profile)
|
||||
if (existing == null) org.strongswan.android.logic.profiles.ProfileExtras.recordManual(this@VpnProfileDetailActivity, profile.getUUID().toString())
|
||||
val intent = Intent(Constants.VPN_PROFILES_CHANGED)
|
||||
intent.putExtra(Constants.VPN_PROFILES_SINGLE, profile.getUUID().toString())
|
||||
LocalBroadcastManager.getInstance(this).sendBroadcast(intent)
|
||||
|
||||
+119
-173
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright (C) 2016-2025 Tobias Brunner
|
||||
* Copyright (C) 2012-2020 Tobias Brunner
|
||||
*
|
||||
* Copyright (C) secunet Security Networks AG
|
||||
*
|
||||
@@ -28,21 +28,13 @@ import android.security.KeyChainAliasCallback;
|
||||
import android.security.KeyChainException;
|
||||
import android.text.TextUtils;
|
||||
import android.util.Base64;
|
||||
import android.view.Menu;
|
||||
import android.view.MenuInflater;
|
||||
import android.view.MenuItem;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.Button;
|
||||
import android.widget.EditText;
|
||||
import android.widget.RelativeLayout;
|
||||
import android.widget.TextView;
|
||||
import android.widget.Toast;
|
||||
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONException;
|
||||
import org.json.JSONObject;
|
||||
import org.strongswan.android.R;
|
||||
import org.strongswan.android.logic.profiles.ProfileExtras;
|
||||
import org.strongswan.android.data.VpnProfile;
|
||||
import org.strongswan.android.data.VpnProfile.SelectedAppsHandling;
|
||||
import org.strongswan.android.data.VpnProfileDataSource;
|
||||
@@ -50,8 +42,10 @@ import org.strongswan.android.data.VpnProfileSource;
|
||||
import org.strongswan.android.data.VpnType;
|
||||
import org.strongswan.android.data.VpnType.VpnTypeFeature;
|
||||
import org.strongswan.android.logic.TrustedCertificateManager;
|
||||
import org.strongswan.android.logic.routing.RoutingManager;
|
||||
import org.strongswan.android.security.TrustedCertificateEntry;
|
||||
import org.strongswan.android.ui.widget.TextInputLayoutHelper;
|
||||
import org.strongswan.android.ui.compose.ImportUiModel;
|
||||
import org.strongswan.android.ui.compose.ImportScreenKt;
|
||||
import org.strongswan.android.utils.Constants;
|
||||
import org.strongswan.android.utils.IPRangeSet;
|
||||
import org.strongswan.android.utils.Utils;
|
||||
@@ -77,9 +71,7 @@ import javax.net.ssl.SSLHandshakeException;
|
||||
|
||||
import androidx.activity.result.ActivityResultLauncher;
|
||||
import androidx.activity.result.contract.ActivityResultContracts;
|
||||
import androidx.appcompat.app.ActionBar;
|
||||
import androidx.appcompat.app.AppCompatActivity;
|
||||
import androidx.core.view.WindowCompat;
|
||||
import androidx.loader.app.LoaderManager;
|
||||
import androidx.loader.content.AsyncTaskLoader;
|
||||
import androidx.loader.content.Loader;
|
||||
@@ -98,31 +90,15 @@ public class VpnProfileImportActivity extends AppCompatActivity
|
||||
private TrustedCertificateEntry mCertEntry;
|
||||
private TrustedCertificateEntry mUserCertEntry;
|
||||
private String mUserCertLoading;
|
||||
private boolean mHideImport;
|
||||
private androidx.core.widget.ContentLoadingProgressBar mProgressBar;
|
||||
private TextView mExistsWarning;
|
||||
private TextView mSharedSecretWarning;
|
||||
private ViewGroup mBasicDataGroup;
|
||||
private TextView mName;
|
||||
private TextView mGateway;
|
||||
private TextView mSelectVpnType;
|
||||
private ViewGroup mUsernamePassword;
|
||||
private EditText mUsername;
|
||||
private TextInputLayoutHelper mUsernameWrap;
|
||||
private EditText mPassword;
|
||||
private ViewGroup mUserCertificate;
|
||||
private RelativeLayout mSelectUserCert;
|
||||
private Button mImportUserCert;
|
||||
private ViewGroup mRemoteCertificate;
|
||||
private RelativeLayout mRemoteCert;
|
||||
private final ImportUiModel mUi = new ImportUiModel();
|
||||
|
||||
private final ActivityResultLauncher<Intent> mImportPKCS12 = registerForActivityResult(
|
||||
new ActivityResultContracts.StartActivityForResult(),
|
||||
result -> {
|
||||
if (result.getResultCode() == RESULT_OK)
|
||||
{ /* no need to import twice */
|
||||
mImportUserCert.setEnabled(false);
|
||||
mSelectUserCert.performClick();
|
||||
mUi.setShowImportUserCert(false);
|
||||
chooseUserCertificate();
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -199,58 +175,12 @@ public class VpnProfileImportActivity extends AppCompatActivity
|
||||
{
|
||||
super.onCreate(savedInstanceState);
|
||||
|
||||
ActionBar bar = getSupportActionBar();
|
||||
if (bar != null)
|
||||
{
|
||||
bar.setHomeAsUpIndicator(R.drawable.ic_close_white_24dp);
|
||||
bar.setDisplayHomeAsUpEnabled(true);
|
||||
}
|
||||
|
||||
mDataSource = new VpnProfileSource(this);
|
||||
mDataSource.open();
|
||||
|
||||
setContentView(R.layout.profile_import_view);
|
||||
WindowCompat.enableEdgeToEdge(getWindow());
|
||||
|
||||
mProgressBar = findViewById(R.id.progress_bar);
|
||||
mExistsWarning = findViewById(R.id.exists_warning);
|
||||
mSharedSecretWarning = findViewById(R.id.shared_secret_warning);
|
||||
mBasicDataGroup = findViewById(R.id.basic_data_group);
|
||||
mName = findViewById(R.id.name);
|
||||
mGateway = findViewById(R.id.gateway);
|
||||
mSelectVpnType = findViewById(R.id.vpn_type);
|
||||
|
||||
mUsernamePassword = findViewById(R.id.username_password_group);
|
||||
mUsername = findViewById(R.id.username);
|
||||
mUsernameWrap = findViewById(R.id.username_wrap);
|
||||
mPassword = findViewById(R.id.password);
|
||||
|
||||
mUserCertificate = findViewById(R.id.user_certificate_group);
|
||||
mSelectUserCert = findViewById(R.id.select_user_certificate);
|
||||
mImportUserCert = findViewById(R.id.import_user_certificate);
|
||||
|
||||
mRemoteCertificate = findViewById(R.id.remote_certificate_group);
|
||||
mRemoteCert = findViewById(R.id.remote_certificate);
|
||||
|
||||
mExistsWarning.setVisibility(View.GONE);
|
||||
mSharedSecretWarning.setVisibility(View.GONE);
|
||||
mBasicDataGroup.setVisibility(View.GONE);
|
||||
mUsernamePassword.setVisibility(View.GONE);
|
||||
mUserCertificate.setVisibility(View.GONE);
|
||||
mRemoteCertificate.setVisibility(View.GONE);
|
||||
|
||||
mSelectUserCert.setOnClickListener(new SelectUserCertOnClickListener());
|
||||
mImportUserCert.setOnClickListener(new View.OnClickListener()
|
||||
{
|
||||
@Override
|
||||
public void onClick(View v)
|
||||
{
|
||||
Intent intent = KeyChain.createInstallIntent();
|
||||
intent.putExtra(KeyChain.EXTRA_NAME, getString(R.string.profile_cert_alias, mProfile.getName()));
|
||||
intent.putExtra(KeyChain.EXTRA_PKCS12, mProfile.PKCS12);
|
||||
mImportPKCS12.launch(intent);
|
||||
}
|
||||
});
|
||||
ImportScreenKt.installImportScreen(this, mUi,
|
||||
this::saveProfile, this::finish,
|
||||
this::importPkcs12, this::chooseUserCertificate, this::retryLoad);
|
||||
|
||||
Intent intent = getIntent();
|
||||
String action = intent.getAction();
|
||||
@@ -280,7 +210,10 @@ public class VpnProfileImportActivity extends AppCompatActivity
|
||||
{
|
||||
LoaderManager.getInstance(this).initLoader(USER_CERT_LOADER, null, mUserCertificateLoaderCallbacks);
|
||||
}
|
||||
mImportUserCert.setEnabled(!savedInstanceState.getBoolean(PKCS12_INSTALLED));
|
||||
if (savedInstanceState.getBoolean(PKCS12_INSTALLED))
|
||||
{
|
||||
mUi.setShowImportUserCert(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -299,50 +232,49 @@ public class VpnProfileImportActivity extends AppCompatActivity
|
||||
{
|
||||
outState.putString(VpnProfileDataSource.KEY_USER_CERTIFICATE, mUserCertEntry.getAlias());
|
||||
}
|
||||
outState.putBoolean(PKCS12_INSTALLED, !mImportUserCert.isEnabled());
|
||||
outState.putBoolean(PKCS12_INSTALLED, !mUi.getShowImportUserCert());
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onCreateOptionsMenu(Menu menu)
|
||||
private void importPkcs12()
|
||||
{
|
||||
MenuInflater inflater = getMenuInflater();
|
||||
inflater.inflate(R.menu.profile_import, menu);
|
||||
if (mHideImport)
|
||||
if (mProfile == null)
|
||||
{
|
||||
MenuItem item = menu.findItem(R.id.menu_accept);
|
||||
item.setVisible(false);
|
||||
return;
|
||||
}
|
||||
return true;
|
||||
Intent intent = KeyChain.createInstallIntent();
|
||||
intent.putExtra(KeyChain.EXTRA_NAME, getString(R.string.profile_cert_alias, mProfile.getName()));
|
||||
intent.putExtra(KeyChain.EXTRA_PKCS12, mProfile.PKCS12);
|
||||
mImportPKCS12.launch(intent);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onOptionsItemSelected(MenuItem item)
|
||||
{
|
||||
switch (item.getItemId())
|
||||
{
|
||||
case android.R.id.home:
|
||||
finish();
|
||||
return true;
|
||||
case R.id.menu_accept:
|
||||
saveProfile();
|
||||
return true;
|
||||
default:
|
||||
return super.onOptionsItemSelected(item);
|
||||
}
|
||||
}
|
||||
private Uri mLastUri;
|
||||
|
||||
private void loadProfile(Uri uri)
|
||||
{
|
||||
mProgressBar.show();
|
||||
mUi.setLoading(true);
|
||||
|
||||
/* shown in the preview: "link" for downloads, "file" otherwise */
|
||||
mUi.setSource(uri.getScheme());
|
||||
mUi.setSourceUri(uri.toString());
|
||||
mUi.setLoadError(null);
|
||||
mLastUri = uri;
|
||||
Bundle args = new Bundle();
|
||||
args.putParcelable(PROFILE_URI, uri);
|
||||
LoaderManager.getInstance(this).initLoader(PROFILE_LOADER, args, mProfileLoaderCallbacks);
|
||||
LoaderManager.getInstance(this).restartLoader(PROFILE_LOADER, args, mProfileLoaderCallbacks);
|
||||
}
|
||||
|
||||
/** «Try again» after a failed download. */
|
||||
private void retryLoad()
|
||||
{
|
||||
if (mLastUri != null)
|
||||
{
|
||||
loadProfile(mLastUri);
|
||||
}
|
||||
}
|
||||
|
||||
public void handleProfile(ProfileLoadResult data)
|
||||
{
|
||||
mProgressBar.hide();
|
||||
mUi.setLoading(false);
|
||||
|
||||
mProfile = null;
|
||||
if (data != null && data.ThrownException == null)
|
||||
@@ -354,10 +286,8 @@ public class VpnProfileImportActivity extends AppCompatActivity
|
||||
}
|
||||
catch (JSONException e)
|
||||
{
|
||||
mExistsWarning.setVisibility(View.VISIBLE);
|
||||
mExistsWarning.setText(e.getLocalizedMessage());
|
||||
mHideImport = true;
|
||||
invalidateOptionsMenu();
|
||||
mUi.setExistsWarning(e.getLocalizedMessage());
|
||||
mUi.setHideImport(true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -387,44 +317,34 @@ public class VpnProfileImportActivity extends AppCompatActivity
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
if (error != null)
|
||||
{
|
||||
Toast.makeText(this, getString(R.string.profile_import_failed_detail, error), Toast.LENGTH_LONG).show();
|
||||
}
|
||||
else
|
||||
{
|
||||
Toast.makeText(this, R.string.profile_import_failed, Toast.LENGTH_LONG).show();
|
||||
}
|
||||
finish();
|
||||
/* the sheet shows the failure with the likely reasons and a retry */
|
||||
mUi.setLoadError(error != null ? error : getString(R.string.profile_import_failed));
|
||||
return;
|
||||
}
|
||||
mExisting = mDataSource.getVpnProfile(mProfile.getUUID());
|
||||
mExistsWarning.setVisibility(mExisting != null ? View.VISIBLE : View.GONE);
|
||||
mUi.setExistsWarning(mExisting != null ? getString(R.string.profile_import_exists) : null);
|
||||
|
||||
mBasicDataGroup.setVisibility(View.VISIBLE);
|
||||
mName.setText(mProfile.getName());
|
||||
mGateway.setText(mProfile.getGateway());
|
||||
mSelectVpnType.setText(getResources().getStringArray(R.array.vpn_types)[mProfile.getVpnType().ordinal()]);
|
||||
mUi.setShowForm(true);
|
||||
mUi.setName(mProfile.getName());
|
||||
mUi.setGateway(mProfile.getGateway());
|
||||
mUi.setVpnType(getResources().getStringArray(R.array.vpn_types)[mProfile.getVpnType().ordinal()]);
|
||||
|
||||
mUsernamePassword.setVisibility(mProfile.getVpnType().has(VpnTypeFeature.USER_PASS) ? View.VISIBLE : View.GONE);
|
||||
mUi.setRequireUserPass(mProfile.getVpnType().has(VpnTypeFeature.USER_PASS));
|
||||
if (mProfile.getVpnType().has(VpnTypeFeature.USER_PASS))
|
||||
{
|
||||
mUsername.setText(mProfile.getUsername());
|
||||
if (!TextUtils.isEmpty(mProfile.getUsername()))
|
||||
{
|
||||
mUsername.setEnabled(false);
|
||||
}
|
||||
mPassword.setText(mProfile.getPassword());
|
||||
mUi.setUsername(mProfile.getUsername() != null ? mProfile.getUsername() : "");
|
||||
mUi.setUsernameEditable(TextUtils.isEmpty(mProfile.getUsername()));
|
||||
mUi.setPassword(mProfile.getPassword() != null ? mProfile.getPassword() : "");
|
||||
mUi.setPasswordEditable(TextUtils.isEmpty(mProfile.getPassword()));
|
||||
if (!TextUtils.isEmpty(mProfile.getPassword()))
|
||||
{
|
||||
mPassword.setEnabled(false);
|
||||
mSharedSecretWarning.setVisibility(View.VISIBLE);
|
||||
mUi.setSharedSecretWarning(true);
|
||||
}
|
||||
}
|
||||
|
||||
mUserCertificate.setVisibility(mProfile.getVpnType().has(VpnTypeFeature.CERTIFICATE) ? View.VISIBLE : View.GONE);
|
||||
mRemoteCertificate.setVisibility(mProfile.Certificate != null ? View.VISIBLE : View.GONE);
|
||||
mImportUserCert.setVisibility(mProfile.PKCS12 != null ? View.VISIBLE : View.GONE);
|
||||
mUi.setRequireUserCert(mProfile.getVpnType().has(VpnTypeFeature.CERTIFICATE));
|
||||
mUi.setShowRemoteCert(mProfile.Certificate != null);
|
||||
mUi.setShowImportUserCert(mProfile.PKCS12 != null);
|
||||
|
||||
if (mProfile.getVpnType().has(VpnTypeFeature.CERTIFICATE))
|
||||
{ /* try to load an existing certificate with the default name */
|
||||
@@ -446,13 +366,14 @@ public class VpnProfileImportActivity extends AppCompatActivity
|
||||
store.load(null, null);
|
||||
String alias = store.getCertificateAlias(certificate);
|
||||
mCertEntry = new TrustedCertificateEntry(alias, certificate);
|
||||
((TextView)mRemoteCert.findViewById(android.R.id.text1)).setText(mCertEntry.getSubjectPrimary());
|
||||
((TextView)mRemoteCert.findViewById(android.R.id.text2)).setText(mCertEntry.getSubjectSecondary());
|
||||
mUi.setShowRemoteCert(true);
|
||||
mUi.setRemoteCertPrimary(mCertEntry.getSubjectPrimary());
|
||||
mUi.setRemoteCertSecondary(mCertEntry.getSubjectSecondary());
|
||||
}
|
||||
catch (CertificateException | NoSuchAlgorithmException | KeyStoreException | IOException e)
|
||||
{
|
||||
e.printStackTrace();
|
||||
mRemoteCertificate.setVisibility(View.GONE);
|
||||
mUi.setShowRemoteCert(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -468,22 +389,40 @@ public class VpnProfileImportActivity extends AppCompatActivity
|
||||
{
|
||||
if (mUserCertLoading != null)
|
||||
{
|
||||
((TextView)mSelectUserCert.findViewById(android.R.id.text1)).setText(mUserCertLoading);
|
||||
((TextView)mSelectUserCert.findViewById(android.R.id.text2)).setText(R.string.loading);
|
||||
mUi.setUserCertLoading(true);
|
||||
mUi.setUserCertAlias(mUserCertLoading);
|
||||
mUi.setUserCertSubject(getString(R.string.loading));
|
||||
}
|
||||
else if (mUserCertEntry != null)
|
||||
{ /* clear any errors and set the new data */
|
||||
((TextView)mSelectUserCert.findViewById(android.R.id.text1)).setError(null);
|
||||
((TextView)mSelectUserCert.findViewById(android.R.id.text1)).setText(mUserCertEntry.getAlias());
|
||||
((TextView)mSelectUserCert.findViewById(android.R.id.text2)).setText(mUserCertEntry.getCertificate().getSubjectDN().toString());
|
||||
mUi.setUserCertLoading(false);
|
||||
mUi.setUserCertError(false);
|
||||
mUi.setUserCertAlias(mUserCertEntry.getAlias());
|
||||
mUi.setUserCertSubject(mUserCertEntry.getCertificate().getSubjectDN().toString());
|
||||
}
|
||||
else
|
||||
{
|
||||
((TextView)mSelectUserCert.findViewById(android.R.id.text1)).setText(R.string.profile_user_select_certificate_label);
|
||||
((TextView)mSelectUserCert.findViewById(android.R.id.text2)).setText(R.string.profile_user_select_certificate);
|
||||
mUi.setUserCertLoading(false);
|
||||
mUi.setUserCertAlias(null);
|
||||
mUi.setUserCertSubject(null);
|
||||
}
|
||||
}
|
||||
|
||||
private void chooseUserCertificate()
|
||||
{
|
||||
String alias = null;
|
||||
if (mUserCertEntry != null)
|
||||
{
|
||||
alias = mUserCertEntry.getAlias();
|
||||
mUserCertEntry = null;
|
||||
}
|
||||
else if (mProfile != null)
|
||||
{
|
||||
alias = getString(R.string.profile_cert_alias, mProfile.getName());
|
||||
}
|
||||
KeyChain.choosePrivateKeyAlias(this, new UserCertAliasCallback(), null, null, null, -1, alias);
|
||||
}
|
||||
|
||||
private ParsedVpnProfile parseProfile(JSONObject obj) throws JSONException
|
||||
{
|
||||
UUID uuid;
|
||||
@@ -701,6 +640,11 @@ public class VpnProfileImportActivity extends AppCompatActivity
|
||||
if (verifyInput())
|
||||
{
|
||||
updateProfileData();
|
||||
if (mProfile.getSelectedAppsHandling() != SelectedAppsHandling.SELECTED_APPS_DISABLE)
|
||||
{ /* per-profile apps are gone: they join the global direct-apps list */
|
||||
RoutingManager.getInstance(this).importProfileApps(mProfile.getSelectedAppsHandling(), mProfile.getSelectedAppsSet());
|
||||
mProfile.setSelectedAppsHandling(SelectedAppsHandling.SELECTED_APPS_DISABLE);
|
||||
}
|
||||
if (mExisting != null)
|
||||
{
|
||||
mProfile.setUUID(mExisting.getUUID());
|
||||
@@ -725,6 +669,11 @@ public class VpnProfileImportActivity extends AppCompatActivity
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
/* remember where it came from (F_Profile «Profile source») */
|
||||
ProfileExtras.recordImport(this, mProfile.getUUID().toString(),
|
||||
mLastUri != null ? mLastUri.getScheme() : null,
|
||||
mLastUri != null ? mLastUri.toString() : null,
|
||||
mLastUri != null && mLastUri.getPath() != null && mLastUri.getPath().contains("/qr/"));
|
||||
Intent intent = new Intent(Constants.VPN_PROFILES_CHANGED);
|
||||
intent.putExtra(Constants.VPN_PROFILES_SINGLE, mProfile.getUUID().toString());
|
||||
LocalBroadcastManager.getInstance(this).sendBroadcast(intent);
|
||||
@@ -732,6 +681,13 @@ public class VpnProfileImportActivity extends AppCompatActivity
|
||||
intent = new Intent(this, MainActivity.class);
|
||||
intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
|
||||
startActivity(intent);
|
||||
if (mUi.getConnectNow())
|
||||
{ /* «Connect right away» in the preview */
|
||||
Intent start = new Intent(this, VpnProfileControlActivity.class);
|
||||
start.setAction(VpnProfileControlActivity.START_PROFILE);
|
||||
start.putExtra(VpnProfileControlActivity.EXTRA_VPN_PROFILE_UUID, mProfile.getUUID().toString());
|
||||
startActivity(start);
|
||||
}
|
||||
|
||||
setResult(RESULT_OK, new Intent().putExtra(VpnProfileDataSource.KEY_UUID, mProfile.getUUID().toString()));
|
||||
finish();
|
||||
@@ -748,15 +704,15 @@ public class VpnProfileImportActivity extends AppCompatActivity
|
||||
boolean valid = true;
|
||||
if (mProfile.getVpnType().has(VpnTypeFeature.USER_PASS))
|
||||
{
|
||||
if (mUsername.getText().toString().trim().isEmpty())
|
||||
if (mUi.getUsername().trim().isEmpty())
|
||||
{
|
||||
mUsernameWrap.setError(getString(R.string.alert_text_no_input_username));
|
||||
mUi.setUsernameError(true);
|
||||
valid = false;
|
||||
}
|
||||
}
|
||||
if (mProfile.getVpnType().has(VpnTypeFeature.CERTIFICATE) && mUserCertEntry == null)
|
||||
{ /* let's show an error icon */
|
||||
((TextView)mSelectUserCert.findViewById(android.R.id.text1)).setError("");
|
||||
{
|
||||
mUi.setUserCertError(true);
|
||||
valid = false;
|
||||
}
|
||||
return valid;
|
||||
@@ -767,10 +723,16 @@ public class VpnProfileImportActivity extends AppCompatActivity
|
||||
*/
|
||||
private void updateProfileData()
|
||||
{
|
||||
/* the name can be changed in the preview */
|
||||
String name = mUi.getName().trim();
|
||||
if (!name.isEmpty())
|
||||
{
|
||||
mProfile.setName(name);
|
||||
}
|
||||
if (mProfile.getVpnType().has(VpnTypeFeature.USER_PASS))
|
||||
{
|
||||
mProfile.setUsername(mUsername.getText().toString().trim());
|
||||
String password = mPassword.getText().toString().trim();
|
||||
mProfile.setUsername(mUi.getUsername().trim());
|
||||
String password = mUi.getPassword().trim();
|
||||
password = password.isEmpty() ? null : password;
|
||||
mProfile.setPassword(password);
|
||||
}
|
||||
@@ -906,26 +868,10 @@ public class VpnProfileImportActivity extends AppCompatActivity
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask the user to select an available certificate.
|
||||
* Receives the alias chosen in the KeyChain dialog and loads the certificate.
|
||||
*/
|
||||
private class SelectUserCertOnClickListener implements View.OnClickListener, KeyChainAliasCallback
|
||||
private class UserCertAliasCallback implements KeyChainAliasCallback
|
||||
{
|
||||
@Override
|
||||
public void onClick(View v)
|
||||
{
|
||||
String alias = null;
|
||||
if (mUserCertEntry != null)
|
||||
{
|
||||
alias = mUserCertEntry.getAlias();
|
||||
mUserCertEntry = null;
|
||||
}
|
||||
else if (mProfile != null)
|
||||
{
|
||||
alias = getString(R.string.profile_cert_alias, mProfile.getName());
|
||||
}
|
||||
KeyChain.choosePrivateKeyAlias(VpnProfileImportActivity.this, this, null, null, null, -1, alias);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void alias(final String alias)
|
||||
{
|
||||
|
||||
+71
-17
@@ -17,18 +17,41 @@ import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.content.pm.ShortcutInfoCompat
|
||||
import androidx.core.content.pm.ShortcutManagerCompat
|
||||
import androidx.core.graphics.drawable.IconCompat
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.strongswan.android.R
|
||||
import org.strongswan.android.ui.compose.theme.ShxType
|
||||
import org.strongswan.android.ui.compose.theme.LocalShxColors
|
||||
import org.strongswan.android.ui.compose.home.serverCode
|
||||
import org.strongswan.android.ui.compose.design.ShxPanel
|
||||
import org.strongswan.android.ui.compose.design.ShxIcons
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.background
|
||||
import org.strongswan.android.data.VpnProfile
|
||||
import org.strongswan.android.data.VpnProfileSource
|
||||
import org.strongswan.android.ui.compose.SecondaryScaffold
|
||||
@@ -41,11 +64,22 @@ class VpnProfileSelectActivity : AppCompatActivity()
|
||||
super.onCreate(savedInstanceState)
|
||||
enableEdgeToEdge()
|
||||
setResult(RESULT_CANCELED)
|
||||
val source = VpnProfileSource(this).open()
|
||||
val profiles = source.allVpnProfiles.sortedBy { it.name?.lowercase() ?: "" }
|
||||
source.close()
|
||||
setContent {
|
||||
ShxTheme {
|
||||
/* profile list comes from SQLite, load off the main thread */
|
||||
val profiles by produceState<List<VpnProfile>>(emptyList()) {
|
||||
value = withContext(Dispatchers.IO) {
|
||||
val source = VpnProfileSource(applicationContext).open()
|
||||
try
|
||||
{
|
||||
source.allVpnProfiles.sortedBy { it.name?.lowercase() ?: "" }
|
||||
}
|
||||
finally
|
||||
{
|
||||
source.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
ProfileSelectScreen(
|
||||
profiles = profiles,
|
||||
onBack = { finish() },
|
||||
@@ -75,20 +109,40 @@ private fun ProfileSelectScreen(
|
||||
onSelect: (VpnProfile) -> Unit,
|
||||
)
|
||||
{
|
||||
val c = LocalShxColors.current
|
||||
SecondaryScaffold(title = stringResource(R.string.strongswan_shortcut), onBack = onBack) { padding ->
|
||||
if (profiles.isEmpty())
|
||||
Column(Modifier.fillMaxSize().padding(padding).verticalScroll(rememberScrollState()).padding(start = 16.dp, end = 16.dp, bottom = 24.dp))
|
||||
{
|
||||
Text(stringResource(R.string.no_profiles), modifier = Modifier.padding(padding).padding(24.dp))
|
||||
}
|
||||
else
|
||||
{
|
||||
LazyColumn(Modifier.fillMaxSize().padding(padding)) {
|
||||
items(profiles, key = { it.getUUID().toString() }) { profile ->
|
||||
ListItem(
|
||||
headlineContent = { Text(profile.name ?: "") },
|
||||
supportingContent = { Text(profile.gateway ?: "") },
|
||||
modifier = Modifier.clickable { onSelect(profile) },
|
||||
if (profiles.isEmpty())
|
||||
{
|
||||
Text(stringResource(R.string.no_profiles), style = ShxType.body, color = c.textSub, modifier = Modifier.padding(horizontal = 6.dp, vertical = 8.dp))
|
||||
return@Column
|
||||
}
|
||||
/* the servers as on F_Servers: code circle, name, address */
|
||||
ShxPanel()
|
||||
{
|
||||
profiles.forEachIndexed { i, profile ->
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
modifier = Modifier.fillMaxWidth().height(68.dp).clickable(role = Role.Button) { onSelect(profile) }.padding(horizontal = 14.dp),
|
||||
)
|
||||
{
|
||||
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(40.dp).clip(CircleShape).background(c.tile))
|
||||
{
|
||||
Text(serverCode(profile, null), style = ShxType.chip.copy(fontWeight = FontWeight.ExtraBold), color = c.onAccentContainer)
|
||||
}
|
||||
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp))
|
||||
{
|
||||
Text(profile.name ?: profile.gateway ?: "", style = ShxType.rowTitle, color = c.text, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
Text(profile.gateway ?: "", style = ShxType.meta.copy(fontWeight = FontWeight.Normal), color = c.textSub, maxLines = 1)
|
||||
}
|
||||
Icon(ShxIcons.Chevron, contentDescription = null, tint = c.textSub, modifier = Modifier.size(18.dp))
|
||||
}
|
||||
if (i < profiles.lastIndex)
|
||||
{
|
||||
Box(Modifier.fillMaxWidth().height(1.dp).background(c.tile))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+58
-22
@@ -24,7 +24,6 @@ import android.content.ComponentName;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.content.ServiceConnection;
|
||||
import android.content.SharedPreferences;
|
||||
import android.graphics.drawable.Icon;
|
||||
import android.os.Build;
|
||||
import android.os.IBinder;
|
||||
@@ -38,9 +37,7 @@ import org.strongswan.android.data.VpnProfileDataSource;
|
||||
import org.strongswan.android.data.VpnProfileSource;
|
||||
import org.strongswan.android.data.VpnType;
|
||||
import org.strongswan.android.logic.VpnStateService;
|
||||
import org.strongswan.android.utils.Constants;
|
||||
|
||||
import androidx.preference.PreferenceManager;
|
||||
import org.strongswan.android.utils.DefaultVpnProfile;
|
||||
|
||||
@TargetApi(Build.VERSION_CODES.N)
|
||||
public class VpnTileService extends TileService implements VpnStateService.VpnStateListener
|
||||
@@ -48,6 +45,9 @@ public class VpnTileService extends TileService implements VpnStateService.VpnSt
|
||||
private boolean mListening;
|
||||
private VpnProfileDataSource mDataSource;
|
||||
private VpnStateService mService;
|
||||
private boolean mBound;
|
||||
private VpnProfile mDefaultProfile;
|
||||
private boolean mDefaultProfileLoaded;
|
||||
private final ServiceConnection mServiceConnection = new ServiceConnection()
|
||||
{
|
||||
@Override
|
||||
@@ -74,8 +74,8 @@ public class VpnTileService extends TileService implements VpnStateService.VpnSt
|
||||
super.onCreate();
|
||||
|
||||
Context context = getApplicationContext();
|
||||
context.bindService(new Intent(context, VpnStateService.class),
|
||||
mServiceConnection, Service.BIND_AUTO_CREATE);
|
||||
mBound = context.bindService(new Intent(context, VpnStateService.class),
|
||||
mServiceConnection, Service.BIND_AUTO_CREATE);
|
||||
|
||||
mDataSource = new VpnProfileSource(this);
|
||||
mDataSource.open();
|
||||
@@ -86,10 +86,14 @@ public class VpnTileService extends TileService implements VpnStateService.VpnSt
|
||||
{
|
||||
super.onDestroy();
|
||||
|
||||
if (mService != null)
|
||||
/* the binding lives on the application context: unbind even if the
|
||||
* service never connected, or it leaks for the life of the process */
|
||||
if (mBound)
|
||||
{
|
||||
getApplicationContext().unbindService(mServiceConnection);
|
||||
mBound = false;
|
||||
}
|
||||
mService = null;
|
||||
mDataSource.close();
|
||||
mDataSource = null;
|
||||
}
|
||||
@@ -99,6 +103,8 @@ public class VpnTileService extends TileService implements VpnStateService.VpnSt
|
||||
{
|
||||
super.onStartListening();
|
||||
mListening = true;
|
||||
/* the configured profile may have changed while the panel was closed */
|
||||
mDefaultProfileLoaded = false;
|
||||
|
||||
if (mService != null)
|
||||
{
|
||||
@@ -121,13 +127,14 @@ public class VpnTileService extends TileService implements VpnStateService.VpnSt
|
||||
|
||||
private VpnProfile getProfile()
|
||||
{
|
||||
SharedPreferences pref = PreferenceManager.getDefaultSharedPreferences(this);
|
||||
String uuid = pref.getString(Constants.PREF_DEFAULT_VPN_PROFILE, null);
|
||||
if (uuid == null || uuid.equals(Constants.PREF_DEFAULT_VPN_PROFILE_MRU))
|
||||
/* updateTile() runs on every state notification (including retry
|
||||
* countdown ticks): query the database once per listening session */
|
||||
if (!mDefaultProfileLoaded)
|
||||
{
|
||||
uuid = pref.getString(Constants.PREF_MRU_VPN_PROFILE, null);
|
||||
mDefaultProfile = mDataSource != null ? mDataSource.getVpnProfile(DefaultVpnProfile.getDefaultProfileUuid(this)) : null;
|
||||
mDefaultProfileLoaded = true;
|
||||
}
|
||||
return mDataSource != null ? mDataSource.getVpnProfile(uuid) : null;
|
||||
return mDefaultProfile;
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -145,7 +152,12 @@ public class VpnTileService extends TileService implements VpnStateService.VpnSt
|
||||
{ /* always get the plain profile without cached password */
|
||||
profile = mDataSource.getVpnProfile(profile.getUUID());
|
||||
}
|
||||
/* reconnect the profile in case of an error */
|
||||
/* a problem: the tap opens the app, which explains it (F_QsTile) */
|
||||
if (mService.getErrorState() != VpnStateService.ErrorState.NO_ERROR)
|
||||
{
|
||||
openApp(new Intent(this, MainActivity.class));
|
||||
return;
|
||||
}
|
||||
if (mService.getErrorState() == VpnStateService.ErrorState.NO_ERROR)
|
||||
{
|
||||
switch (mService.getState())
|
||||
@@ -205,11 +217,16 @@ public class VpnTileService extends TileService implements VpnStateService.VpnSt
|
||||
return;
|
||||
}
|
||||
}
|
||||
Intent intent = new Intent(this, MainActivity.class);
|
||||
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
|
||||
/* no servers yet: straight to «Add server» */
|
||||
openApp(new Intent(this, MainActivity.class).putExtra(MainActivity.EXTRA_ADD_SERVER, getProfile() == null));
|
||||
}
|
||||
|
||||
private void openApp(Intent intent)
|
||||
{
|
||||
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TOP);
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
|
||||
{
|
||||
startActivityAndCollapse(PendingIntent.getActivity(this, 0, intent, PendingIntent.FLAG_IMMUTABLE));
|
||||
startActivityAndCollapse(PendingIntent.getActivity(this, 0, intent, PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT));
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -248,11 +265,28 @@ public class VpnTileService extends TileService implements VpnStateService.VpnSt
|
||||
return;
|
||||
}
|
||||
|
||||
/* F_QsTile: always «SHX VPN», the state in the subtitle */
|
||||
tile.setLabel(getString(R.string.tile_label));
|
||||
String subtitle;
|
||||
if (error != VpnStateService.ErrorState.NO_ERROR)
|
||||
{
|
||||
tile.setState(Tile.STATE_INACTIVE);
|
||||
tile.setIcon(Icon.createWithResource(this, R.drawable.ic_notification_warning));
|
||||
tile.setLabel(getString(R.string.tile_connect));
|
||||
switch (error)
|
||||
{
|
||||
case UNREACHABLE:
|
||||
case LOOKUP_FAILED:
|
||||
subtitle = getString(R.string.tile_no_answer);
|
||||
break;
|
||||
case AUTH_FAILED:
|
||||
case PEER_AUTH_FAILED:
|
||||
case PASSWORD_MISSING:
|
||||
subtitle = getString(R.string.tile_auth_error);
|
||||
break;
|
||||
default:
|
||||
subtitle = getString(R.string.tile_problem);
|
||||
break;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -262,23 +296,25 @@ public class VpnTileService extends TileService implements VpnStateService.VpnSt
|
||||
case DISABLED:
|
||||
tile.setState(Tile.STATE_INACTIVE);
|
||||
tile.setIcon(Icon.createWithResource(this, R.drawable.ic_notification_disconnected));
|
||||
tile.setLabel(getString(R.string.tile_connect));
|
||||
subtitle = getString(R.string.tile_off);
|
||||
break;
|
||||
case CONNECTING:
|
||||
tile.setState(Tile.STATE_ACTIVE);
|
||||
tile.setIcon(Icon.createWithResource(this, R.drawable.ic_notification_connecting));
|
||||
tile.setLabel(getString(R.string.tile_disconnect));
|
||||
subtitle = getString(R.string.tile_connecting);
|
||||
break;
|
||||
case CONNECTED:
|
||||
default:
|
||||
tile.setState(Tile.STATE_ACTIVE);
|
||||
tile.setIcon(Icon.createWithResource(this, R.drawable.ic_notification));
|
||||
tile.setLabel(getString(R.string.tile_disconnect));
|
||||
/* the server name, unless the screen is locked */
|
||||
subtitle = profile != null && !isSecure() ? profile.getName() : getString(R.string.status_protected);
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (profile != null && !isSecure())
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q)
|
||||
{
|
||||
tile.setLabel(profile.getName());
|
||||
tile.setSubtitle(subtitle);
|
||||
}
|
||||
tile.updateTile();
|
||||
}
|
||||
|
||||
-53
@@ -1,53 +0,0 @@
|
||||
/*
|
||||
* Copyright (C) 2016-2019 Tobias Brunner
|
||||
*
|
||||
* Copyright (C) secunet Security Networks AG
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or modify it
|
||||
* under the terms of the GNU General Public License as published by the
|
||||
* Free Software Foundation; either version 2 of the License, or (at your
|
||||
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful, but
|
||||
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
||||
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
|
||||
* for more details.
|
||||
*/
|
||||
|
||||
package org.strongswan.android.ui.adapter;
|
||||
|
||||
import android.content.Context;
|
||||
import android.widget.ArrayAdapter;
|
||||
|
||||
import org.strongswan.android.security.TrustedCertificateEntry;
|
||||
|
||||
public class CertificateIdentitiesAdapter extends ArrayAdapter<String>
|
||||
{
|
||||
TrustedCertificateEntry mCertificate;
|
||||
|
||||
public CertificateIdentitiesAdapter(Context context)
|
||||
{
|
||||
super(context, android.R.layout.simple_dropdown_item_1line);
|
||||
extractIdentities();
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a new certificate for this adapter.
|
||||
*
|
||||
* @param certificate the certificate to extract identities from (null to clear)
|
||||
*/
|
||||
public void setCertificate(TrustedCertificateEntry certificate)
|
||||
{
|
||||
mCertificate = certificate;
|
||||
clear();
|
||||
extractIdentities();
|
||||
}
|
||||
|
||||
private void extractIdentities()
|
||||
{
|
||||
if (mCertificate != null)
|
||||
{
|
||||
addAll(mCertificate.getSubjectAltNames());
|
||||
}
|
||||
}
|
||||
}
|
||||
-72
@@ -1,72 +0,0 @@
|
||||
/*
|
||||
* Copyright (C) 2013 Tobias Brunner
|
||||
*
|
||||
* Copyright (C) secunet Security Networks AG
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or modify it
|
||||
* under the terms of the GNU General Public License as published by the
|
||||
* Free Software Foundation; either version 2 of the License, or (at your
|
||||
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful, but
|
||||
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
||||
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
|
||||
* for more details.
|
||||
*/
|
||||
|
||||
package org.strongswan.android.ui.adapter;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import org.strongswan.android.R;
|
||||
import org.strongswan.android.logic.imc.RemediationInstruction;
|
||||
|
||||
import android.content.Context;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.ArrayAdapter;
|
||||
import android.widget.TextView;
|
||||
|
||||
public class RemediationInstructionAdapter extends ArrayAdapter<RemediationInstruction>
|
||||
{
|
||||
public RemediationInstructionAdapter(Context context)
|
||||
{
|
||||
super(context, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set new data for this adapter.
|
||||
*
|
||||
* @param data the new data (null to clear)
|
||||
*/
|
||||
public void setData(List<RemediationInstruction> data)
|
||||
{
|
||||
clear();
|
||||
if (data != null)
|
||||
{
|
||||
addAll(data);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public View getView(int position, View convertView, ViewGroup parent)
|
||||
{
|
||||
View view;
|
||||
if (convertView != null)
|
||||
{
|
||||
view = convertView;
|
||||
}
|
||||
else
|
||||
{
|
||||
LayoutInflater inflater = LayoutInflater.from(getContext());
|
||||
view = inflater.inflate(R.layout.remediation_instruction_item, parent, false);
|
||||
}
|
||||
RemediationInstruction item = getItem(position);
|
||||
TextView text = (TextView)view.findViewById(android.R.id.text1);
|
||||
text.setText(item.getTitle());
|
||||
text = (TextView)view.findViewById(android.R.id.text2);
|
||||
text.setText(item.getDescription());
|
||||
return view;
|
||||
}
|
||||
}
|
||||
-73
@@ -1,73 +0,0 @@
|
||||
/*
|
||||
* Copyright (C) 2017 Tobias Brunner
|
||||
*
|
||||
* Copyright (C) secunet Security Networks AG
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or modify it
|
||||
* under the terms of the GNU General Public License as published by the
|
||||
* Free Software Foundation; either version 2 of the License, or (at your
|
||||
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful, but
|
||||
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
||||
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
|
||||
* for more details.
|
||||
*/
|
||||
|
||||
package org.strongswan.android.ui.adapter;
|
||||
|
||||
import android.content.pm.ApplicationInfo;
|
||||
import android.content.pm.PackageManager;
|
||||
import android.graphics.drawable.Drawable;
|
||||
|
||||
import java.text.Collator;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
public class SelectedApplicationEntry implements Comparable<SelectedApplicationEntry>
|
||||
{
|
||||
private final ApplicationInfo mInfo;
|
||||
private final Drawable mIcon;
|
||||
private final String mName;
|
||||
private boolean mSelected;
|
||||
|
||||
public SelectedApplicationEntry(PackageManager packageManager, ApplicationInfo info)
|
||||
{
|
||||
mInfo = info;
|
||||
CharSequence name = info.loadLabel(packageManager);
|
||||
mName = name == null ? info.packageName : name.toString();
|
||||
mIcon = info.loadIcon(packageManager);
|
||||
}
|
||||
|
||||
public void setSelected(boolean selected)
|
||||
{
|
||||
mSelected = selected;
|
||||
}
|
||||
|
||||
public boolean isSelected()
|
||||
{
|
||||
return mSelected;
|
||||
}
|
||||
|
||||
public ApplicationInfo getInfo()
|
||||
{
|
||||
return mInfo;
|
||||
}
|
||||
|
||||
public Drawable getIcon()
|
||||
{
|
||||
return mIcon;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString()
|
||||
{
|
||||
return mName;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int compareTo(@NonNull SelectedApplicationEntry another)
|
||||
{
|
||||
return Collator.getInstance().compare(toString(), another.toString());
|
||||
}
|
||||
}
|
||||
-175
@@ -1,175 +0,0 @@
|
||||
/*
|
||||
* Copyright (C) 2017 Tobias Brunner
|
||||
*
|
||||
* Copyright (C) secunet Security Networks AG
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or modify it
|
||||
* under the terms of the GNU General Public License as published by the
|
||||
* Free Software Foundation; either version 2 of the License, or (at your
|
||||
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful, but
|
||||
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
||||
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
|
||||
* for more details.
|
||||
*/
|
||||
|
||||
package org.strongswan.android.ui.adapter;
|
||||
|
||||
import android.content.Context;
|
||||
import android.text.TextUtils;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.BaseAdapter;
|
||||
import android.widget.Filter;
|
||||
import android.widget.Filterable;
|
||||
import android.widget.ImageView;
|
||||
import android.widget.TextView;
|
||||
|
||||
import org.strongswan.android.R;
|
||||
import org.strongswan.android.ui.widget.CheckableLinearLayout;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
public class SelectedApplicationsAdapter extends BaseAdapter implements Filterable
|
||||
{
|
||||
private final Context mContext;
|
||||
private final Object mLock = new Object();
|
||||
private final List<SelectedApplicationEntry> mData;
|
||||
private List<SelectedApplicationEntry> mDataFiltered;
|
||||
private SelectedApplicationsFilter mFilter;
|
||||
private boolean mReadOnly;
|
||||
|
||||
public SelectedApplicationsAdapter(Context context)
|
||||
{
|
||||
mContext = context;
|
||||
mData = mDataFiltered = new ArrayList<>();
|
||||
}
|
||||
|
||||
/**
|
||||
* Set new data for this adapter.
|
||||
*
|
||||
* @param data the new data (null to clear)
|
||||
*/
|
||||
public void setData(List<SelectedApplicationEntry> data)
|
||||
{
|
||||
synchronized (mLock)
|
||||
{
|
||||
mData.clear();
|
||||
mDataFiltered = mData;
|
||||
if (data != null)
|
||||
{
|
||||
mData.addAll(data);
|
||||
}
|
||||
}
|
||||
notifyDataSetChanged();
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getCount()
|
||||
{
|
||||
return mDataFiltered.size();
|
||||
}
|
||||
|
||||
@Override
|
||||
public SelectedApplicationEntry getItem(int position)
|
||||
{
|
||||
return mDataFiltered.get(position);
|
||||
}
|
||||
|
||||
@Override
|
||||
public long getItemId(int position)
|
||||
{
|
||||
return mDataFiltered.get(position).toString().hashCode();
|
||||
}
|
||||
|
||||
@Override
|
||||
public View getView(int position, View convertView, ViewGroup parent)
|
||||
{
|
||||
View view;
|
||||
if (convertView != null)
|
||||
{
|
||||
view = convertView;
|
||||
}
|
||||
else
|
||||
{
|
||||
LayoutInflater inflater = LayoutInflater.from(mContext);
|
||||
view = inflater.inflate(R.layout.selected_application_item, parent, false);
|
||||
}
|
||||
SelectedApplicationEntry item = getItem(position);
|
||||
CheckableLinearLayout checkable = (CheckableLinearLayout)view;
|
||||
checkable.setChecked(item.isSelected());
|
||||
checkable.setEnabled(!mReadOnly);
|
||||
ImageView icon = view.findViewById(R.id.app_icon);
|
||||
icon.setImageDrawable(item.getIcon());
|
||||
TextView text = view.findViewById(R.id.app_name);
|
||||
text.setText(item.toString());
|
||||
return view;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Filter getFilter()
|
||||
{
|
||||
if (mFilter == null)
|
||||
{
|
||||
mFilter = new SelectedApplicationsFilter();
|
||||
}
|
||||
return mFilter;
|
||||
}
|
||||
|
||||
public boolean isReadOnly()
|
||||
{
|
||||
return mReadOnly;
|
||||
}
|
||||
|
||||
public void setReadOnly(final boolean readOnly)
|
||||
{
|
||||
this.mReadOnly = readOnly;
|
||||
}
|
||||
|
||||
private class SelectedApplicationsFilter extends Filter
|
||||
{
|
||||
|
||||
@Override
|
||||
protected FilterResults performFiltering(CharSequence constraint)
|
||||
{
|
||||
FilterResults results = new FilterResults();
|
||||
ArrayList<SelectedApplicationEntry> data, filtered;
|
||||
|
||||
synchronized (mLock)
|
||||
{
|
||||
data = new ArrayList<>(mData);
|
||||
}
|
||||
|
||||
if (TextUtils.isEmpty(constraint))
|
||||
{
|
||||
filtered = data;
|
||||
}
|
||||
else
|
||||
{
|
||||
String filter = constraint.toString().toLowerCase();
|
||||
filtered = new ArrayList<>();
|
||||
for (SelectedApplicationEntry entry : data)
|
||||
{
|
||||
if (entry.toString().toLowerCase().contains(filter))
|
||||
{
|
||||
filtered.add(entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
results.values = filtered;
|
||||
results.count = filtered.size();
|
||||
return results;
|
||||
}
|
||||
|
||||
@Override
|
||||
@SuppressWarnings("unchecked")
|
||||
protected void publishResults(CharSequence constraint, FilterResults results)
|
||||
{
|
||||
mDataFiltered = (List<SelectedApplicationEntry>)results.values;
|
||||
notifyDataSetChanged();
|
||||
}
|
||||
}
|
||||
}
|
||||
-72
@@ -1,72 +0,0 @@
|
||||
/*
|
||||
* Copyright (C) 2012 Tobias Brunner
|
||||
*
|
||||
* Copyright (C) secunet Security Networks AG
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or modify it
|
||||
* under the terms of the GNU General Public License as published by the
|
||||
* Free Software Foundation; either version 2 of the License, or (at your
|
||||
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful, but
|
||||
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
||||
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
|
||||
* for more details.
|
||||
*/
|
||||
|
||||
package org.strongswan.android.ui.adapter;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import org.strongswan.android.R;
|
||||
import org.strongswan.android.security.TrustedCertificateEntry;
|
||||
|
||||
import android.content.Context;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.ArrayAdapter;
|
||||
import android.widget.TextView;
|
||||
|
||||
public class TrustedCertificateAdapter extends ArrayAdapter<TrustedCertificateEntry>
|
||||
{
|
||||
public TrustedCertificateAdapter(Context context)
|
||||
{
|
||||
super(context, R.layout.trusted_certificates_item);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set new data for this adapter.
|
||||
*
|
||||
* @param data the new data (null to clear)
|
||||
*/
|
||||
public void setData(List<TrustedCertificateEntry> data)
|
||||
{
|
||||
clear();
|
||||
if (data != null)
|
||||
{
|
||||
addAll(data);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public View getView(int position, View convertView, ViewGroup parent)
|
||||
{
|
||||
View view;
|
||||
if (convertView != null)
|
||||
{
|
||||
view = convertView;
|
||||
}
|
||||
else
|
||||
{
|
||||
LayoutInflater inflater = LayoutInflater.from(getContext());
|
||||
view = inflater.inflate(R.layout.trusted_certificates_item, parent, false);
|
||||
}
|
||||
TrustedCertificateEntry item = getItem(position);
|
||||
TextView text = (TextView)view.findViewById(R.id.subject_primary);
|
||||
text.setText(item.getSubjectPrimary());
|
||||
text = (TextView)view.findViewById(R.id.subject_secondary);
|
||||
text.setText(item.getSubjectSecondary());
|
||||
return view;
|
||||
}
|
||||
}
|
||||
+263
@@ -0,0 +1,263 @@
|
||||
/*
|
||||
* Copyright (C) 2026 SHX VPN
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or modify it
|
||||
* under the terms of the GNU General Public License as published by the
|
||||
* Free Software Foundation; either version 2 of the License, or (at your
|
||||
* option) any later version.
|
||||
*/
|
||||
|
||||
package org.strongswan.android.ui.compose
|
||||
|
||||
import android.content.ClipboardManager
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.graphics.Bitmap
|
||||
import android.net.Uri
|
||||
import android.widget.Toast
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.core.content.FileProvider
|
||||
import com.google.zxing.BarcodeFormat
|
||||
import com.google.zxing.EncodeHintType
|
||||
import com.google.zxing.qrcode.QRCodeWriter
|
||||
import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel
|
||||
import com.journeyapps.barcodescanner.ScanContract
|
||||
import com.journeyapps.barcodescanner.ScanOptions
|
||||
import org.strongswan.android.R
|
||||
import org.strongswan.android.ui.VpnProfileDetailActivity
|
||||
import org.strongswan.android.ui.VpnProfileImportActivity
|
||||
import org.strongswan.android.ui.compose.design.ShxButton
|
||||
import org.strongswan.android.ui.compose.design.ShxButtonStyle
|
||||
import org.strongswan.android.ui.compose.design.ShxIcons
|
||||
import org.strongswan.android.ui.compose.design.ShxSheet
|
||||
import org.strongswan.android.ui.compose.theme.LocalShxColors
|
||||
import org.strongswan.android.ui.compose.theme.ShxType
|
||||
import java.io.File
|
||||
|
||||
/** QR codes of profiles: what a scan turns into, and a profile turned into a QR image. */
|
||||
object ProfileQr
|
||||
{
|
||||
/**
|
||||
* The import intent for a scanned text: a link opens the import screen on
|
||||
* that URL, a .sswan document is handed over as a temporary file.
|
||||
*/
|
||||
fun importIntent(context: Context, text: String): Intent?
|
||||
{
|
||||
val trimmed = text.trim()
|
||||
val intent = Intent(context, VpnProfileImportActivity::class.java).setAction(Intent.ACTION_VIEW)
|
||||
if (trimmed.startsWith("https://", true) || trimmed.startsWith("http://", true))
|
||||
{
|
||||
return intent.setData(Uri.parse(trimmed))
|
||||
}
|
||||
if (!trimmed.startsWith("{"))
|
||||
{
|
||||
return null
|
||||
}
|
||||
val dir = File(context.cacheDir, "qr").apply { mkdirs() }
|
||||
val file = File(dir, "scanned.sswan")
|
||||
file.writeText(trimmed)
|
||||
val uri = FileProvider.getUriForFile(context, context.packageName + ".fileprovider", file)
|
||||
return intent.setDataAndType(uri, "application/vnd.strongswan.profile").addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
}
|
||||
|
||||
/** A QR bitmap of [text], or null when it does not fit into a QR code. */
|
||||
fun bitmap(text: String, size: Int = 720, highCorrection: Boolean = false): Bitmap? = try
|
||||
{
|
||||
val matrix = QRCodeWriter().encode(
|
||||
text, BarcodeFormat.QR_CODE, size, size,
|
||||
mapOf(
|
||||
EncodeHintType.ERROR_CORRECTION to if (highCorrection) ErrorCorrectionLevel.H else ErrorCorrectionLevel.L,
|
||||
EncodeHintType.MARGIN to 1,
|
||||
EncodeHintType.CHARACTER_SET to "UTF-8",
|
||||
),
|
||||
)
|
||||
val pixels = IntArray(matrix.width * matrix.height) { i ->
|
||||
if (matrix.get(i % matrix.width, i / matrix.width)) android.graphics.Color.BLACK else android.graphics.Color.WHITE
|
||||
}
|
||||
Bitmap.createBitmap(pixels, matrix.width, matrix.height, Bitmap.Config.ARGB_8888)
|
||||
}
|
||||
catch (_: Exception)
|
||||
{
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/** «Add server» sheet (F_Import): QR first, then a link, then a file or manual entry. */
|
||||
@Composable
|
||||
fun AddServerSheet(onDismiss: () -> Unit)
|
||||
{
|
||||
val context = LocalContext.current
|
||||
val c = LocalShxColors.current
|
||||
var link by remember { mutableStateOf("") }
|
||||
val scanner = rememberLauncherForActivityResult(ScanContract()) { result ->
|
||||
val text = result.contents ?: return@rememberLauncherForActivityResult
|
||||
val intent = ProfileQr.importIntent(context, text)
|
||||
if (intent != null)
|
||||
{
|
||||
onDismiss()
|
||||
context.startActivity(intent)
|
||||
}
|
||||
else
|
||||
{
|
||||
Toast.makeText(context, R.string.add_qr_not_profile, Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
fun openLink()
|
||||
{
|
||||
val url = link.trim()
|
||||
if (!url.startsWith("http://", true) && !url.startsWith("https://", true))
|
||||
{
|
||||
Toast.makeText(context, R.string.add_link_invalid, Toast.LENGTH_SHORT).show()
|
||||
return
|
||||
}
|
||||
onDismiss()
|
||||
context.startActivity(Intent(context, VpnProfileImportActivity::class.java).setAction(Intent.ACTION_VIEW).setData(Uri.parse(url)))
|
||||
}
|
||||
ShxSheet(onDismiss = onDismiss)
|
||||
{
|
||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(horizontal = 4.dp))
|
||||
{
|
||||
Text(stringResource(R.string.add_server_title), style = ShxType.sheetTitle, color = c.text, modifier = Modifier.weight(1f))
|
||||
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(44.dp).clip(CircleShape).clickable(role = Role.Button, onClick = onDismiss))
|
||||
{
|
||||
Icon(ShxIcons.Close, contentDescription = stringResource(R.string.add_close), tint = c.textSoft, modifier = Modifier.size(22.dp))
|
||||
}
|
||||
}
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(14.dp),
|
||||
modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(24.dp)).background(c.accent)
|
||||
.clickable(role = Role.Button) {
|
||||
scanner.launch(
|
||||
ScanOptions()
|
||||
.setDesiredBarcodeFormats(ScanOptions.QR_CODE)
|
||||
.setPrompt(context.getString(R.string.add_qr_prompt))
|
||||
.setBeepEnabled(false)
|
||||
.setOrientationLocked(false)
|
||||
)
|
||||
}
|
||||
.padding(18.dp),
|
||||
)
|
||||
{
|
||||
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(48.dp).clip(RoundedCornerShape(16.dp)).background(Color.White.copy(alpha = 0.18f)))
|
||||
{
|
||||
Icon(ShxIcons.Scan, contentDescription = null, tint = Color.White, modifier = Modifier.size(26.dp))
|
||||
}
|
||||
Column(verticalArrangement = Arrangement.spacedBy(2.dp))
|
||||
{
|
||||
Text(stringResource(R.string.add_qr), style = ShxType.cardHeading, color = Color.White)
|
||||
Text(stringResource(R.string.add_qr_sub), style = ShxType.sub, color = Color(0xFFE7DFFF))
|
||||
}
|
||||
}
|
||||
val shape = RoundedCornerShape(24.dp)
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier.fillMaxWidth().clip(shape).background(c.card).border(1.dp, c.cardBorder, shape).padding(14.dp),
|
||||
)
|
||||
{
|
||||
Text(stringResource(R.string.add_link_title), style = ShxType.cardTitle, color = c.text)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically)
|
||||
{
|
||||
Box(
|
||||
contentAlignment = Alignment.CenterStart,
|
||||
modifier = Modifier.weight(1f).height(48.dp).clip(RoundedCornerShape(14.dp)).background(c.tile).padding(horizontal = 12.dp),
|
||||
)
|
||||
{
|
||||
if (link.isEmpty())
|
||||
{
|
||||
Text("https://…/profile.sswan", style = ShxType.body.copy(fontSize = 15.sp, fontWeight = FontWeight.Medium), color = c.textSub, maxLines = 1, overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis)
|
||||
}
|
||||
BasicTextField(
|
||||
value = link,
|
||||
onValueChange = { link = it },
|
||||
singleLine = true,
|
||||
textStyle = ShxType.body.copy(fontSize = 15.sp, fontWeight = FontWeight.Medium, color = c.text),
|
||||
cursorBrush = SolidColor(c.accent),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri, imeAction = ImeAction.Go),
|
||||
keyboardActions = KeyboardActions(onGo = { openLink() }),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier.height(48.dp).clip(RoundedCornerShape(14.dp)).background(c.accentMuted)
|
||||
.clickable(role = Role.Button) {
|
||||
if (link.isBlank())
|
||||
{
|
||||
val clip = (context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager)?.primaryClip
|
||||
link = clip?.takeIf { it.itemCount > 0 }?.getItemAt(0)?.coerceToText(context)?.toString()?.trim().orEmpty()
|
||||
}
|
||||
else
|
||||
{
|
||||
openLink()
|
||||
}
|
||||
}
|
||||
.padding(horizontal = 16.dp),
|
||||
)
|
||||
{
|
||||
Text(
|
||||
stringResource(if (link.isBlank()) R.string.add_link_paste else R.string.add_link_open),
|
||||
style = ShxType.body.copy(fontWeight = FontWeight.Bold),
|
||||
color = c.textTonal,
|
||||
)
|
||||
}
|
||||
}
|
||||
Text(stringResource(R.string.add_link_note), style = ShxType.meta.copy(fontWeight = FontWeight.Normal, lineHeight = 17.sp), color = c.textSub)
|
||||
}
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(10.dp))
|
||||
{
|
||||
ShxButton(
|
||||
stringResource(R.string.add_file),
|
||||
{
|
||||
onDismiss()
|
||||
context.startActivity(Intent(context, VpnProfileImportActivity::class.java))
|
||||
},
|
||||
style = ShxButtonStyle.OUTLINE, modifier = Modifier.weight(1f),
|
||||
)
|
||||
ShxButton(
|
||||
stringResource(R.string.add_manual),
|
||||
{
|
||||
onDismiss()
|
||||
context.startActivity(Intent(context, VpnProfileDetailActivity::class.java))
|
||||
},
|
||||
style = ShxButtonStyle.OUTLINE, modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+348
@@ -0,0 +1,348 @@
|
||||
/*
|
||||
* Copyright (C) 2026 SHX VPN
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or modify it
|
||||
* under the terms of the GNU General Public License as published by the
|
||||
* Free Software Foundation; either version 2 of the License, or (at your
|
||||
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful, but
|
||||
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
||||
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
|
||||
* for more details.
|
||||
*/
|
||||
|
||||
package org.strongswan.android.ui.compose
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import org.strongswan.android.ui.compose.design.ShxAlertDialog
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.RadioButton
|
||||
import androidx.compose.material3.SegmentedButton
|
||||
import androidx.compose.material3.SegmentedButtonDefaults
|
||||
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.strongswan.android.R
|
||||
import org.strongswan.android.ui.compose.theme.LocalShxColors
|
||||
import org.strongswan.android.ui.compose.theme.ShxType
|
||||
import org.strongswan.android.ui.compose.design.ShxDialogButton
|
||||
import org.strongswan.android.ui.compose.design.ShxIcons
|
||||
import org.strongswan.android.ui.compose.components.ShxSection
|
||||
import org.strongswan.android.ui.compose.components.ShxSectionDivider
|
||||
import org.strongswan.android.ui.compose.components.ShxSettingRow
|
||||
import org.strongswan.android.ui.compose.components.ShxSettingToggleRow
|
||||
import org.strongswan.android.logic.connectivity.HealthAction
|
||||
import org.strongswan.android.logic.connectivity.HealthCheckManager
|
||||
import org.strongswan.android.logic.connectivity.HealthConfig
|
||||
import org.strongswan.android.logic.connectivity.ProbeTarget
|
||||
import org.strongswan.android.logic.connectivity.ServerCheckMode
|
||||
import org.strongswan.android.ui.compose.theme.ShxSpacing
|
||||
|
||||
/**
|
||||
* Connectivity check settings: what to probe, how often, and what to do when
|
||||
* the server or the internet through the tunnel is unreachable.
|
||||
*/
|
||||
@Composable
|
||||
fun ConnectivityCheckScreen()
|
||||
{
|
||||
val context = LocalContext.current
|
||||
val manager = remember { HealthCheckManager.getInstance(context) }
|
||||
var config by remember { mutableStateOf(manager.currentConfig) }
|
||||
var showIntervalPicker by remember { mutableStateOf(false) }
|
||||
|
||||
fun update(transform: (HealthConfig) -> HealthConfig)
|
||||
{
|
||||
val updated = transform(config)
|
||||
manager.updateConfig { updated }
|
||||
config = updated
|
||||
}
|
||||
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(bottom = ShxSpacing.xl),
|
||||
)
|
||||
{
|
||||
ShxSection(title = stringResource(R.string.settings_section_general))
|
||||
{
|
||||
ShxSettingToggleRow(
|
||||
icon = ShxIcons.Pulse,
|
||||
title = stringResource(R.string.health_check_title),
|
||||
subtitle = stringResource(R.string.health_check_summary),
|
||||
checked = config.enabled,
|
||||
onCheckedChange = { checked -> update { it.copy(enabled = checked) } },
|
||||
)
|
||||
ShxSectionDivider()
|
||||
ShxSettingRow(
|
||||
icon = ShxIcons.Clock,
|
||||
title = stringResource(R.string.health_check_interval),
|
||||
subtitle = intervalLabel(config.intervalSec, config.aggressiveMode),
|
||||
enabled = config.enabled && !config.aggressiveMode,
|
||||
onClick = { if (config.enabled && !config.aggressiveMode) showIntervalPicker = true },
|
||||
)
|
||||
ShxSectionDivider()
|
||||
ShxSettingToggleRow(
|
||||
icon = ShxIcons.Bolt,
|
||||
title = stringResource(R.string.health_check_aggressive_title),
|
||||
subtitle = stringResource(R.string.health_check_aggressive_summary),
|
||||
checked = config.aggressiveMode,
|
||||
onCheckedChange = { aggressive -> update { it.copy(aggressiveMode = aggressive) } },
|
||||
)
|
||||
ShxSectionDivider()
|
||||
ShxSettingToggleRow(
|
||||
icon = ShxIcons.Battery,
|
||||
title = stringResource(R.string.health_check_adaptive_title),
|
||||
subtitle = stringResource(R.string.health_check_adaptive_summary),
|
||||
checked = config.adaptiveMode,
|
||||
onCheckedChange = { adaptive -> update { it.copy(adaptiveMode = adaptive) } },
|
||||
)
|
||||
}
|
||||
|
||||
ShxSection(title = stringResource(R.string.health_check_targets_title))
|
||||
{
|
||||
ShxSettingToggleRow(
|
||||
icon = ShxIcons.Shield,
|
||||
title = stringResource(R.string.health_check_server_title),
|
||||
subtitle = stringResource(R.string.health_check_server_summary),
|
||||
checked = config.serverCheckMode != ServerCheckMode.OFF,
|
||||
onCheckedChange = { on ->
|
||||
update {
|
||||
it.copy(serverCheckMode = if (on) ServerCheckMode.WITH_TUNNEL else ServerCheckMode.OFF)
|
||||
}
|
||||
},
|
||||
)
|
||||
listOf(ProbeTarget.GOOGLE, ProbeTarget.YOUTUBE, ProbeTarget.CLOUDFLARE).forEach { target ->
|
||||
ShxSectionDivider()
|
||||
ShxSettingToggleRow(
|
||||
icon = ShxIcons.Globe,
|
||||
title = targetLabel(target),
|
||||
subtitle = null,
|
||||
checked = config.targets.contains(target),
|
||||
onCheckedChange = { on ->
|
||||
update { it.copy(targets = if (on) it.targets + target else it.targets - target) }
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
ShxSection(title = stringResource(R.string.health_check_server_down_title))
|
||||
{
|
||||
Column(Modifier.padding(horizontal = ShxSpacing.lg, vertical = 10.dp))
|
||||
{
|
||||
Text(
|
||||
stringResource(R.string.health_check_mode),
|
||||
style = ShxType.body,
|
||||
color = if (config.enabled) LocalShxColors.current.text else LocalShxColors.current.textSub,
|
||||
)
|
||||
SingleChoiceSegmentedButtonRow(Modifier.fillMaxWidth().padding(top = ShxSpacing.sm))
|
||||
{
|
||||
ServerCheckMode.entries.forEachIndexed { index, mode ->
|
||||
SegmentedButton(
|
||||
selected = config.serverCheckMode == mode,
|
||||
onClick = { if (config.enabled) update { it.copy(serverCheckMode = mode) } },
|
||||
enabled = config.enabled,
|
||||
shape = SegmentedButtonDefaults.itemShape(index = index, count = ServerCheckMode.entries.size),
|
||||
label = { Text(serverModeShortLabel(mode), maxLines = 1) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
ShxSectionDivider()
|
||||
ShxSettingRow(
|
||||
icon = ShxIcons.Pulse,
|
||||
title = stringResource(R.string.health_check_action),
|
||||
subtitle = actionLabel(config.serverAction),
|
||||
enabled = config.enabled,
|
||||
onClick = {
|
||||
if (config.enabled)
|
||||
{
|
||||
update {
|
||||
it.copy(
|
||||
serverAction = if (it.serverAction == HealthAction.RECONNECT) HealthAction.IGNORE else HealthAction.RECONNECT
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
ShxSectionDivider()
|
||||
AttemptsRow(
|
||||
title = stringResource(R.string.health_check_max_attempts),
|
||||
attempts = config.serverMaxAttempts,
|
||||
enabled = config.enabled && config.serverAction == HealthAction.RECONNECT,
|
||||
onDecrement = { update { it.copy(serverMaxAttempts = (it.serverMaxAttempts - 1).coerceAtLeast(1)) } },
|
||||
onIncrement = { update { it.copy(serverMaxAttempts = (it.serverMaxAttempts + 1).coerceAtMost(5)) } },
|
||||
)
|
||||
}
|
||||
|
||||
ShxSection(title = stringResource(R.string.health_check_tunnel_dead_title))
|
||||
{
|
||||
ShxSettingRow(
|
||||
icon = ShxIcons.Pulse,
|
||||
title = stringResource(R.string.health_check_action),
|
||||
subtitle = actionLabel(config.tunnelDeadAction),
|
||||
enabled = config.enabled,
|
||||
onClick = {
|
||||
if (config.enabled)
|
||||
{
|
||||
update {
|
||||
it.copy(
|
||||
tunnelDeadAction = if (it.tunnelDeadAction == HealthAction.RECONNECT) HealthAction.IGNORE else HealthAction.RECONNECT
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
ShxSectionDivider()
|
||||
AttemptsRow(
|
||||
title = stringResource(R.string.health_check_max_attempts),
|
||||
attempts = config.tunnelDeadMaxAttempts,
|
||||
enabled = config.enabled && config.tunnelDeadAction == HealthAction.RECONNECT,
|
||||
onDecrement = { update { it.copy(tunnelDeadMaxAttempts = (it.tunnelDeadMaxAttempts - 1).coerceAtLeast(1)) } },
|
||||
onIncrement = { update { it.copy(tunnelDeadMaxAttempts = (it.tunnelDeadMaxAttempts + 1).coerceAtMost(5)) } },
|
||||
)
|
||||
}
|
||||
|
||||
ShxSection(title = stringResource(R.string.health_check_partial_title))
|
||||
{
|
||||
ShxSettingRow(
|
||||
icon = ShxIcons.Pulse,
|
||||
title = stringResource(R.string.health_check_action),
|
||||
subtitle = actionLabel(config.partialDownAction),
|
||||
enabled = config.enabled,
|
||||
onClick = {
|
||||
if (config.enabled)
|
||||
{
|
||||
update {
|
||||
it.copy(
|
||||
partialDownAction = if (it.partialDownAction == HealthAction.RECONNECT) HealthAction.IGNORE else HealthAction.RECONNECT
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (showIntervalPicker)
|
||||
{
|
||||
val options = listOf(15, 30, 60, 300)
|
||||
ShxAlertDialog(
|
||||
onDismissRequest = { showIntervalPicker = false },
|
||||
title = { Text(stringResource(R.string.health_check_interval)) },
|
||||
text = {
|
||||
Column {
|
||||
options.forEach { seconds ->
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable {
|
||||
update { it.copy(intervalSec = seconds) }
|
||||
showIntervalPicker = false
|
||||
}
|
||||
.padding(vertical = 10.dp),
|
||||
)
|
||||
{
|
||||
RadioButton(selected = config.intervalSec == seconds, onClick = null)
|
||||
Text(intervalLabel(seconds, false), modifier = Modifier.padding(start = ShxSpacing.md))
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
ShxDialogButton(onClick = { showIntervalPicker = false }) {
|
||||
Text(stringResource(android.R.string.cancel))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun intervalLabel(seconds: Int, aggressive: Boolean): String = when
|
||||
{
|
||||
aggressive -> stringResource(R.string.health_check_interval_aggressive)
|
||||
seconds < 60 -> stringResource(R.string.health_check_interval_seconds, seconds)
|
||||
else -> stringResource(R.string.health_check_interval_minutes, seconds / 60)
|
||||
}
|
||||
|
||||
/** Short labels for the segmented control (full ones don't fit a segment). */
|
||||
@Composable
|
||||
private fun serverModeShortLabel(mode: ServerCheckMode): String = when (mode)
|
||||
{
|
||||
ServerCheckMode.ALWAYS -> stringResource(R.string.health_check_mode_short_always)
|
||||
ServerCheckMode.WITH_TUNNEL -> stringResource(R.string.health_check_mode_short_tunnel_down)
|
||||
ServerCheckMode.OFF -> stringResource(R.string.health_check_mode_short_never)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun actionLabel(action: HealthAction): String = when (action)
|
||||
{
|
||||
HealthAction.RECONNECT -> stringResource(R.string.health_action_reconnect)
|
||||
HealthAction.IGNORE -> stringResource(R.string.health_action_ignore)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun targetLabel(target: ProbeTarget): String = when (target)
|
||||
{
|
||||
ProbeTarget.GOOGLE -> stringResource(R.string.probe_target_google)
|
||||
ProbeTarget.YOUTUBE -> stringResource(R.string.probe_target_youtube)
|
||||
ProbeTarget.CLOUDFLARE -> stringResource(R.string.probe_target_cloudflare)
|
||||
ProbeTarget.SERVER -> stringResource(R.string.probe_target_server)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AttemptsRow(
|
||||
title: String,
|
||||
attempts: Int,
|
||||
enabled: Boolean,
|
||||
onDecrement: () -> Unit,
|
||||
onIncrement: () -> Unit,
|
||||
)
|
||||
{
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = ShxSpacing.lg, vertical = 10.dp),
|
||||
)
|
||||
{
|
||||
Text(
|
||||
title,
|
||||
style = ShxType.body,
|
||||
color = if (enabled) LocalShxColors.current.text else LocalShxColors.current.textSub,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
IconButton(onClick = onDecrement, enabled = enabled && attempts > 1) {
|
||||
Icon(ShxIcons.Minus, contentDescription = stringResource(R.string.health_check_attempt_minus))
|
||||
}
|
||||
Text(
|
||||
text = attempts.toString(),
|
||||
style = ShxType.cardTitle,
|
||||
modifier = Modifier.padding(horizontal = ShxSpacing.sm),
|
||||
)
|
||||
IconButton(onClick = onIncrement, enabled = enabled && attempts < 5) {
|
||||
Icon(ShxIcons.Plus, contentDescription = stringResource(R.string.health_check_attempt_plus))
|
||||
}
|
||||
}
|
||||
}
|
||||
+407
@@ -0,0 +1,407 @@
|
||||
/*
|
||||
* Copyright (C) 2026 SHX VPN
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or modify it
|
||||
* under the terms of the GNU General Public License as published by the
|
||||
* Free Software Foundation; either version 2 of the License, or (at your
|
||||
* option) any later version.
|
||||
*/
|
||||
|
||||
package org.strongswan.android.ui.compose
|
||||
|
||||
import android.content.Context
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.NetworkCapabilities
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.statusBarsPadding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.repeatOnLifecycle
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.strongswan.android.R
|
||||
import org.strongswan.android.logic.CharonVpnService
|
||||
import org.strongswan.android.logic.VpnStateService
|
||||
import org.strongswan.android.logic.connectivity.ConnectivityProbe
|
||||
import org.strongswan.android.logic.connectivity.ProbeTarget
|
||||
import org.strongswan.android.logic.connectivity.WhiteListManager
|
||||
import org.strongswan.android.logic.connectivity.WhiteListStatus
|
||||
import org.strongswan.android.logic.routing.AppTraffic
|
||||
import org.strongswan.android.logic.routing.RoutingManager
|
||||
import org.strongswan.android.ui.compose.design.ShxButton
|
||||
import org.strongswan.android.ui.compose.design.ShxButtonStyle
|
||||
import org.strongswan.android.ui.compose.design.ShxIcons
|
||||
import org.strongswan.android.ui.compose.design.ShxPageHeader
|
||||
import org.strongswan.android.ui.compose.design.ShxPanel
|
||||
import org.strongswan.android.ui.compose.design.ShxSectionHeader
|
||||
import org.strongswan.android.ui.compose.routing.AppIcon
|
||||
import org.strongswan.android.ui.compose.theme.LocalShxColors
|
||||
import org.strongswan.android.ui.compose.theme.ShxType
|
||||
|
||||
/** One line of the check list. */
|
||||
data class DiagStep(val title: String, val value: String, val ok: Boolean?)
|
||||
|
||||
/** Result of a diagnostics round. */
|
||||
data class DiagReport(val steps: List<DiagStep>, val at: Long, val vpnUp: Boolean)
|
||||
{
|
||||
/** null = still checking, true = everything answered */
|
||||
val allOk: Boolean? get() = if (steps.any { it.ok == null }) null else steps.all { it.ok == true }
|
||||
}
|
||||
|
||||
private suspend fun runDiagnostics(context: Context): DiagReport = coroutineScope {
|
||||
val vpn = VpnStateService.getLiveInstance()
|
||||
val vpnUp = vpn?.state == VpnStateService.State.CONNECTED
|
||||
val gateway = (vpn?.profile)?.gateway
|
||||
val whiteList = WhiteListManager.getInstance(context)
|
||||
whiteList.checkNow()
|
||||
val server = async { if (gateway != null) ConnectivityProbe.probe(ProbeTarget.SERVER, 3000, context, gateway) else null }
|
||||
val foreign = async { ConnectivityProbe.probe(ProbeTarget.GOOGLE, 3000, context, null) }
|
||||
val network = withContext(Dispatchers.Default) { networkLabel(context) }
|
||||
/* the whitelist check runs its own direct round; give it a moment */
|
||||
delay(2500)
|
||||
val wl = whiteList.state.value
|
||||
val serverResult = server.await()
|
||||
val foreignResult = foreign.await()
|
||||
val steps = listOf(
|
||||
DiagStep(context.getString(R.string.diag_step_internet), network ?: context.getString(R.string.diag_none), network != null),
|
||||
DiagStep(
|
||||
context.getString(R.string.diag_step_ru),
|
||||
when (wl.domesticDirectOk)
|
||||
{
|
||||
true -> wl.domesticDirectRttMs?.let { context.getString(R.string.diag_ms, it) } ?: context.getString(R.string.diag_ok)
|
||||
false -> context.getString(R.string.diag_fail)
|
||||
null -> context.getString(R.string.diag_unknown)
|
||||
},
|
||||
wl.domesticDirectOk ?: (network != null),
|
||||
),
|
||||
DiagStep(
|
||||
context.getString(R.string.diag_step_foreign),
|
||||
when
|
||||
{
|
||||
!foreignResult.ok -> if (wl.status == WhiteListStatus.ACTIVE) context.getString(R.string.diag_whitelist) else context.getString(R.string.diag_fail)
|
||||
vpnUp -> context.getString(R.string.diag_via_vpn_ms, foreignResult.rttMs ?: 0)
|
||||
else -> context.getString(R.string.diag_direct_ms, foreignResult.rttMs ?: 0)
|
||||
},
|
||||
foreignResult.ok,
|
||||
),
|
||||
DiagStep(
|
||||
context.getString(R.string.diag_step_server),
|
||||
when
|
||||
{
|
||||
gateway == null -> context.getString(R.string.diag_no_server)
|
||||
serverResult?.ok == true -> context.getString(R.string.diag_ms, serverResult.rttMs ?: 0)
|
||||
else -> context.getString(R.string.diag_no_answer)
|
||||
},
|
||||
if (gateway == null) network != null else serverResult?.ok == true,
|
||||
),
|
||||
DiagStep(
|
||||
context.getString(R.string.diag_step_dns),
|
||||
context.getString(if (vpnUp) R.string.diag_dns_tunnel else R.string.diag_dns_provider),
|
||||
if (vpnUp) foreignResult.ok else network != null,
|
||||
),
|
||||
)
|
||||
DiagReport(steps, System.currentTimeMillis(), vpnUp)
|
||||
}
|
||||
|
||||
private fun networkLabel(context: Context): String?
|
||||
{
|
||||
val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager ?: return null
|
||||
var wifi = false
|
||||
var cell = false
|
||||
for (n in cm.allNetworks)
|
||||
{
|
||||
val caps = runCatching { cm.getNetworkCapabilities(n) }.getOrNull() ?: continue
|
||||
if (caps.hasTransport(NetworkCapabilities.TRANSPORT_VPN))
|
||||
{
|
||||
continue
|
||||
}
|
||||
if (caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)) wifi = true
|
||||
if (caps.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR)) cell = true
|
||||
}
|
||||
return when
|
||||
{
|
||||
wifi -> context.getString(R.string.network_wifi)
|
||||
cell -> context.getString(R.string.network_cellular)
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
/** «Diagnostics» (F_Diag): one look at the whole path, what does not open, unreachable hosts. */
|
||||
@Composable
|
||||
fun DiagnosticsScreen(
|
||||
onBack: () -> Unit,
|
||||
onLog: () -> Unit,
|
||||
onCheckSite: (String) -> Unit,
|
||||
onCheckApp: (String) -> Unit,
|
||||
)
|
||||
{
|
||||
val context = LocalContext.current
|
||||
val c = LocalShxColors.current
|
||||
var round by remember { mutableIntStateOf(0) }
|
||||
val report by produceState<DiagReport?>(null, round) {
|
||||
value = null
|
||||
value = runDiagnostics(context)
|
||||
}
|
||||
val lifecycle = LocalLifecycleOwner.current.lifecycle
|
||||
val dead by produceState(emptyList<DeadTargetUi>(), lifecycle) {
|
||||
lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
|
||||
while (true)
|
||||
{
|
||||
val raw = runCatching { withContext(Dispatchers.Default) { CharonVpnService.getDeadDirectTargets() } }.getOrNull()
|
||||
value = HomeViewModel.parseDeadTargets(raw).filter { !it.viaTunnel }
|
||||
delay(5_000)
|
||||
}
|
||||
}
|
||||
}
|
||||
val topApps by produceState(emptyList<String>()) {
|
||||
value = withContext(Dispatchers.IO) {
|
||||
AppTraffic.since(context, System.currentTimeMillis() - 60L * 60 * 1000).entries
|
||||
.sortedByDescending { it.value }.map { it.key }.take(4)
|
||||
}
|
||||
}
|
||||
var site by remember { mutableStateOf("") }
|
||||
val manager = remember { RoutingManager.getInstance(context) }
|
||||
|
||||
Column(Modifier.fillMaxSize().statusBarsPadding().verticalScroll(rememberScrollState()))
|
||||
{
|
||||
ShxPageHeader(stringResource(R.string.diag_title), onBack)
|
||||
{
|
||||
ShxButton(stringResource(R.string.diag_log), onLog, style = ShxButtonStyle.TONAL, height = 40.dp)
|
||||
}
|
||||
Column(Modifier.padding(start = 16.dp, end = 16.dp, top = 4.dp, bottom = 24.dp), verticalArrangement = Arrangement.spacedBy(14.dp))
|
||||
{
|
||||
Verdict(report, onRepeat = { round++ })
|
||||
ShxPanel(padding = androidx.compose.foundation.layout.PaddingValues(horizontal = 16.dp, vertical = 4.dp))
|
||||
{
|
||||
val steps = report?.steps ?: listOf(
|
||||
R.string.diag_step_internet, R.string.diag_step_ru, R.string.diag_step_foreign, R.string.diag_step_server, R.string.diag_step_dns,
|
||||
).map { DiagStep(context.getString(it), context.getString(R.string.diag_checking), null) }
|
||||
steps.forEachIndexed { i, step ->
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.fillMaxWidth().height(50.dp))
|
||||
{
|
||||
val (bg, fg, mark) = when (step.ok)
|
||||
{
|
||||
true -> Triple(c.successContainer, c.onSuccess, "✓")
|
||||
false -> Triple(c.errorContainer, c.onErrorContainer, "×")
|
||||
null -> Triple(c.tile, c.iconMuted, "…")
|
||||
}
|
||||
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(22.dp).clip(CircleShape).background(bg))
|
||||
{
|
||||
Text(mark, style = ShxType.meta.copy(fontWeight = FontWeight.ExtraBold), color = fg)
|
||||
}
|
||||
Text(step.title, style = ShxType.body.copy(fontSize = 15.sp), color = c.text, modifier = Modifier.weight(1f))
|
||||
Text(step.value, style = ShxType.sub, color = c.textSub)
|
||||
}
|
||||
if (i < steps.lastIndex)
|
||||
{
|
||||
Box(Modifier.fillMaxWidth().height(1.dp).background(c.divider))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ShxSectionHeader(stringResource(R.string.diag_not_opening))
|
||||
ShxPanel(padding = androidx.compose.foundation.layout.PaddingValues(14.dp))
|
||||
{
|
||||
Column(verticalArrangement = Arrangement.spacedBy(14.dp))
|
||||
{
|
||||
if (topApps.isNotEmpty())
|
||||
{
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(6.dp))
|
||||
{
|
||||
topApps.forEach { pkg ->
|
||||
val label = remember(pkg) {
|
||||
runCatching { context.packageManager.let { pm -> pm.getApplicationInfo(pkg, 0).loadLabel(pm).toString() } }.getOrDefault(pkg)
|
||||
}
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(5.dp),
|
||||
modifier = Modifier.weight(1f).clip(RoundedCornerShape(15.dp)).clickable(role = Role.Button) { onCheckApp(pkg) },
|
||||
)
|
||||
{
|
||||
Box(Modifier.size(48.dp).clip(RoundedCornerShape(15.dp))) { AppIcon(pkg, 48.dp) }
|
||||
Text(label, style = ShxType.tag.copy(fontWeight = FontWeight.SemiBold), color = c.text, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically)
|
||||
{
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier.weight(1f).height(48.dp).clip(RoundedCornerShape(16.dp)).background(c.tile).padding(horizontal = 12.dp),
|
||||
)
|
||||
{
|
||||
Icon(ShxIcons.Search, contentDescription = null, tint = c.textSub, modifier = Modifier.size(18.dp))
|
||||
Box(Modifier.weight(1f))
|
||||
{
|
||||
if (site.isEmpty())
|
||||
{
|
||||
Text(stringResource(R.string.diag_site_hint), style = ShxType.body.copy(fontSize = 15.sp), color = c.textSub)
|
||||
}
|
||||
BasicTextField(
|
||||
value = site,
|
||||
onValueChange = { site = it },
|
||||
singleLine = true,
|
||||
textStyle = ShxType.body.copy(fontSize = 15.sp, color = c.text),
|
||||
cursorBrush = SolidColor(c.accent),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri, imeAction = ImeAction.Search),
|
||||
keyboardActions = KeyboardActions(onSearch = { if (site.isNotBlank()) onCheckSite(site.trim()) }),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
}
|
||||
ShxButton(stringResource(R.string.route_check_button), { onCheckSite(site.trim()) }, height = 48.dp, enabled = site.isNotBlank())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (dead.isNotEmpty())
|
||||
{
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp))
|
||||
{
|
||||
ShxSectionHeader(stringResource(R.string.dead_targets_title))
|
||||
Text(
|
||||
dead.size.toString(),
|
||||
style = ShxType.tag,
|
||||
color = c.onWarning,
|
||||
modifier = Modifier.padding(top = 6.dp).clip(RoundedCornerShape(6.dp)).background(c.warningStrong).padding(horizontal = 7.dp, vertical = 1.dp),
|
||||
)
|
||||
}
|
||||
ShxPanel()
|
||||
{
|
||||
dead.forEachIndexed { i, target ->
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
modifier = Modifier.fillMaxWidth().height(60.dp).padding(start = 16.dp, end = 12.dp),
|
||||
)
|
||||
{
|
||||
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp))
|
||||
{
|
||||
Text(target.dst, style = ShxType.mono.copy(fontSize = 14.sp, fontWeight = FontWeight.Bold), color = c.text, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
Text(
|
||||
stringResource(R.string.diag_dead_meta, target.fails, formatDuration(target.leftMs / 1000)),
|
||||
style = ShxType.meta.copy(fontWeight = FontWeight.Normal),
|
||||
color = c.textSub,
|
||||
)
|
||||
}
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier.height(36.dp).clip(RoundedCornerShape(12.dp)).background(c.warningStrong)
|
||||
.clickable(role = Role.Button) {
|
||||
val ip = target.dst.substringBeforeLast(':').removeSurrounding("[", "]")
|
||||
manager.addForcePrefix(ip + if (ip.contains(':')) "/128" else "/32")
|
||||
}
|
||||
.padding(horizontal = 12.dp),
|
||||
)
|
||||
{
|
||||
Text(stringResource(R.string.dead_targets_force), style = ShxType.chip, color = c.onWarningStrong)
|
||||
}
|
||||
}
|
||||
if (i < dead.lastIndex)
|
||||
{
|
||||
Box(Modifier.fillMaxWidth().height(1.dp).background(c.divider))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
ShxButton(stringResource(R.string.diag_send_report), onLog, style = ShxButtonStyle.OUTLINE, height = 52.dp, modifier = Modifier.fillMaxWidth())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Green / amber / grey verdict card with a big round mark. */
|
||||
@Composable
|
||||
private fun Verdict(report: DiagReport?, onRepeat: () -> Unit)
|
||||
{
|
||||
val c = LocalShxColors.current
|
||||
val context = LocalContext.current
|
||||
val ok = report?.allOk
|
||||
val (bg, mark, markFg, titleColor, subColor) = when (ok)
|
||||
{
|
||||
true -> Five(c.successContainer, c.success, androidx.compose.ui.graphics.Color.White, c.onSuccessStrong, c.onSuccess)
|
||||
false -> Five(c.warningStrong, c.warning, androidx.compose.ui.graphics.Color.White, c.onWarningStrong, c.onWarningContainer)
|
||||
null -> Five(c.tile, c.iconMuted, androidx.compose.ui.graphics.Color.White, c.text, c.textSub)
|
||||
}
|
||||
val title = when (ok)
|
||||
{
|
||||
true -> stringResource(if (report?.vpnUp == true) R.string.diag_verdict_ok else R.string.diag_verdict_ok_no_vpn)
|
||||
false -> stringResource(R.string.diag_verdict_problem, report?.steps?.firstOrNull { it.ok == false }?.title ?: "")
|
||||
null -> stringResource(R.string.diag_checking_title)
|
||||
}
|
||||
val sub = when (ok)
|
||||
{
|
||||
null -> stringResource(R.string.diag_checking_sub)
|
||||
else -> stringResource(R.string.diag_checked_at, formatTimeAgo(context, report!!.at))
|
||||
}
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(24.dp)).background(bg)
|
||||
.clickable(enabled = ok != null, role = Role.Button, onClick = onRepeat).padding(16.dp),
|
||||
)
|
||||
{
|
||||
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(40.dp).clip(CircleShape).background(mark))
|
||||
{
|
||||
Icon(if (ok == false) ShxIcons.Alert else if (ok == null) ShxIcons.Refresh else ShxIcons.Check, contentDescription = null, tint = markFg, modifier = Modifier.size(22.dp))
|
||||
}
|
||||
Column(verticalArrangement = Arrangement.spacedBy(2.dp))
|
||||
{
|
||||
Text(title, style = ShxType.cardHeading, color = titleColor)
|
||||
Text(sub, style = ShxType.sub, color = subColor)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private data class Five<A, B, C, D, E>(val a: A, val b: B, val c: C, val d: D, val e: E)
|
||||
|
||||
private fun formatTimeAgo(context: Context, at: Long): String
|
||||
{
|
||||
val sec = ((System.currentTimeMillis() - at) / 1000).coerceAtLeast(0)
|
||||
return if (sec < 60) context.getString(R.string.diag_ago_sec, sec) else context.getString(R.string.diag_ago_min, sec / 60)
|
||||
}
|
||||
+10
-6
@@ -12,13 +12,16 @@ package org.strongswan.android.ui.compose
|
||||
import java.util.Locale
|
||||
import kotlin.math.abs
|
||||
|
||||
/* «1,2 ГБ» in Russian, «1.2 GB» otherwise */
|
||||
private fun russian() = Locale.getDefault().language == "ru"
|
||||
|
||||
fun formatBytes(bytes: Long): String
|
||||
{
|
||||
val units = if (russian()) arrayOf("Б", "КБ", "МБ", "ГБ", "ТБ") else arrayOf("B", "KB", "MB", "GB", "TB")
|
||||
if (bytes <= 0L)
|
||||
{
|
||||
return "0 B"
|
||||
return "0 " + units[0]
|
||||
}
|
||||
val units = arrayOf("B", "KB", "MB", "GB", "TB")
|
||||
var value = bytes.toDouble()
|
||||
var unit = 0
|
||||
while (value >= 1024 && unit < units.lastIndex)
|
||||
@@ -26,17 +29,18 @@ fun formatBytes(bytes: Long): String
|
||||
value /= 1024
|
||||
unit++
|
||||
}
|
||||
return if (unit == 0) String.format(Locale.US, "%d %s", bytes, units[unit])
|
||||
else String.format(Locale.US, "%.1f %s", value, units[unit])
|
||||
return if (unit == 0) String.format(Locale.getDefault(), "%d %s", bytes, units[unit])
|
||||
else String.format(Locale.getDefault(), "%.1f %s", value, units[unit])
|
||||
}
|
||||
|
||||
fun formatRate(bytesPerSecond: Long): String
|
||||
{
|
||||
val perSecond = if (russian()) "/с" else "/s"
|
||||
if (abs(bytesPerSecond) < 32)
|
||||
{
|
||||
return "0 B/s"
|
||||
return formatBytes(0) + perSecond
|
||||
}
|
||||
return formatBytes(bytesPerSecond) + "/s"
|
||||
return formatBytes(bytesPerSecond) + perSecond
|
||||
}
|
||||
|
||||
fun formatDuration(seconds: Long): String
|
||||
|
||||
+364
-569
File diff suppressed because it is too large.
Load diff
+605
-10
@@ -16,14 +16,20 @@ package org.strongswan.android.ui.compose
|
||||
|
||||
import android.app.Application
|
||||
import android.app.Service
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.content.ServiceConnection
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.Network
|
||||
import android.net.NetworkCapabilities
|
||||
import android.net.NetworkRequest
|
||||
import android.os.IBinder
|
||||
import android.os.SystemClock
|
||||
import android.widget.Toast
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.ProcessLifecycleOwner
|
||||
@@ -31,19 +37,41 @@ import androidx.lifecycle.repeatOnLifecycle
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.localbroadcastmanager.content.LocalBroadcastManager
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.currentCoroutineContext
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.strongswan.android.R
|
||||
import org.strongswan.android.data.ProfileExportProvider
|
||||
import org.strongswan.android.data.VpnProfile
|
||||
import org.strongswan.android.data.VpnProfileSource
|
||||
import org.strongswan.android.logic.CharonVpnService
|
||||
import org.strongswan.android.logic.PauseManager
|
||||
import org.strongswan.android.logic.VpnProfileExporter
|
||||
import org.strongswan.android.logic.VpnStateService
|
||||
import org.strongswan.android.logic.VpnStateService.ErrorState
|
||||
import org.strongswan.android.logic.VpnStateService.State
|
||||
import org.strongswan.android.logic.routing.RoutingManager
|
||||
import org.strongswan.android.logic.connectivity.ConnectivityProbe
|
||||
import org.strongswan.android.logic.connectivity.HealthCheckManager
|
||||
import org.strongswan.android.logic.connectivity.ProbeResult
|
||||
import org.strongswan.android.logic.connectivity.ProbeTarget
|
||||
import org.strongswan.android.logic.connectivity.ServerCheckMode
|
||||
import org.strongswan.android.logic.connectivity.WhiteListManager
|
||||
import org.strongswan.android.logic.connectivity.WhiteListState
|
||||
import org.strongswan.android.logic.autoconnect.SsidReader
|
||||
import org.strongswan.android.ui.VpnProfileControlActivity
|
||||
import org.strongswan.android.utils.Constants
|
||||
|
||||
@@ -53,12 +81,45 @@ data class TrafficUi(
|
||||
val rateIn: Long = 0,
|
||||
val rateOut: Long = 0,
|
||||
val durationSec: Long = 0,
|
||||
/** Session split between the tunnel and direct RU traffic; null when the userspace engine is not active. */
|
||||
val split: SplitTrafficUi? = null,
|
||||
)
|
||||
|
||||
data class SplitTrafficUi(
|
||||
val tunnelBytes: Long = 0,
|
||||
val directBytes: Long = 0,
|
||||
val tunnelRate: Long = 0,
|
||||
val directRate: Long = 0,
|
||||
)
|
||||
{
|
||||
val directShare: Int
|
||||
get() = if (tunnelBytes + directBytes <= 0) 0 else ((directBytes * 100) / (tunnelBytes + directBytes)).toInt()
|
||||
}
|
||||
|
||||
/** Public exit (ip/country as seen past the tunnel) and the underlying network. */
|
||||
data class ExitUi(
|
||||
val ip: String? = null,
|
||||
val country: String? = null,
|
||||
val network: String? = null,
|
||||
val ssid: String? = null,
|
||||
)
|
||||
|
||||
/** A direct destination currently refused by the engine's failure cooldown. */
|
||||
data class DeadTargetUi(
|
||||
val dst: String,
|
||||
val fails: Long,
|
||||
val windowMs: Long,
|
||||
val leftMs: Long,
|
||||
val owner: String? = null,
|
||||
/** the engine already reroutes this host through the tunnel */
|
||||
val viaTunnel: Boolean = false,
|
||||
)
|
||||
|
||||
enum class ShxTab
|
||||
{
|
||||
HOME,
|
||||
PROFILES,
|
||||
ROUTING,
|
||||
SETTINGS,
|
||||
}
|
||||
|
||||
@@ -66,6 +127,8 @@ data class HomeUiState(
|
||||
val vpnState: State = State.DISABLED,
|
||||
val error: ErrorState = ErrorState.NO_ERROR,
|
||||
val errorText: Int = 0,
|
||||
/** seconds until the next automatic retry while an error is shown, 0 if none */
|
||||
val retryIn: Int = 0,
|
||||
val activeProfile: VpnProfile? = null,
|
||||
val profiles: List<VpnProfile> = emptyList(),
|
||||
val selected: VpnProfile? = null,
|
||||
@@ -75,6 +138,11 @@ data class HomeUiState(
|
||||
|
||||
class HomeViewModel(app: Application) : AndroidViewModel(app), VpnStateService.VpnStateListener
|
||||
{
|
||||
/* one-shot user messages (string resource ids), shown as snackbars by ShxApp */
|
||||
private val _messages = MutableSharedFlow<Int>(extraBufferCapacity = 4)
|
||||
val messages: SharedFlow<Int> = _messages
|
||||
|
||||
private val homeVisible = MutableStateFlow(false)
|
||||
private val _ui = MutableStateFlow(HomeUiState())
|
||||
val ui: StateFlow<HomeUiState> = _ui
|
||||
|
||||
@@ -82,6 +150,95 @@ class HomeViewModel(app: Application) : AndroidViewModel(app), VpnStateService.V
|
||||
private val _traffic = MutableStateFlow(TrafficUi())
|
||||
val traffic: StateFlow<TrafficUi> = _traffic
|
||||
|
||||
/* probe results for the availability card; separate flow for the same reason */
|
||||
private val _probes = MutableStateFlow<Map<ProbeTarget, ProbeResult>>(emptyMap())
|
||||
val probes: StateFlow<Map<ProbeTarget, ProbeResult>> = _probes
|
||||
|
||||
/* cached ISP white-list status; updated event-driven by WhiteListManager */
|
||||
val whiteList: StateFlow<WhiteListState> = WhiteListManager.getInstance(app).state
|
||||
|
||||
/* true while a probe round is running, for spinner states in the UI */
|
||||
private val _probing = MutableStateFlow(false)
|
||||
val probing: StateFlow<Boolean> = _probing
|
||||
|
||||
/* public exit (ip/country) and the underlying network; refreshed with the
|
||||
* probe round, cached for EXIT_REFRESH_MS */
|
||||
private val _exit = MutableStateFlow<ExitUi?>(null)
|
||||
val exit: StateFlow<ExitUi?> = _exit
|
||||
|
||||
/* direct destinations currently refused by the engine's cooldown */
|
||||
private val _deadTargets = MutableStateFlow<List<DeadTargetUi>>(emptyList())
|
||||
val deadTargets: StateFlow<List<DeadTargetUi>> = _deadTargets
|
||||
|
||||
/* wall-clock end of a running "pause VPN", 0 if none */
|
||||
val pauseUntil: StateFlow<Long> = PauseManager.until(app)
|
||||
|
||||
/* whether any non-VPN network is up; tells "no internet" apart from a server problem */
|
||||
private val _networkAvailable = MutableStateFlow(true)
|
||||
val networkAvailable: StateFlow<Boolean> = _networkAvailable
|
||||
/* (Wi-Fi up, mobile data up) for the "no internet" checklist */
|
||||
private val _networkKinds = MutableStateFlow(false to false)
|
||||
val networkKinds: StateFlow<Pair<Boolean, Boolean>> = _networkKinds
|
||||
|
||||
/* the current Wi-Fi network (null off Wi-Fi or without location access) */
|
||||
private val _ssid = MutableStateFlow<String?>(null)
|
||||
val ssid: StateFlow<String?> = _ssid
|
||||
private val underlyingNetworks = HashMap<Network, NetworkCapabilities?>()
|
||||
private val networkCallback = object : ConnectivityManager.NetworkCallback()
|
||||
{
|
||||
override fun onAvailable(network: Network)
|
||||
{
|
||||
synchronized(underlyingNetworks)
|
||||
{
|
||||
underlyingNetworks[network] = null
|
||||
publishNetworks()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCapabilitiesChanged(network: Network, capabilities: NetworkCapabilities)
|
||||
{
|
||||
synchronized(underlyingNetworks)
|
||||
{
|
||||
val previous = underlyingNetworks.put(network, capabilities)
|
||||
/* fired on every signal-strength tick: the published state (network
|
||||
* kinds, SSID) only depends on the transports, and reading the SSID
|
||||
* is a system call, so unchanged transports end here */
|
||||
if (previous != null && sameTransports(previous, capabilities))
|
||||
{
|
||||
return
|
||||
}
|
||||
publishNetworks()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onLost(network: Network)
|
||||
{
|
||||
synchronized(underlyingNetworks)
|
||||
{
|
||||
underlyingNetworks.remove(network)
|
||||
publishNetworks()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun sameTransports(a: NetworkCapabilities, b: NetworkCapabilities): Boolean =
|
||||
a.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) == b.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) &&
|
||||
a.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) == b.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR)
|
||||
|
||||
private fun publishNetworks()
|
||||
{
|
||||
_networkAvailable.value = underlyingNetworks.isNotEmpty()
|
||||
val caps = underlyingNetworks.values.filterNotNull()
|
||||
_networkKinds.value = caps.any { it.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) } to
|
||||
caps.any { it.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) }
|
||||
val wifi = caps.firstOrNull { it.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) }
|
||||
_ssid.value = wifi?.let { org.strongswan.android.logic.autoconnect.SsidReader.from(getApplication(), it) }
|
||||
}
|
||||
|
||||
/* true while «Fastest» pings the servers */
|
||||
private val _findingFastest = MutableStateFlow(false)
|
||||
val findingFastest: StateFlow<Boolean> = _findingFastest
|
||||
|
||||
private var service: VpnStateService? = null
|
||||
|
||||
/* written from the main thread (pullState) and the poll coroutine (Default) */
|
||||
@@ -89,6 +246,11 @@ class HomeViewModel(app: Application) : AndroidViewModel(app), VpnStateService.V
|
||||
@Volatile private var lastOut = 0L
|
||||
@Volatile private var lastAt = 0L
|
||||
@Volatile private var connectedAt = 0L
|
||||
@Volatile private var lastExitAt = 0L
|
||||
@Volatile private var splitAvailable = false
|
||||
@Volatile private var lastTunnelBytes = 0L
|
||||
@Volatile private var lastDirectBytes = 0L
|
||||
@Volatile private var lastSplitAt = 0L
|
||||
|
||||
private val connection = object : ServiceConnection
|
||||
{
|
||||
@@ -122,16 +284,129 @@ class HomeViewModel(app: Application) : AndroidViewModel(app), VpnStateService.V
|
||||
IntentFilter(Constants.VPN_PROFILES_CHANGED)
|
||||
)
|
||||
reloadProfiles()
|
||||
/* poll traffic only while the app is visible, and off the main thread */
|
||||
WhiteListManager.getInstance(ctx).refreshIfStale()
|
||||
/* the default request excludes VPN networks: this tracks the real uplinks */
|
||||
val cm = ctx.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
|
||||
try
|
||||
{
|
||||
cm?.registerNetworkCallback(
|
||||
NetworkRequest.Builder().addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET).build(),
|
||||
networkCallback,
|
||||
)
|
||||
}
|
||||
catch (_: RuntimeException)
|
||||
{ /* too many callbacks registered app-wide; keep assuming a network */
|
||||
}
|
||||
/* poll traffic only while the Home screen is shown (not on the other
|
||||
* tabs or behind another activity), the VPN is up, and off the main thread */
|
||||
viewModelScope.launch {
|
||||
ProcessLifecycleOwner.get().lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
|
||||
while (isActive)
|
||||
{
|
||||
whileHomeConnected {
|
||||
tickTraffic()
|
||||
delay(TRAFFIC_POLL_MS)
|
||||
}
|
||||
}
|
||||
}
|
||||
/* probe availability under the same conditions */
|
||||
viewModelScope.launch {
|
||||
ProcessLifecycleOwner.get().lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
|
||||
var lastProbeAt = 0L
|
||||
whileHomeConnected {
|
||||
val wait = PROBE_INTERVAL_MS - (SystemClock.elapsedRealtime() - lastProbeAt)
|
||||
if (wait > 0)
|
||||
{
|
||||
delay(wait)
|
||||
}
|
||||
lastProbeAt = SystemClock.elapsedRealtime()
|
||||
probeNow()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs [block] repeatedly while the Home screen is visible and the VPN is
|
||||
* connected; suspends without any polling otherwise. [block] is
|
||||
* cancelled as soon as either condition goes away.
|
||||
*/
|
||||
private suspend fun whileHomeConnected(block: suspend () -> Unit)
|
||||
{
|
||||
combine(homeVisible, _ui.map { it.vpnState == State.CONNECTED }) { visible, connected ->
|
||||
visible && connected
|
||||
}.distinctUntilChanged().collectLatest { active ->
|
||||
while (active && currentCoroutineContext().isActive)
|
||||
{
|
||||
block()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Called by the Home screen while it is (not) shown. */
|
||||
fun setHomeVisible(visible: Boolean)
|
||||
{
|
||||
homeVisible.value = visible
|
||||
}
|
||||
|
||||
/** Immediately reruns the availability probe round (refresh button). */
|
||||
fun refreshProbes()
|
||||
{
|
||||
WhiteListManager.getInstance(getApplication()).checkNow()
|
||||
viewModelScope.launch {
|
||||
probeNow(forceExit = true)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun probeNow(forceExit: Boolean = false)
|
||||
{
|
||||
/* direct (outside the tunnel) probes: at most once per TTL, event-driven */
|
||||
WhiteListManager.getInstance(getApplication()).refreshIfStale()
|
||||
if (_ui.value.vpnState != State.CONNECTED)
|
||||
{
|
||||
return
|
||||
}
|
||||
val profile = _ui.value.activeProfile ?: _ui.value.selected ?: return
|
||||
val gateway = profile.gateway
|
||||
val healthConfig = HealthCheckManager.getInstance(getApplication()).currentConfig
|
||||
val timeout = healthConfig.timeoutMs.coerceAtLeast(1000)
|
||||
val targets = buildList {
|
||||
if (healthConfig.serverCheckMode != ServerCheckMode.OFF)
|
||||
{
|
||||
add(ProbeTarget.SERVER)
|
||||
}
|
||||
addAll(healthConfig.targets)
|
||||
}
|
||||
refreshDeadTargets()
|
||||
viewModelScope.launch { refreshExit(force = forceExit) }
|
||||
if (targets.isEmpty())
|
||||
{
|
||||
return
|
||||
}
|
||||
_probing.value = true
|
||||
try
|
||||
{
|
||||
coroutineScope {
|
||||
targets.forEach { target ->
|
||||
launch {
|
||||
val result = try
|
||||
{
|
||||
ConnectivityProbe.probe(target, timeout, getApplication(), gateway)
|
||||
}
|
||||
catch (_: Throwable)
|
||||
{
|
||||
ProbeResult(target = target, ok = false, rttMs = null)
|
||||
}
|
||||
if (_ui.value.vpnState == State.CONNECTED)
|
||||
{
|
||||
_probes.update { it + (target to result) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_probing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCleared()
|
||||
@@ -146,6 +421,13 @@ class HomeViewModel(app: Application) : AndroidViewModel(app), VpnStateService.V
|
||||
{
|
||||
}
|
||||
LocalBroadcastManager.getInstance(ctx).unregisterReceiver(profilesChanged)
|
||||
try
|
||||
{
|
||||
(ctx.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager)?.unregisterNetworkCallback(networkCallback)
|
||||
}
|
||||
catch (_: IllegalArgumentException)
|
||||
{
|
||||
}
|
||||
super.onCleared()
|
||||
}
|
||||
|
||||
@@ -171,6 +453,7 @@ class HomeViewModel(app: Application) : AndroidViewModel(app), VpnStateService.V
|
||||
|
||||
fun connect(context: Context, profile: VpnProfile)
|
||||
{
|
||||
org.strongswan.android.ui.compose.home.ScenarioHints.manualOn(context)
|
||||
val intent = Intent(context, VpnProfileControlActivity::class.java)
|
||||
intent.action = VpnProfileControlActivity.START_PROFILE
|
||||
intent.putExtra(VpnProfileControlActivity.EXTRA_VPN_PROFILE_UUID, profile.getUUID().toString())
|
||||
@@ -179,6 +462,9 @@ class HomeViewModel(app: Application) : AndroidViewModel(app), VpnStateService.V
|
||||
|
||||
fun disconnect()
|
||||
{
|
||||
org.strongswan.android.logic.scenarios.ScenarioEngine.onManual(getApplication())
|
||||
/* counted per Wi-Fi for the «turn it off here automatically?» suggestion */
|
||||
org.strongswan.android.ui.compose.home.ScenarioHints.manualOff(getApplication(), if (_networkKinds.value.first) _ssid.value else null)
|
||||
service?.disconnect()
|
||||
}
|
||||
|
||||
@@ -187,17 +473,153 @@ class HomeViewModel(app: Application) : AndroidViewModel(app), VpnStateService.V
|
||||
service?.reconnect()
|
||||
}
|
||||
|
||||
/** Disconnects for [minutes]; the tunnel comes back by itself when the pause ends. */
|
||||
fun pause(minutes: Int)
|
||||
{
|
||||
org.strongswan.android.logic.scenarios.ScenarioEngine.onManual(getApplication())
|
||||
val svc = service ?: return
|
||||
val profile = _ui.value.activeProfile ?: _ui.value.selected ?: return
|
||||
PauseManager.pause(getApplication(), svc, profile.getUUID().toString(), minutes)
|
||||
}
|
||||
|
||||
fun extendPause(minutes: Int)
|
||||
{
|
||||
PauseManager.extend(getApplication(), minutes)
|
||||
}
|
||||
|
||||
/** Pause until 07:00 (today or tomorrow). */
|
||||
fun pauseUntilMorning()
|
||||
{
|
||||
org.strongswan.android.logic.scenarios.ScenarioEngine.onManual(getApplication())
|
||||
val svc = service ?: return
|
||||
val profile = _ui.value.activeProfile ?: _ui.value.selected ?: return
|
||||
PauseManager.pauseUntil(getApplication(), svc, profile.getUUID().toString(), PauseManager.nextMorning(System.currentTimeMillis()))
|
||||
}
|
||||
|
||||
/** Pause until the device moves to another network. */
|
||||
fun pauseUntilNetworkChange()
|
||||
{
|
||||
org.strongswan.android.logic.scenarios.ScenarioEngine.onManual(getApplication())
|
||||
val svc = service ?: return
|
||||
val profile = _ui.value.activeProfile ?: _ui.value.selected ?: return
|
||||
PauseManager.pauseUntilNetworkChange(getApplication(), svc, profile.getUUID().toString())
|
||||
}
|
||||
|
||||
/**
|
||||
* «Fastest»: pings every profile's server directly (outside the tunnel)
|
||||
* and connects the one that answers first; a message if none answers.
|
||||
*/
|
||||
fun connectFastest(context: Context)
|
||||
{
|
||||
if (_findingFastest.value)
|
||||
{
|
||||
return
|
||||
}
|
||||
val profiles = _ui.value.profiles
|
||||
if (profiles.isEmpty())
|
||||
{
|
||||
return
|
||||
}
|
||||
_findingFastest.value = true
|
||||
viewModelScope.launch {
|
||||
try
|
||||
{
|
||||
val rtts = coroutineScope {
|
||||
profiles.map { profile ->
|
||||
async {
|
||||
profile to ConnectivityProbe.pingServerDirect(getApplication(), profile.gateway, FASTEST_TIMEOUT_MS)?.first
|
||||
}
|
||||
}.map { it.await() }
|
||||
}
|
||||
val best = rtts.filter { it.second != null }.minByOrNull { it.second!! }?.first
|
||||
if (best == null)
|
||||
{
|
||||
_messages.tryEmit(R.string.home_fastest_none)
|
||||
return@launch
|
||||
}
|
||||
val current = _ui.value.activeProfile
|
||||
if (_ui.value.vpnState == State.CONNECTED && current?.getUUID() == best.getUUID())
|
||||
{
|
||||
_messages.tryEmit(R.string.home_fastest_already)
|
||||
return@launch
|
||||
}
|
||||
select(best)
|
||||
connect(context, best)
|
||||
}
|
||||
finally
|
||||
{
|
||||
_findingFastest.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun resumeNow()
|
||||
{
|
||||
PauseManager.resumeNow(getApplication())
|
||||
}
|
||||
|
||||
fun delete(profile: VpnProfile)
|
||||
{
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
val source = VpnProfileSource(getApplication())
|
||||
source.open()
|
||||
source.deleteVpnProfile(profile)
|
||||
source.close()
|
||||
try
|
||||
{
|
||||
source.deleteVpnProfile(profile)
|
||||
}
|
||||
finally
|
||||
{
|
||||
source.close()
|
||||
}
|
||||
val intent = Intent(Constants.VPN_PROFILES_CHANGED)
|
||||
intent.putExtra(Constants.VPN_PROFILES_MULTIPLE, arrayOf(profile.getUUID().toString()))
|
||||
/* the broadcast reaches profilesChanged, which reloads the list */
|
||||
LocalBroadcastManager.getInstance(getApplication()).sendBroadcast(intent)
|
||||
reloadProfiles()
|
||||
}
|
||||
}
|
||||
|
||||
/** Re-inserts a just-deleted profile (the snackbar "Undo" action). */
|
||||
fun restore(profile: VpnProfile)
|
||||
{
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
val source = VpnProfileSource(getApplication())
|
||||
source.open()
|
||||
try
|
||||
{
|
||||
source.insertProfile(profile)
|
||||
}
|
||||
finally
|
||||
{
|
||||
source.close()
|
||||
}
|
||||
val intent = Intent(Constants.VPN_PROFILES_CHANGED)
|
||||
LocalBroadcastManager.getInstance(getApplication()).sendBroadcast(intent)
|
||||
}
|
||||
}
|
||||
|
||||
/** Exports the profile as a .sswan file and opens the system share sheet. */
|
||||
fun shareProfile(context: Context, profile: VpnProfile, includePassword: Boolean)
|
||||
{
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
val uri = VpnProfileExporter.exportProfile(getApplication(), profile, includePassword)
|
||||
withContext(Dispatchers.Main) {
|
||||
if (uri == null)
|
||||
{
|
||||
_messages.tryEmit(R.string.share_profile_failed)
|
||||
return@withContext
|
||||
}
|
||||
val intent = Intent(Intent.ACTION_SEND)
|
||||
intent.type = ProfileExportProvider.MIME_TYPE
|
||||
intent.putExtra(Intent.EXTRA_STREAM, uri)
|
||||
try
|
||||
{
|
||||
context.startActivity(Intent.createChooser(intent, context.getString(R.string.share_profile)))
|
||||
}
|
||||
catch (_: ActivityNotFoundException)
|
||||
{
|
||||
_messages.tryEmit(R.string.share_profile_failed)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -206,8 +628,14 @@ class HomeViewModel(app: Application) : AndroidViewModel(app), VpnStateService.V
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
val source = VpnProfileSource(getApplication())
|
||||
source.open()
|
||||
val all = source.allVpnProfiles.sortedBy { it.name?.lowercase() ?: "" }
|
||||
source.close()
|
||||
val all = try
|
||||
{
|
||||
source.allVpnProfiles.sortedBy { it.name?.lowercase() ?: "" }
|
||||
}
|
||||
finally
|
||||
{
|
||||
source.close()
|
||||
}
|
||||
_ui.update { state ->
|
||||
val selected = all.firstOrNull { it.getUUID() == state.selected?.getUUID() }
|
||||
?: all.firstOrNull { it.getUUID() == state.activeProfile?.getUUID() }
|
||||
@@ -227,20 +655,32 @@ class HomeViewModel(app: Application) : AndroidViewModel(app), VpnStateService.V
|
||||
lastIn = 0
|
||||
lastOut = 0
|
||||
lastAt = 0
|
||||
splitAvailable = CharonVpnService.isUserspaceRoutingEnabled(getApplication())
|
||||
lastTunnelBytes = 0
|
||||
lastDirectBytes = 0
|
||||
lastSplitAt = 0
|
||||
}
|
||||
if (state != State.CONNECTED)
|
||||
{
|
||||
connectedAt = 0
|
||||
_traffic.value = TrafficUi()
|
||||
_probes.value = emptyMap()
|
||||
_exit.value = null
|
||||
_deadTargets.value = emptyList()
|
||||
lastExitAt = 0
|
||||
}
|
||||
/* the retry countdown notifies every second; skip the update when nothing
|
||||
* actually changed so the Home screen doesn't recompose for no reason */
|
||||
_ui.update {
|
||||
it.copy(
|
||||
val next = it.copy(
|
||||
vpnState = state,
|
||||
error = svc.errorState,
|
||||
errorText = svc.errorText,
|
||||
retryIn = if (svc.errorState != ErrorState.NO_ERROR) svc.retryIn else 0,
|
||||
activeProfile = svc.profile,
|
||||
selected = svc.profile ?: it.selected,
|
||||
)
|
||||
if (next == it) it else next
|
||||
}
|
||||
}
|
||||
|
||||
@@ -278,11 +718,166 @@ class HomeViewModel(app: Application) : AndroidViewModel(app), VpnStateService.V
|
||||
rateIn = rateIn,
|
||||
rateOut = rateOut,
|
||||
durationSec = (now - startedAt) / 1000,
|
||||
split = if (splitAvailable) splitTraffic(now) else null,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Session split between the tunnel and direct RU traffic from the native
|
||||
* engine counters (s[1] tunnel bytes, s[3] direct bytes). Only available
|
||||
* in the userspace routing mode - with system routes the engine never
|
||||
* sees the direct traffic.
|
||||
*/
|
||||
private suspend fun splitTraffic(now: Long): SplitTrafficUi
|
||||
{
|
||||
val stats = try
|
||||
{
|
||||
withContext(Dispatchers.Default) { CharonVpnService.getRoutingStats() }
|
||||
}
|
||||
catch (_: Throwable)
|
||||
{
|
||||
null
|
||||
}
|
||||
if (stats == null || stats.size < 4)
|
||||
{
|
||||
return SplitTrafficUi(tunnelBytes = lastTunnelBytes, directBytes = lastDirectBytes)
|
||||
}
|
||||
val tunnel = stats[1].coerceAtLeast(0)
|
||||
val direct = stats[3].coerceAtLeast(0)
|
||||
val dt = if (lastSplitAt == 0L) 0.0 else ((now - lastSplitAt) / 1000.0).coerceAtLeast(0.2)
|
||||
val tunnelRate = if (lastSplitAt == 0L) 0 else ((tunnel - lastTunnelBytes) / dt).toLong().coerceAtLeast(0)
|
||||
val directRate = if (lastSplitAt == 0L) 0 else ((direct - lastDirectBytes) / dt).toLong().coerceAtLeast(0)
|
||||
lastTunnelBytes = tunnel
|
||||
lastDirectBytes = direct
|
||||
lastSplitAt = now
|
||||
return SplitTrafficUi(tunnelBytes = tunnel, directBytes = direct, tunnelRate = tunnelRate, directRate = directRate)
|
||||
}
|
||||
|
||||
/**
|
||||
* Public exit ip/country via Cloudflare trace through the tunnel, plus
|
||||
* the current underlying network label. Keeps the last known answer on
|
||||
* failure (the sheet simply omits the rows before the first success).
|
||||
*/
|
||||
private suspend fun refreshExit(force: Boolean)
|
||||
{
|
||||
val now = SystemClock.elapsedRealtime()
|
||||
if (!force && now - lastExitAt < EXIT_REFRESH_MS)
|
||||
{
|
||||
return
|
||||
}
|
||||
if (_ui.value.vpnState != State.CONNECTED)
|
||||
{
|
||||
return
|
||||
}
|
||||
val info = ConnectivityProbe.traceExit(EXIT_TIMEOUT_MS) ?: return
|
||||
lastExitAt = now
|
||||
val ctx = getApplication<Application>()
|
||||
val (network, ssid) = underlyingNetworkLabel(ctx)
|
||||
_exit.value = ExitUi(ip = info.ip, country = info.country, network = network, ssid = ssid)
|
||||
}
|
||||
|
||||
private fun underlyingNetworkLabel(ctx: Context): Pair<String?, String?>
|
||||
{
|
||||
val cm = ctx.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
|
||||
?: return null to null
|
||||
/* Android keeps the cellular network agent alive in parallel with
|
||||
* Wi-Fi (allNetworks order is unspecified), so Wi-Fi wins if present -
|
||||
* the same precedence NetworkMonitor uses for the trust decision */
|
||||
var wifiCaps: NetworkCapabilities? = null
|
||||
var cellular = false
|
||||
for (network in cm.allNetworks)
|
||||
{
|
||||
val caps = try
|
||||
{
|
||||
cm.getNetworkCapabilities(network)
|
||||
}
|
||||
catch (_: Exception)
|
||||
{
|
||||
null
|
||||
} ?: continue
|
||||
if (caps.hasTransport(NetworkCapabilities.TRANSPORT_VPN))
|
||||
{
|
||||
continue
|
||||
}
|
||||
if (caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI))
|
||||
{
|
||||
wifiCaps = caps
|
||||
}
|
||||
else if (caps.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR))
|
||||
{
|
||||
cellular = true
|
||||
}
|
||||
}
|
||||
if (wifiCaps != null)
|
||||
{
|
||||
return ctx.getString(R.string.network_wifi) to SsidReader.from(ctx, wifiCaps)
|
||||
}
|
||||
if (cellular)
|
||||
{
|
||||
return ctx.getString(R.string.network_cellular) to null
|
||||
}
|
||||
return null to null
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a /32 (or /128) force-tunnel prefix for a dead "ip:port" target,
|
||||
* so the host permanently leaves the direct set. Applied on the fly,
|
||||
* no reconnect needed.
|
||||
*/
|
||||
fun forceTunnelDeadTarget(dst: String)
|
||||
{
|
||||
val ip = dst.substringBeforeLast(':').removeSurrounding("[", "]")
|
||||
val bits = if (ip.contains(':')) 128 else 32
|
||||
/* on the main dispatcher like every other RoutingManager call:
|
||||
* its config mutations are read-modify-write and not thread-safe
|
||||
* across dispatchers */
|
||||
viewModelScope.launch {
|
||||
RoutingManager.getInstance(getApplication()).addForcePrefix("$ip/$bits")
|
||||
}
|
||||
}
|
||||
|
||||
/** Snapshot of the direct destinations the engine currently refuses. */
|
||||
private suspend fun refreshDeadTargets()
|
||||
{
|
||||
if (_ui.value.vpnState != State.CONNECTED)
|
||||
{
|
||||
return
|
||||
}
|
||||
val raw = try
|
||||
{
|
||||
withContext(Dispatchers.Default) { CharonVpnService.getDeadDirectTargets() }
|
||||
}
|
||||
catch (_: Throwable)
|
||||
{
|
||||
null
|
||||
} ?: return
|
||||
_deadTargets.value = parseDeadTargets(raw)
|
||||
}
|
||||
|
||||
companion object
|
||||
{
|
||||
private const val TRAFFIC_POLL_MS = 500L
|
||||
private const val TRAFFIC_POLL_MS = 1000L
|
||||
private const val PROBE_INTERVAL_MS = 10_000L
|
||||
private const val EXIT_REFRESH_MS = 60_000L
|
||||
private const val EXIT_TIMEOUT_MS = 5000
|
||||
private const val FASTEST_TIMEOUT_MS = 1500
|
||||
|
||||
/** Parses "ip:port|fails|window_ms|left_ms|via_tunnel" entries of the native snapshot. */
|
||||
fun parseDeadTargets(raw: Array<String>?): List<DeadTargetUi> =
|
||||
raw?.mapNotNull { entry ->
|
||||
val parts = entry.split('|')
|
||||
if (parts.size < 4)
|
||||
{
|
||||
return@mapNotNull null
|
||||
}
|
||||
DeadTargetUi(
|
||||
dst = parts[0],
|
||||
fails = parts[1].toLongOrNull() ?: 0,
|
||||
windowMs = parts[2].toLongOrNull() ?: 0,
|
||||
leftMs = parts[3].toLongOrNull() ?: 0,
|
||||
owner = CharonVpnService.getDeadTargetOwner(parts[0]),
|
||||
viaTunnel = parts.getOrNull(4) == "1",
|
||||
)
|
||||
}?.sortedByDescending { it.fails } ?: emptyList()
|
||||
}
|
||||
}
|
||||
+378
@@ -0,0 +1,378 @@
|
||||
/*
|
||||
* Copyright (C) 2026 SHX VPN
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or modify it
|
||||
* under the terms of the GNU General Public License as published by the
|
||||
* Free Software Foundation; either version 2 of the License, or (at your
|
||||
* option) any later version.
|
||||
*/
|
||||
|
||||
@file:OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
|
||||
package org.strongswan.android.ui.compose
|
||||
|
||||
import android.content.Intent
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.LoadingIndicator
|
||||
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.SolidColor
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.text.withStyle
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import org.strongswan.android.R
|
||||
import org.strongswan.android.ui.LogActivity
|
||||
import org.strongswan.android.ui.compose.design.ShxButton
|
||||
import org.strongswan.android.ui.compose.design.ShxButtonStyle
|
||||
import org.strongswan.android.ui.compose.design.ShxIcons
|
||||
import org.strongswan.android.ui.compose.design.ShxNote
|
||||
import org.strongswan.android.ui.compose.design.ShxSheet
|
||||
import org.strongswan.android.ui.compose.design.ShxSwitch
|
||||
import org.strongswan.android.ui.compose.design.ShxTone
|
||||
import org.strongswan.android.ui.compose.theme.LocalShxColors
|
||||
import org.strongswan.android.ui.compose.theme.ShxTheme
|
||||
import org.strongswan.android.ui.compose.theme.ShxType
|
||||
|
||||
/**
|
||||
* Observable UI state of the profile import screen. Owned by the (Java)
|
||||
* activity, read and partially written back by [ImportScreen]; backed by
|
||||
* Compose snapshot state so recomposition just works.
|
||||
*/
|
||||
class ImportUiModel
|
||||
{
|
||||
var loading by mutableStateOf(true)
|
||||
var showForm by mutableStateOf(false)
|
||||
var existsWarning by mutableStateOf<String?>(null)
|
||||
var sharedSecretWarning by mutableStateOf(false)
|
||||
var name by mutableStateOf("")
|
||||
var gateway by mutableStateOf("")
|
||||
var vpnType by mutableStateOf("")
|
||||
var requireUserPass by mutableStateOf(false)
|
||||
var username by mutableStateOf("")
|
||||
var usernameEditable by mutableStateOf(true)
|
||||
var usernameError by mutableStateOf(false)
|
||||
var password by mutableStateOf("")
|
||||
var passwordEditable by mutableStateOf(true)
|
||||
var requireUserCert by mutableStateOf(false)
|
||||
var userCertAlias by mutableStateOf<String?>(null)
|
||||
var userCertSubject by mutableStateOf<String?>(null)
|
||||
var userCertLoading by mutableStateOf(false)
|
||||
var userCertError by mutableStateOf(false)
|
||||
var showImportUserCert by mutableStateOf(false)
|
||||
var showRemoteCert by mutableStateOf(false)
|
||||
var remoteCertPrimary by mutableStateOf<String?>(null)
|
||||
var remoteCertSecondary by mutableStateOf<String?>(null)
|
||||
var hideImport by mutableStateOf(false)
|
||||
/** uri scheme of the profile source (https, content, file) */
|
||||
var source by mutableStateOf<String?>(null)
|
||||
var sourceUri by mutableStateOf<String?>(null)
|
||||
/** set when the profile could not be read; the sheet shows it with a retry */
|
||||
var loadError by mutableStateOf<String?>(null)
|
||||
/** «Connect right away» after adding */
|
||||
var connectNow by mutableStateOf(true)
|
||||
}
|
||||
|
||||
/** Renders the import UI as the mockup sheet (F_ImportPreview / F_ErrImport) in the transparent import activity. */
|
||||
fun ComponentActivity.installImportScreen(
|
||||
model: ImportUiModel,
|
||||
onImport: Runnable,
|
||||
onClose: Runnable,
|
||||
onImportPkcs12: Runnable,
|
||||
onSelectUserCert: Runnable,
|
||||
onRetry: Runnable,
|
||||
)
|
||||
{
|
||||
setContent {
|
||||
ShxTheme {
|
||||
ShxSheet(onDismiss = onClose::run)
|
||||
{
|
||||
ImportScreen(
|
||||
model = model,
|
||||
onImport = onImport::run,
|
||||
onClose = onClose::run,
|
||||
onImportPkcs12 = onImportPkcs12::run,
|
||||
onSelectUserCert = onSelectUserCert::run,
|
||||
onRetry = onRetry::run,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ImportScreen(
|
||||
model: ImportUiModel,
|
||||
onImport: () -> Unit,
|
||||
onClose: () -> Unit,
|
||||
onImportPkcs12: () -> Unit,
|
||||
onSelectUserCert: () -> Unit,
|
||||
onRetry: () -> Unit,
|
||||
)
|
||||
{
|
||||
val c = LocalShxColors.current
|
||||
val context = LocalContext.current
|
||||
when
|
||||
{
|
||||
model.loadError != null || (model.hideImport && model.existsWarning != null) ->
|
||||
{
|
||||
/* F_ErrImport */
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(14.dp), modifier = Modifier.padding(start = 4.dp, end = 4.dp, top = 4.dp))
|
||||
{
|
||||
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(52.dp).clip(CircleShape).background(c.errorContainer))
|
||||
{
|
||||
Icon(ShxIcons.Alert, contentDescription = null, tint = c.error, modifier = Modifier.size(26.dp))
|
||||
}
|
||||
Column(verticalArrangement = Arrangement.spacedBy(2.dp))
|
||||
{
|
||||
Text(
|
||||
stringResource(if (model.source?.startsWith("http") == true) R.string.import_failed_download else R.string.import_failed_read),
|
||||
style = ShxType.pageTitle.copy(letterSpacing = (-0.02).sp), color = c.text,
|
||||
)
|
||||
model.sourceUri?.let {
|
||||
Text(it.substringAfter("://"), style = ShxType.sub.copy(fontFamily = FontFamily.Monospace), color = c.textSub, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
}
|
||||
}
|
||||
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(stringResource(R.string.import_reasons).uppercase(), style = ShxType.section.copy(fontSize = 12.sp), color = c.accentText)
|
||||
val reasons = if (model.source?.startsWith("http") == true) listOf(
|
||||
R.string.import_reason_blocked to R.string.import_reason_blocked_d,
|
||||
R.string.import_reason_offline to R.string.import_reason_offline_d,
|
||||
R.string.import_reason_tls to R.string.import_reason_tls_d,
|
||||
) else listOf(R.string.import_reason_format to R.string.import_reason_format_d)
|
||||
reasons.forEach { (t, d) ->
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(10.dp))
|
||||
{
|
||||
Box(Modifier.padding(top = 8.dp).size(6.dp).clip(CircleShape).background(c.error))
|
||||
Text(
|
||||
buildAnnotatedString {
|
||||
withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { append(stringResource(t)) }
|
||||
append(" — ")
|
||||
withStyle(SpanStyle(color = c.textSub)) { append(stringResource(d)) }
|
||||
},
|
||||
style = ShxType.note.copy(fontSize = 14.sp), color = c.text,
|
||||
)
|
||||
}
|
||||
}
|
||||
(model.loadError ?: model.existsWarning)?.let {
|
||||
Text(it, style = ShxType.meta.copy(fontWeight = FontWeight.Normal), color = c.textSub)
|
||||
}
|
||||
}
|
||||
ShxNote(stringResource(R.string.import_ask_admin))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(10.dp))
|
||||
{
|
||||
if (model.source?.startsWith("http") == true)
|
||||
{
|
||||
ShxButton(stringResource(R.string.retry), onRetry, height = 52.dp, modifier = Modifier.weight(1f))
|
||||
}
|
||||
ShxButton(stringResource(R.string.add_close), onClose, style = ShxButtonStyle.SOFT, height = 52.dp, modifier = Modifier.weight(1f))
|
||||
}
|
||||
Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.Center)
|
||||
{
|
||||
Text(
|
||||
stringResource(R.string.import_support_details),
|
||||
style = ShxType.chip, color = c.accentText,
|
||||
modifier = Modifier.clip(RoundedCornerShape(12.dp))
|
||||
.clickable(role = Role.Button) { context.startActivity(Intent(context, LogActivity::class.java)) }.padding(8.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
model.loading ->
|
||||
{
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(14.dp), modifier = Modifier.fillMaxWidth().padding(vertical = 40.dp))
|
||||
{
|
||||
LoadingIndicator(Modifier.size(48.dp))
|
||||
Text(stringResource(R.string.import_reading), style = ShxType.body, color = c.textSub)
|
||||
}
|
||||
}
|
||||
else ->
|
||||
{
|
||||
/* F_ImportPreview */
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(14.dp), modifier = Modifier.padding(start = 4.dp, end = 4.dp, top = 4.dp))
|
||||
{
|
||||
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(56.dp).clip(CircleShape).background(c.accentContainer))
|
||||
{
|
||||
Text(model.name.filter { it.isLetterOrDigit() }.take(2).uppercase().ifEmpty { "VPN" }, style = ShxType.cardHeading.copy(fontSize = 18.sp), color = c.onAccentContainer)
|
||||
}
|
||||
Column(verticalArrangement = Arrangement.spacedBy(2.dp))
|
||||
{
|
||||
Text(stringResource(R.string.import_add_title), style = ShxType.sheetTitle, color = c.text)
|
||||
Text(stringResource(R.string.import_read_ok), style = ShxType.sub, color = c.textSub)
|
||||
}
|
||||
}
|
||||
val shape = RoundedCornerShape(24.dp)
|
||||
Column(Modifier.fillMaxWidth().clip(shape).background(c.card).border(1.dp, c.cardBorder, shape))
|
||||
{
|
||||
Column(Modifier.padding(horizontal = 16.dp, vertical = 12.dp), verticalArrangement = Arrangement.spacedBy(4.dp))
|
||||
{
|
||||
Text(stringResource(R.string.import_name), style = ShxType.meta.copy(fontWeight = FontWeight.Bold), color = c.textSub)
|
||||
BasicTextField(
|
||||
value = model.name,
|
||||
onValueChange = { model.name = it },
|
||||
singleLine = true,
|
||||
textStyle = ShxType.rowTitle.copy(color = c.text),
|
||||
cursorBrush = SolidColor(c.accent),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
val rows = buildList {
|
||||
add(stringResource(R.string.import_server) to (model.gateway to c.text))
|
||||
add(stringResource(R.string.import_type) to (model.vpnType to c.text))
|
||||
add(stringResource(R.string.import_login) to ((when
|
||||
{
|
||||
model.requireUserCert && model.requireUserPass -> stringResource(R.string.import_login_both)
|
||||
model.requireUserCert -> stringResource(R.string.import_login_cert)
|
||||
else -> stringResource(R.string.import_login_password)
|
||||
}) to c.text))
|
||||
add(stringResource(R.string.import_source) to (stringResource(if (model.source?.startsWith("http") == true) R.string.import_source_link else R.string.import_source_file) to c.text))
|
||||
if (model.showRemoteCert)
|
||||
{
|
||||
add(stringResource(R.string.import_ca) to (stringResource(R.string.import_ca_included) to c.onSuccess))
|
||||
}
|
||||
}
|
||||
rows.forEach { (k, v) ->
|
||||
Box(Modifier.fillMaxWidth().height(1.dp).background(c.divider))
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth().heightIn(min = 52.dp).padding(horizontal = 16.dp))
|
||||
{
|
||||
Text(k, style = ShxType.body, color = c.textSoft, maxLines = 1, softWrap = false)
|
||||
Text(
|
||||
v.first, style = ShxType.body.copy(fontWeight = FontWeight.Bold), color = v.second, maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||
textAlign = androidx.compose.ui.text.style.TextAlign.End, modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (model.requireUserPass)
|
||||
{
|
||||
ImportField(stringResource(R.string.profile_username_label).trimEnd(':'), model.username, model.usernameEditable, model.usernameError) { model.username = it }
|
||||
ImportField(stringResource(R.string.profile_password_label).trimEnd(':'), model.password, model.passwordEditable, false, secret = true) { model.password = it }
|
||||
if (model.sharedSecretWarning)
|
||||
{
|
||||
ShxNote(stringResource(R.string.profile_import_shared_secret), tone = ShxTone.NEUTRAL)
|
||||
}
|
||||
}
|
||||
if (model.requireUserCert)
|
||||
{
|
||||
val certShape = RoundedCornerShape(24.dp)
|
||||
Column(Modifier.fillMaxWidth().clip(certShape).background(c.card).border(1.dp, if (model.userCertError) c.error else c.cardBorder, certShape))
|
||||
{
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
modifier = Modifier.fillMaxWidth().clickable(onClick = onSelectUserCert).padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
)
|
||||
{
|
||||
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp))
|
||||
{
|
||||
Text(model.userCertAlias ?: stringResource(R.string.profile_user_select_certificate_label), style = ShxType.cardTitle.copy(fontWeight = FontWeight.Bold), color = if (model.userCertError) c.error else c.text)
|
||||
Text(model.userCertSubject ?: stringResource(R.string.profile_user_select_certificate), style = ShxType.meta.copy(fontWeight = FontWeight.Normal), color = c.textSub)
|
||||
}
|
||||
if (model.userCertLoading) LoadingIndicator(Modifier.size(20.dp))
|
||||
else Icon(ShxIcons.Chevron, contentDescription = null, tint = c.iconMuted, modifier = Modifier.size(18.dp))
|
||||
}
|
||||
if (model.showImportUserCert)
|
||||
{
|
||||
Box(Modifier.fillMaxWidth().height(1.dp).background(c.divider))
|
||||
Text(
|
||||
stringResource(R.string.profile_cert_import), style = ShxType.chip, color = c.accentText,
|
||||
modifier = Modifier.fillMaxWidth().clickable(onClick = onImportPkcs12).padding(horizontal = 16.dp, vertical = 14.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
val toggleShape = RoundedCornerShape(24.dp)
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
modifier = Modifier.fillMaxWidth().clip(toggleShape).background(c.card).border(1.dp, c.cardBorder, toggleShape).padding(start = 16.dp, end = 14.dp, top = 12.dp, bottom = 12.dp),
|
||||
)
|
||||
{
|
||||
Text(stringResource(R.string.import_connect_now), style = ShxType.cardTitle.copy(fontWeight = FontWeight.Bold), color = c.text, modifier = Modifier.weight(1f))
|
||||
ShxSwitch(checked = model.connectNow, onCheckedChange = { model.connectNow = it })
|
||||
}
|
||||
if (model.existsWarning != null)
|
||||
{
|
||||
Text(
|
||||
buildAnnotatedString {
|
||||
withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { append(stringResource(R.string.import_exists_title)) }
|
||||
append(" ")
|
||||
append(stringResource(R.string.import_exists_text))
|
||||
},
|
||||
style = ShxType.note, color = c.textTonal,
|
||||
modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(18.dp)).background(c.accentSoft).padding(horizontal = 14.dp, vertical = 12.dp),
|
||||
)
|
||||
}
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(10.dp))
|
||||
{
|
||||
ShxButton(stringResource(android.R.string.cancel), onClose, style = ShxButtonStyle.OUTLINE, modifier = Modifier.weight(1f))
|
||||
ShxButton(
|
||||
stringResource(if (model.existsWarning != null) R.string.import_update_server else R.string.import_add_server),
|
||||
onImport, modifier = Modifier.weight(2f), enabled = !model.hideImport,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Field of the manual/import forms: white box, radius 16, label 12/700, value 16/600. */
|
||||
@Composable
|
||||
private fun ImportField(label: String, value: String, editable: Boolean, error: Boolean, secret: Boolean = false, onChange: (String) -> Unit)
|
||||
{
|
||||
val c = LocalShxColors.current
|
||||
val shape = RoundedCornerShape(16.dp)
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
modifier = Modifier.fillMaxWidth().clip(shape).background(c.card).border(if (error) 2.dp else 1.dp, if (error) c.error else c.cardBorder, shape).padding(horizontal = 14.dp, vertical = 10.dp),
|
||||
)
|
||||
{
|
||||
Text(label, style = ShxType.meta.copy(fontWeight = FontWeight.Bold), color = if (error) c.error else c.textSub)
|
||||
BasicTextField(
|
||||
value = value,
|
||||
onValueChange = onChange,
|
||||
enabled = editable,
|
||||
singleLine = true,
|
||||
textStyle = ShxType.body.copy(fontSize = 16.sp, color = if (editable) c.text else c.textSub),
|
||||
cursorBrush = SolidColor(c.accent),
|
||||
visualTransformation = if (secret) androidx.compose.ui.text.input.PasswordVisualTransformation() else androidx.compose.ui.text.input.VisualTransformation.None,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+313
@@ -0,0 +1,313 @@
|
||||
/*
|
||||
* Copyright (C) 2026 SHX VPN
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or modify it
|
||||
* under the terms of the GNU General Public License as published by the
|
||||
* Free Software Foundation; either version 2 of the License, or (at your
|
||||
* option) any later version.
|
||||
*/
|
||||
|
||||
package org.strongswan.android.ui.compose
|
||||
|
||||
import android.text.format.DateUtils
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.statusBarsPadding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import org.strongswan.android.R
|
||||
import org.strongswan.android.logic.events.EventAction
|
||||
import org.strongswan.android.logic.events.EventCategory
|
||||
import org.strongswan.android.logic.events.EventKind
|
||||
import org.strongswan.android.logic.events.EventLog
|
||||
import org.strongswan.android.logic.events.ShxEvent
|
||||
import org.strongswan.android.ui.compose.design.ShxChip
|
||||
import org.strongswan.android.ui.compose.design.ShxChipTone
|
||||
import org.strongswan.android.ui.compose.design.ShxHeaderAction
|
||||
import org.strongswan.android.ui.compose.design.ShxIcons
|
||||
import org.strongswan.android.ui.compose.design.ShxPageHeader
|
||||
import org.strongswan.android.ui.compose.design.ShxSectionHeader
|
||||
import org.strongswan.android.ui.compose.home.formatClock
|
||||
import org.strongswan.android.ui.compose.theme.LocalShxColors
|
||||
import org.strongswan.android.ui.compose.theme.ShxType
|
||||
import java.util.Calendar
|
||||
|
||||
/** Inbox filters, in chip order. */
|
||||
internal enum class InboxFilter
|
||||
{
|
||||
ALL,
|
||||
WAITING,
|
||||
CONNECTION,
|
||||
ROUTING,
|
||||
APP;
|
||||
|
||||
fun matches(event: ShxEvent): Boolean = when (this)
|
||||
{
|
||||
ALL -> true
|
||||
WAITING -> event.waiting
|
||||
CONNECTION -> event.category == EventCategory.CONNECTION
|
||||
ROUTING -> event.category == EventCategory.ROUTING
|
||||
APP -> event.category == EventCategory.APP
|
||||
}
|
||||
}
|
||||
|
||||
/** Groups events by calendar day, newest first: (day start, events). */
|
||||
internal fun groupByDay(events: List<ShxEvent>): List<Pair<Long, List<ShxEvent>>> =
|
||||
events.sortedByDescending { it.at }.groupBy { dayStart(it.at) }.toList()
|
||||
|
||||
internal fun dayStart(at: Long): Long
|
||||
{
|
||||
val c = Calendar.getInstance()
|
||||
c.timeInMillis = at
|
||||
c.set(Calendar.HOUR_OF_DAY, 0)
|
||||
c.set(Calendar.MINUTE, 0)
|
||||
c.set(Calendar.SECOND, 0)
|
||||
c.set(Calendar.MILLISECOND, 0)
|
||||
return c.timeInMillis
|
||||
}
|
||||
|
||||
/** «Events» (F_Inbox). */
|
||||
@Composable
|
||||
fun InboxScreen(onBack: () -> Unit, onAction: (ShxEvent) -> Unit, onNotificationSettings: () -> Unit)
|
||||
{
|
||||
val context = LocalContext.current
|
||||
val c = LocalShxColors.current
|
||||
val log = remember { EventLog.getInstance(context) }
|
||||
val events by log.events.collectAsStateWithLifecycle()
|
||||
var filter by rememberSaveable { mutableStateOf(InboxFilter.ALL) }
|
||||
val waiting = events.count { it.waiting }
|
||||
val shown = remember(events, filter) { groupByDay(events.filter(filter::matches)) }
|
||||
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize().background(c.background).statusBarsPadding(),
|
||||
contentPadding = PaddingValues(bottom = 28.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
)
|
||||
{
|
||||
item(key = "header") {
|
||||
ShxPageHeader(stringResource(R.string.events_title), onBack)
|
||||
{
|
||||
if (events.any { !it.read })
|
||||
{
|
||||
ShxHeaderAction(stringResource(R.string.events_read_all)) { log.markAllRead() }
|
||||
}
|
||||
}
|
||||
}
|
||||
item(key = "filters") {
|
||||
FlowRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 4.dp, bottom = 4.dp),
|
||||
)
|
||||
{
|
||||
ShxChip(stringResource(R.string.events_filter_all), filter == InboxFilter.ALL, { filter = InboxFilter.ALL })
|
||||
ShxChip(
|
||||
if (waiting > 0) stringResource(R.string.events_filter_waiting_n, waiting) else stringResource(R.string.events_filter_waiting),
|
||||
filter == InboxFilter.WAITING, { filter = InboxFilter.WAITING },
|
||||
tone = if (waiting > 0) ShxChipTone.WARNING else ShxChipTone.NORMAL,
|
||||
)
|
||||
ShxChip(stringResource(R.string.events_filter_connection), filter == InboxFilter.CONNECTION, { filter = InboxFilter.CONNECTION })
|
||||
ShxChip(stringResource(R.string.events_filter_routing), filter == InboxFilter.ROUTING, { filter = InboxFilter.ROUTING })
|
||||
ShxChip(stringResource(R.string.events_filter_app), filter == InboxFilter.APP, { filter = InboxFilter.APP })
|
||||
}
|
||||
}
|
||||
if (shown.isEmpty())
|
||||
{
|
||||
item(key = "empty") {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 32.dp, vertical = 48.dp),
|
||||
)
|
||||
{
|
||||
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(56.dp).clip(RoundedCornerShape(18.dp)).background(c.accentContainer))
|
||||
{
|
||||
Icon(ShxIcons.Bell, contentDescription = null, tint = c.onAccentContainer, modifier = Modifier.size(26.dp))
|
||||
}
|
||||
Text(
|
||||
stringResource(if (events.isEmpty()) R.string.events_empty else R.string.events_empty_filter),
|
||||
style = ShxType.note.copy(fontSize = 14.sp),
|
||||
color = c.textSoft,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
shown.forEach { (day, dayEvents) ->
|
||||
item(key = "day-$day") {
|
||||
ShxSectionHeader(dayLabel(day), Modifier.padding(horizontal = 16.dp))
|
||||
}
|
||||
items(dayEvents, key = { it.id }) { event ->
|
||||
EventCard(event = event, onAction = { onAction(event) }, onDecline = { log.answer(event.id) })
|
||||
}
|
||||
}
|
||||
item(key = "footer") {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 22.dp, vertical = 12.dp),
|
||||
)
|
||||
{
|
||||
Text(stringResource(R.string.events_footer), style = ShxType.meta.copy(fontWeight = FontWeight.Normal), color = c.textSub, textAlign = TextAlign.Center)
|
||||
ShxHeaderAction(stringResource(R.string.events_notification_settings), onNotificationSettings)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun dayLabel(day: Long): String
|
||||
{
|
||||
val context = LocalContext.current
|
||||
val today = dayStart(System.currentTimeMillis())
|
||||
return when (day)
|
||||
{
|
||||
today -> stringResource(R.string.events_today)
|
||||
dayStart(today - 12 * 60 * 60 * 1000L) -> stringResource(R.string.events_yesterday)
|
||||
else -> DateUtils.formatDateTime(context, day, DateUtils.FORMAT_SHOW_DATE or DateUtils.FORMAT_NO_YEAR)
|
||||
}
|
||||
}
|
||||
|
||||
/** Event card of the mockup: radius 22, 36dp icon well, title 15/800 + time, text 13, small buttons. */
|
||||
@Composable
|
||||
private fun EventCard(event: ShxEvent, onAction: () -> Unit, onDecline: () -> Unit)
|
||||
{
|
||||
val context = LocalContext.current
|
||||
val c = LocalShxColors.current
|
||||
val warn = event.kind == EventKind.WARN && !event.resolved
|
||||
val ask = event.kind == EventKind.ASK && !event.resolved
|
||||
val (bg, border) = when
|
||||
{
|
||||
warn -> c.warningContainer to c.warningBorder
|
||||
ask -> c.card to c.accentBorder
|
||||
else -> c.card to c.cardBorder
|
||||
}
|
||||
val (icon: ImageVector, iconBg: Color, iconFg: Color) = when (event.kind)
|
||||
{
|
||||
EventKind.WARN -> Triple(ShxIcons.Alert, c.warning, Color.White)
|
||||
EventKind.ASK -> Triple(if (event.category == EventCategory.ROUTING) ShxIcons.Apps else ShxIcons.Help, c.accent, Color.White)
|
||||
EventKind.INFO -> Triple(
|
||||
when (event.category)
|
||||
{
|
||||
EventCategory.APP -> ShxIcons.Download
|
||||
EventCategory.ROUTING -> ShxIcons.Route
|
||||
EventCategory.CONNECTION -> ShxIcons.Swap
|
||||
},
|
||||
c.tile, c.onAccentContainer,
|
||||
)
|
||||
EventKind.OK -> Triple(ShxIcons.Shield, c.successContainer, c.onSuccess)
|
||||
}
|
||||
val actionBg = if (warn) c.warning else c.accent
|
||||
val shape = RoundedCornerShape(22.dp)
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 16.dp)
|
||||
.fillMaxWidth()
|
||||
.alpha(if (event.resolved) 0.7f else 1f)
|
||||
.clip(shape)
|
||||
.background(bg)
|
||||
.border(1.dp, border, shape)
|
||||
.padding(14.dp),
|
||||
)
|
||||
{
|
||||
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(36.dp).clip(RoundedCornerShape(12.dp)).background(iconBg))
|
||||
{
|
||||
Icon(icon, contentDescription = null, tint = iconFg, modifier = Modifier.size(18.dp))
|
||||
}
|
||||
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp))
|
||||
{
|
||||
Row(verticalAlignment = Alignment.Top, horizontalArrangement = Arrangement.spacedBy(8.dp))
|
||||
{
|
||||
Text(
|
||||
if (event.count > 1) stringResource(R.string.events_title_count, event.title, event.count) else event.title,
|
||||
style = ShxType.cardTitle.copy(fontWeight = if (event.read) FontWeight.Bold else FontWeight.ExtraBold),
|
||||
color = c.text,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Text(formatClock(context, event.at), style = ShxType.meta.copy(fontWeight = FontWeight.Normal), color = c.textSub)
|
||||
}
|
||||
if (event.text.isNotEmpty())
|
||||
{
|
||||
Text(event.text, style = ShxType.note, color = c.textSoft)
|
||||
}
|
||||
if (event.resolved)
|
||||
{
|
||||
Text(stringResource(R.string.events_resolved), style = ShxType.meta.copy(fontWeight = FontWeight.ExtraBold), color = c.success)
|
||||
}
|
||||
else if (event.action != EventAction.NONE)
|
||||
{
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.padding(top = 6.dp))
|
||||
{
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier.height(36.dp).clip(RoundedCornerShape(12.dp)).background(actionBg)
|
||||
.clickable(role = Role.Button, onClick = onAction).padding(horizontal = 14.dp),
|
||||
)
|
||||
{
|
||||
Text(stringResource(event.action.labelRes()), style = ShxType.chip.copy(fontWeight = FontWeight.ExtraBold), color = Color.White)
|
||||
}
|
||||
if (event.kind == EventKind.ASK)
|
||||
{
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier.height(36.dp).clip(RoundedCornerShape(12.dp)).border(1.dp, c.outline, RoundedCornerShape(12.dp))
|
||||
.clickable(role = Role.Button, onClick = onDecline).padding(horizontal = 12.dp),
|
||||
)
|
||||
{
|
||||
Text(stringResource(R.string.events_action_no), style = ShxType.chip, color = c.text)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun EventAction.labelRes(): Int = when (this)
|
||||
{
|
||||
EventAction.OPEN_HOME -> R.string.events_action_open
|
||||
EventAction.OPEN_ROUTING -> R.string.events_action_routing
|
||||
EventAction.OPEN_ROUTE_CHECK -> R.string.events_action_check
|
||||
EventAction.OPEN_LOG -> R.string.show_log
|
||||
EventAction.INSTALL_UPDATE -> R.string.events_action_update
|
||||
EventAction.SET_APP_DIRECT -> R.string.events_action_direct
|
||||
EventAction.PROFILE_UPDATE -> R.string.events_action_profile_update
|
||||
EventAction.OPEN_SERVER -> R.string.events_action_open_server
|
||||
EventAction.NONE -> R.string.events_action_open
|
||||
}
|
||||
+209
@@ -0,0 +1,209 @@
|
||||
/*
|
||||
* Copyright (C) 2026 SHX VPN
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or modify it
|
||||
* under the terms of the GNU General Public License as published by the
|
||||
* Free Software Foundation; either version 2 of the License, or (at your
|
||||
* option) any later version.
|
||||
*/
|
||||
|
||||
package org.strongswan.android.ui.compose
|
||||
|
||||
import android.Manifest
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.net.Uri
|
||||
import android.net.VpnService
|
||||
import android.os.Build
|
||||
import android.os.PowerManager
|
||||
import android.provider.Settings
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import org.strongswan.android.R
|
||||
import org.strongswan.android.logic.routing.AppTraffic
|
||||
import org.strongswan.android.ui.compose.design.ShxIcons
|
||||
import org.strongswan.android.ui.compose.theme.LocalShxColors
|
||||
import org.strongswan.android.ui.compose.theme.ShxType
|
||||
|
||||
/** How much a permission matters, as the tag on its row. */
|
||||
enum class PermissionLevel { REQUIRED, IMPORTANT, OPTIONAL }
|
||||
|
||||
/** Current state of the five permissions the VPN works best with. */
|
||||
data class PermissionStatus(
|
||||
val vpn: Boolean,
|
||||
val notifications: Boolean,
|
||||
val battery: Boolean,
|
||||
val usage: Boolean,
|
||||
val location: Boolean,
|
||||
)
|
||||
|
||||
@SuppressLint("BatteryLife")
|
||||
fun permissionStatus(context: Context): PermissionStatus
|
||||
{
|
||||
val pm = context.getSystemService(Context.POWER_SERVICE) as? PowerManager
|
||||
return PermissionStatus(
|
||||
vpn = runCatching { VpnService.prepare(context) == null }.getOrDefault(false),
|
||||
notifications = NotificationManagerCompat.from(context).areNotificationsEnabled(),
|
||||
battery = pm?.isIgnoringBatteryOptimizations(context.packageName) ?: true,
|
||||
usage = AppTraffic.hasAccess(context),
|
||||
location = ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* «For the VPN to work reliably» (F_Permissions): one card per permission
|
||||
* with its tag (required / important / optional), why it is needed, and
|
||||
* a green check or an «Allow» button. Re-checked whenever the screen resumes.
|
||||
*/
|
||||
@SuppressLint("BatteryLife")
|
||||
@Composable
|
||||
fun PermissionsList(modifier: Modifier = Modifier)
|
||||
{
|
||||
val context = LocalContext.current
|
||||
var resumes by remember { mutableIntStateOf(0) }
|
||||
val owner = LocalLifecycleOwner.current
|
||||
DisposableEffect(owner) {
|
||||
val observer = LifecycleEventObserver { _, event -> if (event == Lifecycle.Event.ON_RESUME) resumes++ }
|
||||
owner.lifecycle.addObserver(observer)
|
||||
onDispose { owner.lifecycle.removeObserver(observer) }
|
||||
}
|
||||
val status = remember(resumes) { permissionStatus(context) }
|
||||
val vpnLauncher = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { resumes++ }
|
||||
val permissionLauncher = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { resumes++ }
|
||||
fun open(intent: Intent) = runCatching { context.startActivity(intent) }
|
||||
|
||||
Column(modifier, verticalArrangement = Arrangement.spacedBy(10.dp))
|
||||
{
|
||||
PermissionCard(
|
||||
ShxIcons.Shield, stringResource(R.string.perm_vpn), PermissionLevel.REQUIRED, stringResource(R.string.perm_vpn_why), status.vpn,
|
||||
) { runCatching { VpnService.prepare(context) }.getOrNull()?.let { vpnLauncher.launch(it) } }
|
||||
PermissionCard(
|
||||
ShxIcons.Bell, stringResource(R.string.perm_notifications), PermissionLevel.REQUIRED, stringResource(R.string.perm_notifications_why), status.notifications,
|
||||
)
|
||||
{
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED)
|
||||
{
|
||||
permissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
|
||||
}
|
||||
else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O)
|
||||
{
|
||||
open(Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS).putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName))
|
||||
}
|
||||
}
|
||||
PermissionCard(
|
||||
ShxIcons.Phone, stringResource(R.string.perm_battery), PermissionLevel.IMPORTANT, stringResource(R.string.perm_battery_why), status.battery,
|
||||
/* the vendor switches (step 2 of F_Battery) matter even when Android allows it */
|
||||
openWhenDone = true,
|
||||
) { open(Intent(context, org.strongswan.android.ui.BatteryActivity::class.java)) }
|
||||
PermissionCard(
|
||||
ShxIcons.Stats, stringResource(R.string.perm_usage), PermissionLevel.OPTIONAL, stringResource(R.string.perm_usage_why), status.usage,
|
||||
)
|
||||
{
|
||||
if (open(Intent(Settings.ACTION_USAGE_ACCESS_SETTINGS, Uri.fromParts("package", context.packageName, null))).isFailure)
|
||||
{
|
||||
open(Intent(Settings.ACTION_USAGE_ACCESS_SETTINGS))
|
||||
}
|
||||
}
|
||||
PermissionCard(
|
||||
ShxIcons.Pin, stringResource(R.string.perm_location), PermissionLevel.OPTIONAL, stringResource(R.string.perm_location_why), status.location,
|
||||
) { permissionLauncher.launch(Manifest.permission.ACCESS_FINE_LOCATION) }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PermissionCard(icon: ImageVector, title: String, level: PermissionLevel, why: String, done: Boolean, openWhenDone: Boolean = false, onAllow: () -> Unit)
|
||||
{
|
||||
val c = LocalShxColors.current
|
||||
val (tagBg, tagFg, tag) = when (level)
|
||||
{
|
||||
PermissionLevel.REQUIRED -> Triple(c.accent, Color.White, stringResource(R.string.perm_tag_required))
|
||||
PermissionLevel.IMPORTANT -> Triple(c.warningStrong, c.onWarning, stringResource(R.string.perm_tag_important))
|
||||
PermissionLevel.OPTIONAL -> Triple(c.tile, c.textSoft, stringResource(R.string.perm_tag_optional))
|
||||
}
|
||||
val border = if (done || level == PermissionLevel.OPTIONAL) c.cardBorder else c.accentBorder
|
||||
val shape = RoundedCornerShape(22.dp)
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
modifier = Modifier.fillMaxWidth().clip(shape).background(c.card).border(1.dp, border, shape)
|
||||
.then(if (done && openWhenDone) Modifier.clickable(role = Role.Button, onClick = onAllow) else Modifier).padding(14.dp),
|
||||
)
|
||||
{
|
||||
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(40.dp).clip(RoundedCornerShape(14.dp)).background(if (done) c.successContainer else c.tile))
|
||||
{
|
||||
Icon(icon, contentDescription = null, tint = if (done) c.onSuccess else c.onAccentContainer, modifier = Modifier.size(20.dp))
|
||||
}
|
||||
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp))
|
||||
{
|
||||
FlowRow(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalArrangement = Arrangement.spacedBy(2.dp), itemVerticalAlignment = Alignment.CenterVertically)
|
||||
{
|
||||
Text(title, style = ShxType.cardTitle, color = c.text)
|
||||
Text(
|
||||
tag.uppercase(), style = ShxType.tag.copy(fontSize = 10.sp), color = tagFg, maxLines = 1, softWrap = false,
|
||||
modifier = Modifier.clip(RoundedCornerShape(6.dp)).background(tagBg).padding(horizontal = 6.dp, vertical = 1.dp),
|
||||
)
|
||||
}
|
||||
Text(why, style = ShxType.meta.copy(fontWeight = FontWeight.Normal, lineHeight = 16.sp), color = c.textSub)
|
||||
}
|
||||
if (done)
|
||||
{
|
||||
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(32.dp).clip(CircleShape).background(c.success))
|
||||
{
|
||||
Icon(ShxIcons.Check, contentDescription = stringResource(R.string.perm_done), tint = Color.White, modifier = Modifier.size(18.dp))
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
val optional = level == PermissionLevel.OPTIONAL
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier.height(36.dp).clip(RoundedCornerShape(12.dp)).background(if (optional) c.tile else c.accent)
|
||||
.clickable(role = Role.Button, onClick = onAllow).padding(horizontal = 12.dp),
|
||||
)
|
||||
{
|
||||
Text(stringResource(R.string.perm_allow), style = ShxType.chip.copy(fontWeight = FontWeight.ExtraBold), color = if (optional) c.onAccentContainer else Color.White)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
/*
|
||||
* Copyright (C) 2026 SHX VPN
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or modify it
|
||||
* under the terms of the GNU General Public License as published by the
|
||||
* Free Software Foundation; either version 2 of the License, or (at your
|
||||
* option) any later version.
|
||||
*/
|
||||
|
||||
package org.strongswan.android.ui.compose
|
||||
|
||||
import android.content.Intent
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.localbroadcastmanager.content.LocalBroadcastManager
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import org.strongswan.android.R
|
||||
import org.strongswan.android.data.VpnProfile
|
||||
import org.strongswan.android.data.VpnProfileDataSource
|
||||
import org.strongswan.android.data.VpnProfileSource
|
||||
import org.strongswan.android.ui.ProfileFormViewModel
|
||||
import org.strongswan.android.utils.Constants
|
||||
|
||||
/**
|
||||
* The profile form hosted inside the detail pane of the Profiles list-detail layout
|
||||
* (expanded width). Same save logic as VpnProfileDetailActivity, which is still used
|
||||
* on phones.
|
||||
*/
|
||||
@Composable
|
||||
fun ProfileEditorPane(
|
||||
profile: VpnProfile?,
|
||||
onClose: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
)
|
||||
{
|
||||
val context = LocalContext.current
|
||||
val dataSource = remember { VpnProfileSource(context).open() as VpnProfileDataSource }
|
||||
DisposableEffect(dataSource) { onDispose { dataSource.close() } }
|
||||
val messages = remember { MutableSharedFlow<Int>(extraBufferCapacity = 1) }
|
||||
val key = profile?.getUUID()?.toString() ?: "new"
|
||||
val formViewModel: ProfileFormViewModel = viewModel(
|
||||
key = "profile-editor-$key",
|
||||
factory = object : ViewModelProvider.Factory
|
||||
{
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>): T =
|
||||
ProfileFormViewModel(profile?.toFormState() ?: ProfileFormState()) as T
|
||||
},
|
||||
)
|
||||
ProfileFormScreen(
|
||||
title = profile?.name ?: stringResource(R.string.add_profile),
|
||||
state = formViewModel.state,
|
||||
onChange = { formViewModel.state = it },
|
||||
onCancel = onClose,
|
||||
onSave = {
|
||||
val checked = formViewModel.state.validate(context)
|
||||
formViewModel.state = checked
|
||||
if (!checked.isValid())
|
||||
{
|
||||
messages.tryEmit(R.string.alert_text_no_input_gateway)
|
||||
return@ProfileFormScreen
|
||||
}
|
||||
val saved = checked.toProfile(profile)
|
||||
if (profile != null) dataSource.updateVpnProfile(saved)
|
||||
else dataSource.insertProfile(saved)
|
||||
if (profile == null) org.strongswan.android.logic.profiles.ProfileExtras.recordManual(context, saved.getUUID().toString())
|
||||
val intent = Intent(Constants.VPN_PROFILES_CHANGED)
|
||||
intent.putExtra(Constants.VPN_PROFILES_SINGLE, saved.getUUID().toString())
|
||||
LocalBroadcastManager.getInstance(context).sendBroadcast(intent)
|
||||
onClose()
|
||||
},
|
||||
messages = messages,
|
||||
)
|
||||
}
|
||||
+237
-306
@@ -9,6 +9,8 @@
|
||||
|
||||
package org.strongswan.android.ui.compose
|
||||
|
||||
import org.strongswan.android.ui.compose.components.ShxSectionCard
|
||||
import org.strongswan.android.ui.compose.components.ShxSettingRow
|
||||
import android.app.Activity
|
||||
import android.content.Intent
|
||||
import android.security.KeyChain
|
||||
@@ -37,18 +39,14 @@ import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.ExpandMore
|
||||
import androidx.compose.material.icons.filled.Visibility
|
||||
import androidx.compose.material.icons.filled.VisibilityOff
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ExposedDropdownMenuAnchorType
|
||||
import androidx.compose.material3.ExposedDropdownMenu
|
||||
import androidx.compose.material3.ExposedDropdownMenuBox
|
||||
import androidx.compose.material3.ExposedDropdownMenuDefaults
|
||||
import androidx.compose.material3.ExposedDropdownMenuAnchorType
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
@@ -82,17 +80,37 @@ import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.text.input.VisualTransformation
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.strongswan.android.R
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.sp
|
||||
import org.strongswan.android.ui.compose.design.ShxButton
|
||||
import org.strongswan.android.ui.compose.design.ShxFormField
|
||||
import org.strongswan.android.ui.compose.design.ShxIcons
|
||||
import org.strongswan.android.ui.compose.design.ShxNote
|
||||
import org.strongswan.android.ui.compose.design.ShxPanel
|
||||
import org.strongswan.android.ui.compose.design.ShxRow
|
||||
import org.strongswan.android.ui.compose.design.ShxSectionHeader
|
||||
import org.strongswan.android.ui.compose.design.ShxTabs
|
||||
import org.strongswan.android.ui.compose.design.ShxToggleRow
|
||||
import org.strongswan.android.ui.compose.design.ShxTone
|
||||
import org.strongswan.android.ui.compose.theme.LocalShxColors
|
||||
import org.strongswan.android.ui.compose.theme.ShxType
|
||||
import org.strongswan.android.data.VpnProfile
|
||||
import org.strongswan.android.data.VpnProfile.SelectedAppsHandling
|
||||
import org.strongswan.android.data.VpnProfileDataSource
|
||||
import org.strongswan.android.data.VpnType
|
||||
import org.strongswan.android.data.VpnType.VpnTypeFeature
|
||||
import org.strongswan.android.ui.SelectedApplicationsActivity
|
||||
import org.strongswan.android.ui.MainActivity
|
||||
import org.strongswan.android.ui.TrustedCertificatesActivity
|
||||
import org.strongswan.android.ui.compose.theme.ShxSpacing
|
||||
import org.strongswan.android.utils.Constants
|
||||
import org.strongswan.android.utils.IPRangeSet
|
||||
import org.strongswan.android.utils.Utils
|
||||
import java.util.TreeSet
|
||||
|
||||
data class ProfileFormState(
|
||||
val name: String = "",
|
||||
@@ -120,8 +138,6 @@ data class ProfileFormState(
|
||||
val excludedSubnets: String = "",
|
||||
val blockIpv4: Boolean = false,
|
||||
val blockIpv6: Boolean = false,
|
||||
val appsHandling: SelectedAppsHandling = SelectedAppsHandling.SELECTED_APPS_DISABLE,
|
||||
val selectedApps: Set<String> = emptySet(),
|
||||
val ikeProposal: String = "",
|
||||
val espProposal: String = "",
|
||||
val proxyHost: String = "",
|
||||
@@ -130,6 +146,10 @@ data class ProfileFormState(
|
||||
val readOnly: Boolean = false,
|
||||
val gatewayError: String? = null,
|
||||
val usernameError: String? = null,
|
||||
val includedSubnetsError: String? = null,
|
||||
val excludedSubnetsError: String? = null,
|
||||
val ikeProposalError: String? = null,
|
||||
val espProposalError: String? = null,
|
||||
val advanced: Boolean = false,
|
||||
)
|
||||
|
||||
@@ -170,8 +190,6 @@ fun ProfileFormState.toProfile(existing: VpnProfile?): VpnProfile
|
||||
if (blockIpv4) st = st or VpnProfile.SPLIT_TUNNELING_BLOCK_IPV4
|
||||
if (blockIpv6) st = st or VpnProfile.SPLIT_TUNNELING_BLOCK_IPV6
|
||||
profile.splitTunneling = if (st == 0) null else st
|
||||
profile.selectedAppsHandling = appsHandling
|
||||
profile.setSelectedApps(TreeSet(selectedApps))
|
||||
profile.ikeProposal = ikeProposal.trim().ifEmpty { null }
|
||||
profile.espProposal = espProposal.trim().ifEmpty { null }
|
||||
profile.proxyHost = proxyHost.trim().ifEmpty { null }
|
||||
@@ -210,8 +228,6 @@ fun VpnProfile.toFormState(): ProfileFormState
|
||||
excludedSubnets = excludedSubnets ?: "",
|
||||
blockIpv4 = st and VpnProfile.SPLIT_TUNNELING_BLOCK_IPV4 != 0,
|
||||
blockIpv6 = st and VpnProfile.SPLIT_TUNNELING_BLOCK_IPV6 != 0,
|
||||
appsHandling = selectedAppsHandling,
|
||||
selectedApps = selectedAppsSet ?: emptySet(),
|
||||
ikeProposal = ikeProposal ?: "",
|
||||
espProposal = espProposal ?: "",
|
||||
proxyHost = proxyHost ?: "",
|
||||
@@ -221,14 +237,15 @@ fun VpnProfile.toFormState(): ProfileFormState
|
||||
advanced = remoteId != null || localId != null || dnsServers != null || getMTU() != null
|
||||
|| port != null || getNATKeepAlive() != null || flags != 0 || st != 0
|
||||
|| includedSubnets != null || excludedSubnets != null
|
||||
|| selectedAppsHandling != SelectedAppsHandling.SELECTED_APPS_DISABLE
|
||||
|| ikeProposal != null || espProposal != null || proxyHost != null,
|
||||
)
|
||||
}
|
||||
|
||||
fun ProfileFormState.validate(ctx: android.content.Context): ProfileFormState
|
||||
{
|
||||
var next = this.copy(gatewayError = null, usernameError = null)
|
||||
var next = this.copy(gatewayError = null, usernameError = null,
|
||||
includedSubnetsError = null, excludedSubnetsError = null,
|
||||
ikeProposalError = null, espProposalError = null)
|
||||
if (gateway.isBlank())
|
||||
{
|
||||
next = next.copy(gatewayError = ctx.getString(R.string.alert_text_no_input_gateway))
|
||||
@@ -249,16 +266,22 @@ fun ProfileFormState.validate(ctx: android.content.Context): ProfileFormState
|
||||
if (includedSubnets.isNotBlank())
|
||||
{
|
||||
try { IPRangeSet.fromString(includedSubnets) } catch (_: Exception) {
|
||||
next = next.copy(advanced = true)
|
||||
next = next.copy(advanced = true, includedSubnetsError = ctx.getString(R.string.alert_text_no_subnets))
|
||||
}
|
||||
}
|
||||
if (excludedSubnets.isNotBlank())
|
||||
{
|
||||
try { IPRangeSet.fromString(excludedSubnets) } catch (_: Exception) {
|
||||
next = next.copy(advanced = true, excludedSubnetsError = ctx.getString(R.string.alert_text_no_subnets))
|
||||
}
|
||||
}
|
||||
if (ikeProposal.isNotBlank() && !Utils.isProposalValid(true, ikeProposal))
|
||||
{
|
||||
next = next.copy(advanced = true)
|
||||
next = next.copy(advanced = true, ikeProposalError = ctx.getString(R.string.alert_text_no_proposal))
|
||||
}
|
||||
if (espProposal.isNotBlank() && !Utils.isProposalValid(false, espProposal))
|
||||
{
|
||||
next = next.copy(advanced = true)
|
||||
next = next.copy(advanced = true, espProposalError = ctx.getString(R.string.alert_text_no_proposal))
|
||||
}
|
||||
return next
|
||||
}
|
||||
@@ -269,9 +292,14 @@ fun ProfileFormState.isValid(): Boolean
|
||||
if (vpnType.has(VpnTypeFeature.USER_PASS) && username.isBlank()) return false
|
||||
if (vpnType.has(VpnTypeFeature.CERTIFICATE) && userCertAlias.isNullOrBlank()) return false
|
||||
if (!caAuto && caAlias.isNullOrBlank()) return false
|
||||
if (includedSubnetsError != null || excludedSubnetsError != null) return false
|
||||
if (ikeProposalError != null || espProposalError != null) return false
|
||||
return true
|
||||
}
|
||||
|
||||
/** The three sign-in variants of the mockup tabs; the rarer types stay in «More». */
|
||||
private val LOGIN_TYPES = listOf(VpnType.IKEV2_EAP, VpnType.IKEV2_CERT, VpnType.IKEV2_CERT_EAP)
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ProfileFormScreen(
|
||||
@@ -280,13 +308,19 @@ fun ProfileFormScreen(
|
||||
onChange: (ProfileFormState) -> Unit,
|
||||
onSave: () -> Unit,
|
||||
onCancel: () -> Unit,
|
||||
/* one-shot messages (string resource ids) shown as a snackbar */
|
||||
messages: kotlinx.coroutines.flow.Flow<Int>? = null,
|
||||
)
|
||||
{
|
||||
val context = LocalContext.current
|
||||
val c = LocalShxColors.current
|
||||
val snackbarHostState = remember { androidx.compose.material3.SnackbarHostState() }
|
||||
androidx.compose.runtime.LaunchedEffect(messages)
|
||||
{
|
||||
messages?.collect { snackbarHostState.showSnackbar(context.getString(it)) }
|
||||
}
|
||||
val types = stringArrayResource(R.array.vpn_types)
|
||||
val appsHandling = stringArrayResource(R.array.apps_handling)
|
||||
var typeExpanded by remember { mutableStateOf(false) }
|
||||
var appsExpanded by remember { mutableStateOf(false) }
|
||||
var showPassword by remember { mutableStateOf(false) }
|
||||
val enabled = !state.readOnly
|
||||
|
||||
@@ -298,347 +332,244 @@ fun ProfileFormScreen(
|
||||
onChange(latest.value.copy(caAlias = alias, caLabel = alias ?: "", caAuto = alias == null))
|
||||
}
|
||||
}
|
||||
val pickApps = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
||||
if (result.resultCode == Activity.RESULT_OK)
|
||||
{
|
||||
val list = result.data?.getStringArrayListExtra(VpnProfileDataSource.KEY_SELECTED_APPS_LIST) ?: arrayListOf()
|
||||
onChange(latest.value.copy(selectedApps = list.toSet()))
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
modifier = Modifier.windowInsetsPadding(
|
||||
WindowInsets.safeDrawing.only(WindowInsetsSides.Bottom),
|
||||
),
|
||||
contentWindowInsets = WindowInsets.safeDrawing.only(
|
||||
WindowInsetsSides.Horizontal + WindowInsetsSides.Top,
|
||||
),
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text(title, maxLines = 1) },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onCancel) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null)
|
||||
}
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.background,
|
||||
),
|
||||
)
|
||||
},
|
||||
bottomBar = {
|
||||
if (enabled)
|
||||
{
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.background,
|
||||
tonalElevation = 0.dp,
|
||||
modifier = Modifier.windowInsetsPadding(
|
||||
WindowInsets.safeDrawing.only(WindowInsetsSides.Bottom),
|
||||
),
|
||||
)
|
||||
{
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 20.dp, vertical = 12.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
OutlinedButton(
|
||||
onClick = onCancel,
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.height(48.dp),
|
||||
) { Text(stringResource(R.string.profile_edit_cancel)) }
|
||||
Button(
|
||||
onClick = onSave,
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
modifier = Modifier
|
||||
.weight(2f)
|
||||
.height(48.dp),
|
||||
) { Text(stringResource(R.string.profile_edit_save)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
) { padding ->
|
||||
Box(Modifier.fillMaxSize().background(c.background))
|
||||
{
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(20.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(14.dp),
|
||||
.windowInsetsPadding(WindowInsets.safeDrawing)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
)
|
||||
{
|
||||
if (state.readOnly)
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth().padding(start = 12.dp, end = 12.dp, top = 16.dp, bottom = 8.dp))
|
||||
{
|
||||
Text(stringResource(R.string.alert_text_vpn_profile_read_only), color = MaterialTheme.colorScheme.error)
|
||||
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(44.dp).clip(CircleShape).clickable(role = Role.Button, onClick = onCancel))
|
||||
{
|
||||
Icon(ShxIcons.Close, contentDescription = stringResource(R.string.back), tint = c.text, modifier = Modifier.size(22.dp))
|
||||
}
|
||||
Text(title, style = ShxType.pageTitle, color = c.text, maxLines = 1, modifier = Modifier.weight(1f))
|
||||
}
|
||||
FormSection(title = stringResource(R.string.profile_section_basic))
|
||||
Column(Modifier.padding(start = 16.dp, end = 16.dp, bottom = 24.dp), verticalArrangement = Arrangement.spacedBy(12.dp))
|
||||
{
|
||||
OutlinedTextField(
|
||||
if (state.readOnly)
|
||||
{
|
||||
ShxNote(stringResource(R.string.alert_text_vpn_profile_read_only), tone = ShxTone.ERROR)
|
||||
}
|
||||
ShxFormField(
|
||||
label = stringResource(R.string.profile_name_label).trimEnd(':'),
|
||||
value = state.name,
|
||||
onValueChange = { onChange(state.copy(name = it)) },
|
||||
placeholder = stringResource(R.string.form_name_placeholder),
|
||||
enabled = enabled,
|
||||
)
|
||||
ShxFormField(
|
||||
label = stringResource(R.string.profile_gateway_label).trimEnd(':'),
|
||||
value = state.gateway,
|
||||
onValueChange = { onChange(state.copy(gateway = it, gatewayError = null)) },
|
||||
label = { Text(stringResource(R.string.profile_gateway_label)) },
|
||||
supportingText = { Text(state.gatewayError ?: stringResource(R.string.profile_gateway_hint)) },
|
||||
isError = state.gatewayError != null,
|
||||
placeholder = stringResource(R.string.form_gateway_placeholder),
|
||||
error = state.gatewayError,
|
||||
enabled = enabled,
|
||||
singleLine = true,
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri),
|
||||
)
|
||||
ExposedDropdownMenuBox(expanded = typeExpanded, onExpandedChange = { if (enabled) typeExpanded = it }) {
|
||||
OutlinedTextField(
|
||||
value = types.getOrElse(state.vpnType.ordinal) { "" },
|
||||
onValueChange = {},
|
||||
readOnly = true,
|
||||
enabled = enabled,
|
||||
label = { Text(stringResource(R.string.profile_vpn_type_label)) },
|
||||
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(typeExpanded) },
|
||||
modifier = Modifier.fillMaxWidth().menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable),
|
||||
ShxSectionHeader(stringResource(R.string.form_login))
|
||||
if (state.vpnType in LOGIN_TYPES)
|
||||
{
|
||||
ShxTabs(
|
||||
options = listOf(stringResource(R.string.form_login_password), stringResource(R.string.form_login_cert), stringResource(R.string.form_login_both)),
|
||||
selected = LOGIN_TYPES.indexOf(state.vpnType),
|
||||
onSelect = { if (enabled) onChange(state.copy(vpnType = LOGIN_TYPES[it])) },
|
||||
)
|
||||
ExposedDropdownMenu(expanded = typeExpanded, onDismissRequest = { typeExpanded = false }) {
|
||||
VpnType.values().forEachIndexed { index, type ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(types.getOrElse(index) { type.identifier }) },
|
||||
onClick = {
|
||||
onChange(state.copy(vpnType = type))
|
||||
typeExpanded = false
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
ShxNote(types.getOrElse(state.vpnType.ordinal) { state.vpnType.identifier }, tone = ShxTone.NEUTRAL)
|
||||
}
|
||||
if (state.vpnType.has(VpnTypeFeature.USER_PASS))
|
||||
{
|
||||
OutlinedTextField(
|
||||
ShxFormField(
|
||||
label = stringResource(R.string.profile_username_label).trimEnd(':'),
|
||||
value = state.username,
|
||||
onValueChange = { onChange(state.copy(username = it, usernameError = null)) },
|
||||
label = { Text(stringResource(R.string.profile_username_label)) },
|
||||
isError = state.usernameError != null,
|
||||
supportingText = state.usernameError?.let { { Text(it) } },
|
||||
error = state.usernameError,
|
||||
enabled = enabled,
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
OutlinedTextField(
|
||||
ShxFormField(
|
||||
label = stringResource(R.string.profile_password_label).trimEnd(':'),
|
||||
value = state.password,
|
||||
onValueChange = { onChange(state.copy(password = it)) },
|
||||
label = { Text(stringResource(R.string.profile_password_label)) },
|
||||
supportingText = { Text(stringResource(R.string.profile_password_hint)) },
|
||||
supporting = stringResource(R.string.profile_password_hint),
|
||||
enabled = enabled,
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
|
||||
visualTransformation = if (showPassword) VisualTransformation.None else PasswordVisualTransformation(),
|
||||
trailingIcon = {
|
||||
IconButton(onClick = { showPassword = !showPassword }) {
|
||||
Icon(if (showPassword) Icons.Default.VisibilityOff else Icons.Default.Visibility, null)
|
||||
trailing = {
|
||||
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(44.dp).clip(CircleShape).clickable { showPassword = !showPassword })
|
||||
{
|
||||
Icon(if (showPassword) ShxIcons.EyeOff else ShxIcons.Eye, null, tint = c.textSub, modifier = Modifier.size(20.dp))
|
||||
}
|
||||
},
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
if (state.vpnType.has(VpnTypeFeature.CERTIFICATE))
|
||||
{
|
||||
TextButton(onClick = {
|
||||
FormPickRow(
|
||||
title = state.userCertAlias ?: stringResource(R.string.profile_user_select_certificate),
|
||||
subtitle = stringResource(R.string.profile_user_certificate_label).trimEnd(':'),
|
||||
enabled = enabled,
|
||||
)
|
||||
{
|
||||
KeyChain.choosePrivateKeyAlias(context as Activity, { alias ->
|
||||
onChange(latest.value.copy(userCertAlias = alias))
|
||||
}, arrayOf("RSA", "EC"), null, null, -1, state.userCertAlias)
|
||||
}, enabled = enabled) {
|
||||
Text(state.userCertAlias ?: stringResource(R.string.profile_user_select_certificate))
|
||||
}
|
||||
}
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Checkbox(
|
||||
checked = state.caAuto,
|
||||
onCheckedChange = { onChange(state.copy(caAuto = it)) },
|
||||
enabled = enabled,
|
||||
)
|
||||
Text(stringResource(R.string.profile_ca_auto_label), modifier = Modifier.clickable(enabled = enabled) {
|
||||
onChange(state.copy(caAuto = !state.caAuto))
|
||||
})
|
||||
}
|
||||
if (!state.caAuto)
|
||||
ShxPanel()
|
||||
{
|
||||
TextButton(onClick = {
|
||||
val intent = Intent(context, TrustedCertificatesActivity::class.java)
|
||||
intent.action = TrustedCertificatesActivity.SELECT_CERTIFICATE
|
||||
pickCa.launch(intent)
|
||||
}, enabled = enabled) {
|
||||
Text(state.caLabel.ifEmpty { stringResource(R.string.profile_ca_select_certificate) })
|
||||
ShxToggleRow(stringResource(R.string.profile_ca_auto_label), state.caAuto, { onChange(state.copy(caAuto = it)) }, enabled = enabled, divider = !state.caAuto)
|
||||
if (!state.caAuto)
|
||||
{
|
||||
FormPickRow(
|
||||
title = state.caLabel.ifEmpty { stringResource(R.string.profile_ca_select_certificate) },
|
||||
subtitle = stringResource(R.string.profile_ca_label).trimEnd(':'),
|
||||
enabled = enabled,
|
||||
bordered = false,
|
||||
)
|
||||
{
|
||||
val intent = Intent(context, TrustedCertificatesActivity::class.java)
|
||||
intent.action = TrustedCertificatesActivity.SELECT_CERTIFICATE
|
||||
pickCa.launch(intent)
|
||||
}
|
||||
}
|
||||
}
|
||||
OutlinedTextField(
|
||||
value = state.name,
|
||||
onValueChange = { onChange(state.copy(name = it)) },
|
||||
label = { Text(stringResource(R.string.profile_name_label)) },
|
||||
supportingText = { Text(stringResource(R.string.profile_name_hint)) },
|
||||
enabled = enabled,
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
HorizontalDivider()
|
||||
val chevron by animateFloatAsState(if (state.advanced) 180f else 0f, label = "adv")
|
||||
ListItem(
|
||||
headlineContent = { Text(stringResource(R.string.profile_advanced_label)) },
|
||||
supportingContent = { Text(stringResource(R.string.profile_advanced_hint)) },
|
||||
trailingContent = {
|
||||
Icon(
|
||||
Icons.Default.ExpandMore,
|
||||
contentDescription = stringResource(R.string.profile_show_advanced_label),
|
||||
modifier = Modifier.rotate(chevron),
|
||||
)
|
||||
},
|
||||
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { onChange(state.copy(advanced = !state.advanced)) },
|
||||
/* «More: port, MTU, DNS, identities» */
|
||||
val chevron by animateFloatAsState(if (state.advanced) 90f else 0f, label = "adv")
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
modifier = Modifier.fillMaxWidth().height(52.dp).clip(RoundedCornerShape(16.dp)).background(c.card)
|
||||
.border(1.dp, c.cardBorder, RoundedCornerShape(16.dp))
|
||||
.clickable(role = Role.Button) { onChange(state.copy(advanced = !state.advanced)) }.padding(horizontal = 16.dp),
|
||||
)
|
||||
{
|
||||
Text(stringResource(R.string.form_more), style = ShxType.body.copy(fontWeight = FontWeight.Bold), color = c.text, modifier = Modifier.weight(1f), maxLines = 1, overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis)
|
||||
Icon(ShxIcons.Chevron, contentDescription = stringResource(R.string.profile_show_advanced_label), tint = c.iconMuted, modifier = Modifier.size(18.dp).rotate(chevron))
|
||||
}
|
||||
AnimatedVisibility(
|
||||
visible = state.advanced,
|
||||
enter = expandVertically() + fadeIn(),
|
||||
exit = shrinkVertically() + fadeOut(),
|
||||
) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
OutlinedTextField(state.remoteId, { onChange(state.copy(remoteId = it)) },
|
||||
label = { Text(stringResource(R.string.profile_remote_id_label)) },
|
||||
supportingText = { Text(stringResource(R.string.profile_remote_id_hint)) },
|
||||
enabled = enabled, singleLine = true, modifier = Modifier.fillMaxWidth())
|
||||
OutlinedTextField(state.localId, { onChange(state.copy(localId = it)) },
|
||||
label = { Text(stringResource(R.string.profile_local_id_label)) },
|
||||
supportingText = { Text(stringResource(R.string.profile_local_id_hint_user)) },
|
||||
enabled = enabled, singleLine = true, modifier = Modifier.fillMaxWidth())
|
||||
OutlinedTextField(state.dns, { onChange(state.copy(dns = it)) },
|
||||
label = { Text(stringResource(R.string.profile_dns_servers_label)) },
|
||||
supportingText = { Text(stringResource(R.string.profile_dns_servers_hint)) },
|
||||
enabled = enabled, singleLine = true, modifier = Modifier.fillMaxWidth())
|
||||
OutlinedTextField(state.mtu, { onChange(state.copy(mtu = it)) },
|
||||
label = { Text(stringResource(R.string.profile_mtu_label)) },
|
||||
enabled = enabled, singleLine = true, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
modifier = Modifier.fillMaxWidth())
|
||||
OutlinedTextField(state.port, { onChange(state.copy(port = it)) },
|
||||
label = { Text(stringResource(R.string.profile_port_label)) },
|
||||
enabled = enabled, singleLine = true, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
modifier = Modifier.fillMaxWidth())
|
||||
OutlinedTextField(state.natKeepalive, { onChange(state.copy(natKeepalive = it)) },
|
||||
label = { Text(stringResource(R.string.profile_nat_keepalive_label)) },
|
||||
enabled = enabled, singleLine = true, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
modifier = Modifier.fillMaxWidth())
|
||||
SwitchRow(stringResource(R.string.profile_cert_req_label), state.certReq, enabled) { onChange(state.copy(certReq = it)) }
|
||||
SwitchRow(stringResource(R.string.profile_use_ocsp_label), state.useOcsp, enabled) { onChange(state.copy(useOcsp = it)) }
|
||||
SwitchRow(stringResource(R.string.profile_use_crl_label), state.useCrl, enabled) { onChange(state.copy(useCrl = it)) }
|
||||
SwitchRow(stringResource(R.string.profile_strict_revocation_label), state.strictRevocation, enabled) { onChange(state.copy(strictRevocation = it)) }
|
||||
SwitchRow(stringResource(R.string.profile_rsa_pss_label), state.rsaPss, enabled) { onChange(state.copy(rsaPss = it)) }
|
||||
SwitchRow(stringResource(R.string.profile_ipv6_transport_label), state.ipv6Transport, enabled) { onChange(state.copy(ipv6Transport = it)) }
|
||||
OutlinedTextField(state.includedSubnets, { onChange(state.copy(includedSubnets = it)) },
|
||||
label = { Text(stringResource(R.string.profile_included_subnets_label)) },
|
||||
enabled = enabled, modifier = Modifier.fillMaxWidth())
|
||||
OutlinedTextField(state.excludedSubnets, { onChange(state.copy(excludedSubnets = it)) },
|
||||
label = { Text(stringResource(R.string.profile_excluded_subnets_label)) },
|
||||
enabled = enabled, modifier = Modifier.fillMaxWidth())
|
||||
SwitchRow(
|
||||
label = stringResource(R.string.profile_split_tunnelingv4_title),
|
||||
checked = state.blockIpv4,
|
||||
enabled = enabled,
|
||||
) { onChange(state.copy(blockIpv4 = it)) }
|
||||
SwitchRow(
|
||||
label = stringResource(R.string.profile_split_tunnelingv6_title),
|
||||
supporting = stringResource(R.string.profile_split_tunnelingv6_hint),
|
||||
checked = state.blockIpv6,
|
||||
enabled = enabled,
|
||||
) { onChange(state.copy(blockIpv6 = it)) }
|
||||
ExposedDropdownMenuBox(expanded = appsExpanded, onExpandedChange = { if (enabled) appsExpanded = it }) {
|
||||
OutlinedTextField(
|
||||
value = appsHandling.getOrElse(state.appsHandling.ordinal) { "" },
|
||||
onValueChange = {},
|
||||
readOnly = true,
|
||||
enabled = enabled,
|
||||
label = { Text(stringResource(R.string.profile_select_apps)) },
|
||||
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(appsExpanded) },
|
||||
modifier = Modifier.fillMaxWidth().menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable),
|
||||
)
|
||||
ExposedDropdownMenu(expanded = appsExpanded, onDismissRequest = { appsExpanded = false }) {
|
||||
SelectedAppsHandling.values().forEachIndexed { index, handling ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(appsHandling.getOrElse(index) { handling.name }) },
|
||||
onClick = {
|
||||
onChange(state.copy(appsHandling = handling))
|
||||
appsExpanded = false
|
||||
},
|
||||
)
|
||||
Column(verticalArrangement = Arrangement.spacedBy(12.dp))
|
||||
{
|
||||
ExposedDropdownMenuBox(expanded = typeExpanded, onExpandedChange = { if (enabled) typeExpanded = it })
|
||||
{
|
||||
FormPickRow(
|
||||
title = types.getOrElse(state.vpnType.ordinal) { "" },
|
||||
subtitle = stringResource(R.string.profile_vpn_type_label).trimEnd(':'),
|
||||
enabled = enabled,
|
||||
modifier = Modifier.menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable),
|
||||
) { typeExpanded = true }
|
||||
ExposedDropdownMenu(expanded = typeExpanded, onDismissRequest = { typeExpanded = false })
|
||||
{
|
||||
VpnType.values().forEachIndexed { index, type ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(types.getOrElse(index) { type.identifier }, style = ShxType.body) },
|
||||
onClick = {
|
||||
onChange(state.copy(vpnType = type))
|
||||
typeExpanded = false
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (state.appsHandling != SelectedAppsHandling.SELECTED_APPS_DISABLE)
|
||||
{
|
||||
TextButton(onClick = {
|
||||
val intent = Intent(context, SelectedApplicationsActivity::class.java)
|
||||
intent.putStringArrayListExtra(VpnProfileDataSource.KEY_SELECTED_APPS_LIST, ArrayList(state.selectedApps))
|
||||
pickApps.launch(intent)
|
||||
}, enabled = enabled) {
|
||||
Text(stringResource(R.string.profile_select_apps) + " (${state.selectedApps.size})")
|
||||
ShxFormField(stringResource(R.string.profile_remote_id_label).trimEnd(':'), state.remoteId, { onChange(state.copy(remoteId = it)) },
|
||||
supporting = stringResource(R.string.profile_remote_id_hint), enabled = enabled)
|
||||
ShxFormField(stringResource(R.string.profile_local_id_label).trimEnd(':'), state.localId, { onChange(state.copy(localId = it)) },
|
||||
supporting = stringResource(R.string.profile_local_id_hint_user), enabled = enabled)
|
||||
ShxFormField(stringResource(R.string.profile_dns_servers_label).trimEnd(':'), state.dns, { onChange(state.copy(dns = it)) },
|
||||
supporting = stringResource(R.string.profile_dns_servers_hint), enabled = enabled, mono = true)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp))
|
||||
{
|
||||
ShxFormField(stringResource(R.string.profile_port_label).trimEnd(':'), state.port, { onChange(state.copy(port = it)) },
|
||||
enabled = enabled, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), modifier = Modifier.weight(1f))
|
||||
ShxFormField(stringResource(R.string.profile_mtu_label).trimEnd(':'), state.mtu, { onChange(state.copy(mtu = it)) },
|
||||
enabled = enabled, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), modifier = Modifier.weight(1f))
|
||||
}
|
||||
ShxFormField(stringResource(R.string.profile_nat_keepalive_label).trimEnd(':'), state.natKeepalive, { onChange(state.copy(natKeepalive = it)) },
|
||||
enabled = enabled, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number))
|
||||
ShxPanel()
|
||||
{
|
||||
ShxToggleRow(stringResource(R.string.profile_cert_req_label), state.certReq, { onChange(state.copy(certReq = it)) }, enabled = enabled, divider = true)
|
||||
ShxToggleRow(stringResource(R.string.profile_use_ocsp_label), state.useOcsp, { onChange(state.copy(useOcsp = it)) }, enabled = enabled, divider = true)
|
||||
ShxToggleRow(stringResource(R.string.profile_use_crl_label), state.useCrl, { onChange(state.copy(useCrl = it)) }, enabled = enabled, divider = true)
|
||||
ShxToggleRow(stringResource(R.string.profile_strict_revocation_label), state.strictRevocation, { onChange(state.copy(strictRevocation = it)) }, enabled = enabled, divider = true)
|
||||
ShxToggleRow(stringResource(R.string.profile_rsa_pss_label), state.rsaPss, { onChange(state.copy(rsaPss = it)) }, enabled = enabled, divider = true)
|
||||
ShxToggleRow(stringResource(R.string.profile_ipv6_transport_label), state.ipv6Transport, { onChange(state.copy(ipv6Transport = it)) }, enabled = enabled, divider = true)
|
||||
ShxToggleRow(stringResource(R.string.profile_split_tunnelingv4_title), state.blockIpv4, { onChange(state.copy(blockIpv4 = it)) }, enabled = enabled, divider = true)
|
||||
ShxToggleRow(stringResource(R.string.profile_split_tunnelingv6_title), state.blockIpv6, { onChange(state.copy(blockIpv6 = it)) },
|
||||
subtitle = stringResource(R.string.profile_split_tunnelingv6_hint), enabled = enabled)
|
||||
}
|
||||
ShxFormField(stringResource(R.string.profile_included_subnets_label).trimEnd(':'), state.includedSubnets, { onChange(state.copy(includedSubnets = it)) },
|
||||
error = state.includedSubnetsError, enabled = enabled, mono = true, singleLine = false)
|
||||
ShxFormField(stringResource(R.string.profile_excluded_subnets_label).trimEnd(':'), state.excludedSubnets, { onChange(state.copy(excludedSubnets = it)) },
|
||||
error = state.excludedSubnetsError, enabled = enabled, mono = true, singleLine = false)
|
||||
ShxPanel()
|
||||
{
|
||||
ShxRow(
|
||||
title = stringResource(R.string.profile_routing_link_title),
|
||||
subtitle = stringResource(R.string.profile_routing_link_summary),
|
||||
icon = ShxIcons.Route,
|
||||
chevron = true,
|
||||
onClick = { context.startActivity(Intent(context, MainActivity::class.java).putExtra(MainActivity.EXTRA_OPEN_ROUTING, true)) },
|
||||
)
|
||||
}
|
||||
ShxFormField(stringResource(R.string.profile_proposals_ike_label).trimEnd(':'), state.ikeProposal, { onChange(state.copy(ikeProposal = it)) },
|
||||
error = state.ikeProposalError, enabled = enabled, mono = true)
|
||||
ShxFormField(stringResource(R.string.profile_proposals_esp_label).trimEnd(':'), state.espProposal, { onChange(state.copy(espProposal = it)) },
|
||||
error = state.espProposalError, enabled = enabled, mono = true)
|
||||
ShxFormField(stringResource(R.string.profile_proxy_host_label).trimEnd(':'), state.proxyHost, { onChange(state.copy(proxyHost = it)) }, enabled = enabled)
|
||||
ShxFormField(stringResource(R.string.profile_proxy_port_label).trimEnd(':'), state.proxyPort, { onChange(state.copy(proxyPort = it)) },
|
||||
enabled = enabled, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number))
|
||||
ShxFormField(stringResource(R.string.profile_proxy_exclusions_label).trimEnd(':'), state.proxyExclusions, { onChange(state.copy(proxyExclusions = it)) }, enabled = enabled)
|
||||
}
|
||||
OutlinedTextField(state.ikeProposal, { onChange(state.copy(ikeProposal = it)) },
|
||||
label = { Text(stringResource(R.string.profile_proposals_ike_label)) },
|
||||
enabled = enabled, modifier = Modifier.fillMaxWidth())
|
||||
OutlinedTextField(state.espProposal, { onChange(state.copy(espProposal = it)) },
|
||||
label = { Text(stringResource(R.string.profile_proposals_esp_label)) },
|
||||
enabled = enabled, modifier = Modifier.fillMaxWidth())
|
||||
OutlinedTextField(state.proxyHost, { onChange(state.copy(proxyHost = it)) },
|
||||
label = { Text(stringResource(R.string.profile_proxy_host_label)) },
|
||||
enabled = enabled, modifier = Modifier.fillMaxWidth())
|
||||
OutlinedTextField(state.proxyPort, { onChange(state.copy(proxyPort = it)) },
|
||||
label = { Text(stringResource(R.string.profile_proxy_port_label)) },
|
||||
enabled = enabled, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
modifier = Modifier.fillMaxWidth())
|
||||
OutlinedTextField(state.proxyExclusions, { onChange(state.copy(proxyExclusions = it)) },
|
||||
label = { Text(stringResource(R.string.profile_proxy_exclusions_label)) },
|
||||
enabled = enabled, modifier = Modifier.fillMaxWidth())
|
||||
}
|
||||
}
|
||||
if (enabled)
|
||||
{
|
||||
ShxButton(stringResource(R.string.profile_edit_save), onSave, modifier = Modifier.fillMaxWidth().padding(top = 8.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
org.strongswan.android.ui.compose.components.ShxSnackbarHost(snackbarHostState, Modifier.align(Alignment.BottomCenter))
|
||||
}
|
||||
}
|
||||
|
||||
/** White row that opens a picker: 12 label on top, 15/700 value, chevron. */
|
||||
@Composable
|
||||
private fun FormSection(title: String, content: @Composable () -> Unit)
|
||||
{
|
||||
Surface(
|
||||
shape = MaterialTheme.shapes.large,
|
||||
color = MaterialTheme.colorScheme.surfaceContainerLow,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp))
|
||||
{
|
||||
Text(
|
||||
text = title.uppercase(),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SwitchRow(
|
||||
label: String,
|
||||
checked: Boolean,
|
||||
private fun FormPickRow(
|
||||
title: String,
|
||||
subtitle: String,
|
||||
enabled: Boolean,
|
||||
supporting: String? = null,
|
||||
onChange: (Boolean) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
bordered: Boolean = true,
|
||||
onClick: () -> Unit,
|
||||
)
|
||||
{
|
||||
ListItem(
|
||||
headlineContent = { Text(label) },
|
||||
supportingContent = supporting?.let { { Text(it) } },
|
||||
trailingContent = {
|
||||
Switch(checked = checked, onCheckedChange = onChange, enabled = enabled)
|
||||
},
|
||||
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
val c = LocalShxColors.current
|
||||
val shape = RoundedCornerShape(16.dp)
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.then(if (bordered) Modifier.clip(shape).background(c.card).border(1.dp, c.cardBorder, shape) else Modifier)
|
||||
.clickable(enabled = enabled, role = Role.Button, onClick = onClick)
|
||||
.padding(horizontal = 14.dp, vertical = 10.dp),
|
||||
)
|
||||
{
|
||||
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp))
|
||||
{
|
||||
Text(subtitle, style = ShxType.meta.copy(fontWeight = FontWeight.Bold), color = c.textSub)
|
||||
Text(title, style = ShxType.body.copy(fontSize = 16.sp), color = if (enabled) c.text else c.textSub, maxLines = 1, overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis)
|
||||
}
|
||||
Icon(ShxIcons.Chevron, contentDescription = null, tint = c.iconMuted, modifier = Modifier.size(18.dp))
|
||||
}
|
||||
}
|
||||
-141
@@ -1,141 +0,0 @@
|
||||
/*
|
||||
* Copyright (C) 2026 SHX VPN
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or modify it
|
||||
* under the terms of the GNU General Public License as published by the
|
||||
* Free Software Foundation; either version 2 of the License, or (at your
|
||||
* option) any later version.
|
||||
*/
|
||||
|
||||
package org.strongswan.android.ui.compose
|
||||
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.rounded.Language
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import org.strongswan.android.R
|
||||
import org.strongswan.android.data.VpnProfile
|
||||
import org.strongswan.android.ui.compose.theme.LocalShxStatusColors
|
||||
|
||||
@Composable
|
||||
fun ProfileAvatar(icon: ImageVector, connected: Boolean, modifier: Modifier = Modifier)
|
||||
{
|
||||
val status = LocalShxStatusColors.current
|
||||
val container = if (connected) status.connectedContainer else MaterialTheme.colorScheme.primaryContainer
|
||||
val tint = if (connected) status.connected else MaterialTheme.colorScheme.primary
|
||||
Box(
|
||||
modifier = modifier
|
||||
.size(40.dp)
|
||||
.background(container, CircleShape),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
tint = tint,
|
||||
modifier = Modifier.size(22.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun ShxProfileRow(
|
||||
profile: VpnProfile,
|
||||
active: Boolean,
|
||||
leadingIcon: ImageVector = Icons.Rounded.Language,
|
||||
onClick: () -> Unit,
|
||||
onLongClick: () -> Unit = {},
|
||||
trailing: (@Composable () -> Unit)? = null,
|
||||
)
|
||||
{
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.combinedClickable(onClick = onClick, onLongClick = onLongClick)
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
) {
|
||||
ProfileAvatar(icon = leadingIcon, connected = active)
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(horizontal = 12.dp),
|
||||
) {
|
||||
Text(
|
||||
text = profile.name ?: "",
|
||||
style = MaterialTheme.typography.titleSmall.copy(fontSize = 16.sp),
|
||||
fontWeight = FontWeight.Medium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
text = profile.gateway ?: "",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
if (trailing != null)
|
||||
{
|
||||
trailing()
|
||||
}
|
||||
else if (active)
|
||||
{
|
||||
ActiveBadge()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ActiveBadge(modifier: Modifier = Modifier)
|
||||
{
|
||||
val status = LocalShxStatusColors.current
|
||||
Surface(
|
||||
color = status.connectedContainer,
|
||||
contentColor = status.connected,
|
||||
shape = CircleShape,
|
||||
modifier = modifier,
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.badge_connected),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ShxDivider(modifier: Modifier = Modifier, indent: Boolean = true)
|
||||
{
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.6f),
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = if (indent) 68.dp else 0.dp, end = if (indent) 16.dp else 0.dp)
|
||||
.height(1.dp),
|
||||
) {}
|
||||
}
|
||||
+360
-196
@@ -9,57 +9,103 @@
|
||||
|
||||
package org.strongswan.android.ui.compose
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import org.strongswan.android.ui.compose.components.ShxSnackbarHost
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.rounded.Add
|
||||
import androidx.compose.material.icons.rounded.Close
|
||||
import androidx.compose.material.icons.rounded.Delete
|
||||
import androidx.compose.material.icons.rounded.Edit
|
||||
import androidx.compose.material.icons.rounded.Language
|
||||
import androidx.compose.material.icons.rounded.MoreVert
|
||||
import androidx.compose.material.icons.rounded.PlayArrow
|
||||
import androidx.compose.material.icons.rounded.Search
|
||||
import androidx.compose.material.icons.rounded.Stop
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SearchBar
|
||||
import androidx.compose.material3.SearchBarDefaults
|
||||
import androidx.compose.material3.SnackbarDuration
|
||||
import androidx.compose.material3.SwipeToDismissBox
|
||||
import androidx.compose.material3.SwipeToDismissBoxValue
|
||||
import androidx.compose.material3.rememberSwipeToDismissBoxState
|
||||
import androidx.compose.foundation.background
|
||||
import org.strongswan.android.ui.compose.theme.ShxSpacing
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.SnackbarResult
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.launch
|
||||
import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi
|
||||
import androidx.compose.material3.adaptive.layout.AnimatedPane
|
||||
import androidx.compose.material3.adaptive.layout.ListDetailPaneScaffold
|
||||
import androidx.compose.material3.adaptive.layout.ListDetailPaneScaffoldRole
|
||||
import androidx.compose.material3.adaptive.navigation.rememberListDetailPaneScaffoldNavigator
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import org.strongswan.android.R
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.statusBarsPadding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.repeatOnLifecycle
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.delay
|
||||
import org.strongswan.android.logic.connectivity.ConnectivityProbe
|
||||
import org.strongswan.android.ui.compose.design.ShxButton
|
||||
import org.strongswan.android.ui.compose.design.ShxButtonStyle
|
||||
import org.strongswan.android.ui.compose.design.ShxDialog
|
||||
import org.strongswan.android.ui.compose.design.ShxIcons
|
||||
import org.strongswan.android.ui.compose.design.ShxPanel
|
||||
import org.strongswan.android.ui.compose.design.ShxRow
|
||||
import org.strongswan.android.ui.compose.design.ShxScreenTitle
|
||||
import org.strongswan.android.ui.compose.design.ShxSectionHeader
|
||||
import org.strongswan.android.ui.compose.design.ShxSheet
|
||||
import org.strongswan.android.ui.compose.design.ShxSheetTitle
|
||||
import org.strongswan.android.ui.compose.theme.LocalShxColors
|
||||
import org.strongswan.android.ui.compose.theme.ShxType
|
||||
import org.strongswan.android.data.VpnProfile
|
||||
import org.strongswan.android.logic.VpnStateService.State
|
||||
import org.strongswan.android.ui.compose.components.ShxCard
|
||||
import org.strongswan.android.ui.compose.components.ShxEmptyState
|
||||
import org.strongswan.android.ui.compose.theme.LocalShxStatusColors
|
||||
import org.strongswan.android.ui.compose.theme.ScreenPadding
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
/**
|
||||
* Profiles tab. On phones it is just the list (the form opens as its own screen);
|
||||
* on expanded widths it becomes a list-detail layout with the profile form in the detail pane.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
|
||||
@Composable
|
||||
fun ProfilesScreen(
|
||||
state: HomeUiState,
|
||||
@@ -67,203 +113,321 @@ fun ProfilesScreen(
|
||||
onDisconnect: () -> Unit,
|
||||
onEdit: (VpnProfile) -> Unit,
|
||||
onDelete: (VpnProfile) -> Unit,
|
||||
onRestore: (VpnProfile) -> Unit,
|
||||
onShare: (VpnProfile) -> Unit,
|
||||
onAdd: () -> Unit,
|
||||
onFastest: () -> Unit = {},
|
||||
)
|
||||
{
|
||||
var query by remember { mutableStateOf("") }
|
||||
if (LocalConfiguration.current.screenWidthDp < 840)
|
||||
{
|
||||
ProfilesList(state, onConnect, onDisconnect, onEdit, onDelete, onRestore, onShare, onAdd, onFastest)
|
||||
return
|
||||
}
|
||||
/* null = nothing open, "" = new profile, otherwise the edited profile's uuid */
|
||||
var editing by rememberSaveable { mutableStateOf<String?>(null) }
|
||||
val navigator = rememberListDetailPaneScaffoldNavigator<Nothing>()
|
||||
LaunchedEffect(editing)
|
||||
{
|
||||
navigator.navigateTo(if (editing != null) ListDetailPaneScaffoldRole.Detail else ListDetailPaneScaffoldRole.List)
|
||||
}
|
||||
ListDetailPaneScaffold(
|
||||
directive = navigator.scaffoldDirective,
|
||||
value = navigator.scaffoldValue,
|
||||
listPane = {
|
||||
AnimatedPane {
|
||||
ProfilesList(
|
||||
state = state,
|
||||
onConnect = onConnect,
|
||||
onDisconnect = onDisconnect,
|
||||
onEdit = { editing = it.getUUID().toString() },
|
||||
onDelete = onDelete,
|
||||
onRestore = onRestore,
|
||||
onShare = onShare,
|
||||
onAdd = { editing = "" },
|
||||
)
|
||||
}
|
||||
},
|
||||
detailPane = {
|
||||
AnimatedPane {
|
||||
val target = editing
|
||||
if (target != null)
|
||||
{
|
||||
val profile = state.profiles.firstOrNull { it.getUUID().toString() == target }
|
||||
ProfileEditorPane(profile = profile, onClose = { editing = null })
|
||||
}
|
||||
else
|
||||
{
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center)
|
||||
{
|
||||
ShxEmptyState(icon = ShxIcons.Globe, message = stringResource(R.string.profile_detail_placeholder))
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/** Server status line: green "connected · 38 ms", green "available · 29 ms" or amber "no answer". */
|
||||
private data class ServerPing(val rttMs: Long?)
|
||||
|
||||
@Composable
|
||||
private fun ProfilesList(
|
||||
state: HomeUiState,
|
||||
onConnect: (VpnProfile) -> Unit,
|
||||
onDisconnect: () -> Unit,
|
||||
onEdit: (VpnProfile) -> Unit,
|
||||
onDelete: (VpnProfile) -> Unit,
|
||||
onRestore: (VpnProfile) -> Unit,
|
||||
onShare: (VpnProfile) -> Unit,
|
||||
onAdd: () -> Unit,
|
||||
onFastest: () -> Unit = {},
|
||||
)
|
||||
{
|
||||
val context = LocalContext.current
|
||||
val c = LocalShxColors.current
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
val scope = rememberCoroutineScope()
|
||||
var menuFor by remember { mutableStateOf<VpnProfile?>(null) }
|
||||
var pendingDelete by remember { mutableStateOf<VpnProfile?>(null) }
|
||||
var deleteFor by remember { mutableStateOf<VpnProfile?>(null) }
|
||||
val activeUuid = state.activeProfile?.getUUID()
|
||||
val connected = state.vpnState == State.CONNECTED
|
||||
val profiles = state.profiles
|
||||
val filtered = remember(query, profiles)
|
||||
{
|
||||
if (query.isBlank()) profiles
|
||||
else profiles.filter {
|
||||
(it.name ?: "").contains(query, ignoreCase = true) || (it.gateway ?: "").contains(query, ignoreCase = true)
|
||||
}
|
||||
}
|
||||
|
||||
Column(Modifier.fillMaxSize())
|
||||
{
|
||||
TopAppBar(
|
||||
title = { Text(stringResource(R.string.nav_profiles), fontWeight = FontWeight.SemiBold) },
|
||||
actions = {
|
||||
IconButton(onClick = onAdd) {
|
||||
Icon(Icons.Rounded.Add, contentDescription = stringResource(R.string.add_profile_cta))
|
||||
}
|
||||
},
|
||||
colors = androidx.compose.material3.TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.background,
|
||||
),
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = query,
|
||||
onValueChange = { query = it },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = ScreenPadding, vertical = 4.dp),
|
||||
placeholder = { Text(stringResource(R.string.search_profiles)) },
|
||||
leadingIcon = { Icon(Icons.Rounded.Search, contentDescription = null) },
|
||||
trailingIcon = {
|
||||
if (query.isNotEmpty())
|
||||
{
|
||||
IconButton(onClick = { query = "" }) {
|
||||
Icon(Icons.Rounded.Close, contentDescription = null)
|
||||
}
|
||||
}
|
||||
},
|
||||
singleLine = true,
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
)
|
||||
if (filtered.isEmpty())
|
||||
{
|
||||
EmptyProfilesState(query = query, onAdd = onAdd)
|
||||
}
|
||||
else
|
||||
{
|
||||
Surface(
|
||||
shape = MaterialTheme.shapes.large,
|
||||
color = MaterialTheme.colorScheme.surfaceContainerLow,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = ScreenPadding, vertical = 12.dp),
|
||||
) {
|
||||
LazyColumn {
|
||||
itemsIndexed(filtered, key = { _, p -> p.getUUID().toString() }) { index, profile ->
|
||||
val isActive = connected && profile.getUUID() == activeUuid
|
||||
Box {
|
||||
ShxProfileRow(
|
||||
profile = profile,
|
||||
active = isActive,
|
||||
onClick = { if (isActive) onDisconnect() else onConnect(profile) },
|
||||
onLongClick = { menuFor = profile },
|
||||
trailing = {
|
||||
IconButton(onClick = { menuFor = profile }) {
|
||||
Icon(
|
||||
Icons.Rounded.MoreVert,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
if (isActive) ActiveBadge()
|
||||
},
|
||||
)
|
||||
DropdownMenu(expanded = menuFor == profile, onDismissRequest = { menuFor = null })
|
||||
{
|
||||
if (isActive)
|
||||
{
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.disconnect)) },
|
||||
leadingIcon = { Icon(Icons.Rounded.Stop, contentDescription = null) },
|
||||
onClick = {
|
||||
menuFor = null
|
||||
onDisconnect()
|
||||
},
|
||||
)
|
||||
}
|
||||
else
|
||||
{
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.connect)) },
|
||||
leadingIcon = { Icon(Icons.Rounded.PlayArrow, contentDescription = null) },
|
||||
onClick = {
|
||||
menuFor = null
|
||||
onConnect(profile)
|
||||
},
|
||||
)
|
||||
}
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.edit_profile)) },
|
||||
leadingIcon = { Icon(Icons.Rounded.Edit, contentDescription = null) },
|
||||
onClick = {
|
||||
menuFor = null
|
||||
onEdit(profile)
|
||||
},
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = {
|
||||
Text(
|
||||
stringResource(R.string.delete_profile),
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
},
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
Icons.Rounded.Delete,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
},
|
||||
onClick = {
|
||||
menuFor = null
|
||||
pendingDelete = profile
|
||||
},
|
||||
)
|
||||
}
|
||||
/* direct pings of every server (outside the tunnel), refreshed while the tab is shown */
|
||||
val lifecycle = androidx.lifecycle.compose.LocalLifecycleOwner.current.lifecycle
|
||||
val pings by produceState(emptyMap<String, ServerPing>(), profiles, lifecycle) {
|
||||
lifecycle.repeatOnLifecycle(androidx.lifecycle.Lifecycle.State.STARTED) {
|
||||
while (true)
|
||||
{
|
||||
value = coroutineScope {
|
||||
profiles.map { p ->
|
||||
async {
|
||||
p.getUUID().toString() to ServerPing(ConnectivityProbe.pingServerDirect(context, p.gateway, 1500)?.first)
|
||||
}
|
||||
if (index < filtered.lastIndex)
|
||||
{
|
||||
ShxDivider()
|
||||
}
|
||||
}
|
||||
}.map { it.await() }.toMap()
|
||||
}
|
||||
delay(30_000)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pendingDelete?.let { profile ->
|
||||
AlertDialog(
|
||||
onDismissRequest = { pendingDelete = null },
|
||||
title = { Text(stringResource(R.string.delete_profile)) },
|
||||
text = { Text(profile.name ?: "") },
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
onDelete(profile)
|
||||
pendingDelete = null
|
||||
}) { Text(stringResource(R.string.delete_profile), color = MaterialTheme.colorScheme.error) }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { pendingDelete = null }) {
|
||||
Text(stringResource(android.R.string.cancel))
|
||||
fun deleteWithUndo(profile: VpnProfile)
|
||||
{
|
||||
scope.launch {
|
||||
onDelete(profile)
|
||||
val result = snackbarHostState.showSnackbar(
|
||||
message = context.getString(R.string.profile_deleted_snackbar),
|
||||
actionLabel = context.getString(R.string.action_undo),
|
||||
duration = SnackbarDuration.Long,
|
||||
)
|
||||
if (result == SnackbarResult.ActionPerformed)
|
||||
{
|
||||
onRestore(profile)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
deleteFor?.let { profile ->
|
||||
val current = profile.getUUID() == activeUuid && state.vpnState != State.DISABLED
|
||||
ShxDialog(
|
||||
title = stringResource(R.string.server_delete_title, profile.name ?: profile.gateway),
|
||||
paragraphs = listOfNotNull(
|
||||
if (current) stringResource(R.string.server_delete_current) else null,
|
||||
stringResource(R.string.server_delete_restore),
|
||||
),
|
||||
confirm = stringResource(R.string.server_delete_confirm),
|
||||
dismiss = stringResource(android.R.string.cancel),
|
||||
onConfirm = {
|
||||
deleteFor = null
|
||||
if (current)
|
||||
{
|
||||
onDisconnect()
|
||||
}
|
||||
deleteWithUndo(profile)
|
||||
},
|
||||
onDismiss = { deleteFor = null },
|
||||
icon = ShxIcons.Trash,
|
||||
danger = true,
|
||||
)
|
||||
}
|
||||
|
||||
Box(Modifier.fillMaxSize().background(c.background))
|
||||
{
|
||||
Column(Modifier.fillMaxSize().statusBarsPadding().verticalScroll(rememberScrollState()))
|
||||
{
|
||||
ShxScreenTitle(stringResource(R.string.nav_profiles))
|
||||
{
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier.size(44.dp).clip(CircleShape).background(c.accent)
|
||||
.clickable(role = Role.Button, onClickLabel = stringResource(R.string.add_profile_cta), onClick = onAdd),
|
||||
)
|
||||
{
|
||||
Icon(ShxIcons.Plus, contentDescription = stringResource(R.string.add_profile_cta), tint = Color.White, modifier = Modifier.size(22.dp))
|
||||
}
|
||||
}
|
||||
Column(Modifier.padding(start = 16.dp, end = 16.dp, top = 4.dp, bottom = 24.dp), verticalArrangement = Arrangement.spacedBy(12.dp))
|
||||
{
|
||||
if (profiles.isEmpty())
|
||||
{
|
||||
EmptyProfilesState(onAdd)
|
||||
return@Column
|
||||
}
|
||||
if (profiles.size > 1)
|
||||
{
|
||||
AutoServerCard(onFastest)
|
||||
}
|
||||
ShxSectionHeader(stringResource(R.string.servers_mine, profiles.size), Modifier.padding(top = 8.dp))
|
||||
ShxPanel()
|
||||
{
|
||||
profiles.forEachIndexed { index, profile ->
|
||||
val isActive = connected && profile.getUUID() == activeUuid
|
||||
ServerRow(
|
||||
profile = profile,
|
||||
active = isActive,
|
||||
ping = pings[profile.getUUID().toString()],
|
||||
onClick = { if (isActive) onDisconnect() else onConnect(profile) },
|
||||
onMore = { menuFor = profile },
|
||||
)
|
||||
if (index < profiles.lastIndex)
|
||||
{
|
||||
Box(Modifier.fillMaxWidth().height(1.dp).background(c.divider))
|
||||
}
|
||||
}
|
||||
}
|
||||
Text(stringResource(R.string.servers_note), style = ShxType.meta.copy(fontWeight = FontWeight.Normal, lineHeight = 17.sp), color = c.textSub, modifier = Modifier.padding(horizontal = 6.dp))
|
||||
}
|
||||
}
|
||||
ShxSnackbarHost(snackbarHostState, Modifier.align(Alignment.BottomCenter))
|
||||
}
|
||||
|
||||
menuFor?.let { profile ->
|
||||
val isActive = connected && profile.getUUID() == activeUuid
|
||||
ShxSheet(onDismiss = { menuFor = null })
|
||||
{
|
||||
ShxSheetTitle(profile.name ?: profile.gateway ?: "")
|
||||
Text(profile.gateway ?: "", style = ShxType.note.copy(fontSize = 14.sp), color = c.textSub, modifier = Modifier.padding(horizontal = 4.dp))
|
||||
ShxPanel()
|
||||
{
|
||||
ShxRow(
|
||||
title = stringResource(if (isActive) R.string.disconnect else R.string.connect),
|
||||
icon = if (isActive) ShxIcons.Close else ShxIcons.Power,
|
||||
divider = true, minHeight = 64.dp,
|
||||
onClick = {
|
||||
menuFor = null
|
||||
if (isActive) onDisconnect() else onConnect(profile)
|
||||
},
|
||||
)
|
||||
ShxRow(
|
||||
title = stringResource(R.string.edit_profile),
|
||||
icon = ShxIcons.Sliders, iconBackground = c.tile,
|
||||
divider = true, minHeight = 64.dp,
|
||||
onClick = {
|
||||
menuFor = null
|
||||
onEdit(profile)
|
||||
},
|
||||
)
|
||||
ShxRow(
|
||||
title = stringResource(R.string.share_profile),
|
||||
icon = ShxIcons.Download, iconBackground = c.tile,
|
||||
minHeight = 64.dp,
|
||||
onClick = {
|
||||
menuFor = null
|
||||
onShare(profile)
|
||||
},
|
||||
)
|
||||
}
|
||||
ShxButton(
|
||||
stringResource(R.string.delete_profile),
|
||||
{
|
||||
menuFor = null
|
||||
deleteFor = profile
|
||||
},
|
||||
style = ShxButtonStyle.DANGER,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
icon = ShxIcons.Trash,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** «Automatically»: the fastest server that connects (2px purple border, bolt circle). */
|
||||
@Composable
|
||||
private fun AutoServerCard(onClick: () -> Unit)
|
||||
{
|
||||
val c = LocalShxColors.current
|
||||
val shape = RoundedCornerShape(24.dp)
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(14.dp),
|
||||
modifier = Modifier.fillMaxWidth().clip(shape).background(c.accentSoft).border(2.dp, c.accent, shape)
|
||||
.clickable(role = Role.Button, onClick = onClick).padding(horizontal = 16.dp, vertical = 14.dp),
|
||||
)
|
||||
{
|
||||
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(40.dp).clip(CircleShape).background(c.accent))
|
||||
{
|
||||
Icon(ShxIcons.Bolt, contentDescription = null, tint = Color.White, modifier = Modifier.size(20.dp))
|
||||
}
|
||||
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp))
|
||||
{
|
||||
Text(stringResource(R.string.servers_auto), style = ShxType.rowTitle.copy(fontWeight = FontWeight.ExtraBold), color = c.text)
|
||||
Text(stringResource(R.string.servers_auto_sub), style = ShxType.sub.copy(fontWeight = FontWeight.SemiBold), color = c.onAccentContainer)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Server row of the mockup: 68dp, code circle, name 16/700, status dot line, "more" button. */
|
||||
@Composable
|
||||
private fun ServerRow(profile: VpnProfile, active: Boolean, ping: ServerPing?, onClick: () -> Unit, onMore: () -> Unit)
|
||||
{
|
||||
val c = LocalShxColors.current
|
||||
val code = org.strongswan.android.ui.compose.home.serverCode(profile, null)
|
||||
val (dot, meta) = when
|
||||
{
|
||||
ping == null -> c.iconMuted to stringResource(R.string.servers_checking)
|
||||
ping.rttMs == null -> c.warning to stringResource(R.string.servers_no_answer)
|
||||
active -> c.success to stringResource(R.string.servers_connected_ms, ping.rttMs)
|
||||
else -> c.success to stringResource(R.string.servers_available_ms, ping.rttMs)
|
||||
}
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
modifier = Modifier.fillMaxWidth().height(68.dp).clickable(role = Role.Button, onClick = onClick).padding(start = 14.dp, end = 8.dp),
|
||||
)
|
||||
{
|
||||
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(40.dp).clip(CircleShape).background(if (active) c.accent else c.tile))
|
||||
{
|
||||
Text(code, style = ShxType.chip.copy(fontWeight = FontWeight.ExtraBold), color = if (active) Color.White else c.onAccentContainer)
|
||||
}
|
||||
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp))
|
||||
{
|
||||
Text(profile.name ?: profile.gateway ?: "", style = ShxType.rowTitle, color = c.text, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp))
|
||||
{
|
||||
Box(Modifier.size(7.dp).clip(CircleShape).background(dot))
|
||||
Text(meta, style = ShxType.meta.copy(fontWeight = FontWeight.Normal), color = c.textSub, maxLines = 1)
|
||||
}
|
||||
}
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier.size(44.dp).clip(CircleShape).clickable(role = Role.Button, onClick = onMore),
|
||||
)
|
||||
{
|
||||
Icon(ShxIcons.More, contentDescription = stringResource(R.string.action_more_options), tint = c.textSub, modifier = Modifier.size(20.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EmptyProfilesState(query: String, onAdd: () -> Unit)
|
||||
private fun EmptyProfilesState(onAdd: () -> Unit)
|
||||
{
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(32.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
)
|
||||
ShxPanel()
|
||||
{
|
||||
Icon(
|
||||
Icons.Rounded.Language,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(48.dp),
|
||||
ShxEmptyState(
|
||||
icon = ShxIcons.Globe,
|
||||
message = stringResource(R.string.no_profiles),
|
||||
actionLabel = stringResource(R.string.add_profile_cta),
|
||||
onAction = onAdd,
|
||||
)
|
||||
Text(
|
||||
text = if (query.isBlank()) stringResource(R.string.no_profiles) else stringResource(R.string.nothing_found),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
if (query.isBlank())
|
||||
{
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Row {
|
||||
TextButton(onClick = onAdd) {
|
||||
Icon(Icons.Rounded.Add, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
Spacer(Modifier.size(8.dp))
|
||||
Text(stringResource(R.string.add_profile_cta))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+747
@@ -0,0 +1,747 @@
|
||||
/*
|
||||
* Copyright (C) 2026 SHX VPN
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or modify it
|
||||
* under the terms of the GNU General Public License as published by the
|
||||
* Free Software Foundation; either version 2 of the License, or (at your
|
||||
* option) any later version.
|
||||
*/
|
||||
|
||||
package org.strongswan.android.ui.compose
|
||||
|
||||
import android.content.Context
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.Network
|
||||
import android.net.NetworkCapabilities
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.statusBarsPadding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.text.withStyle
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.preference.PreferenceManager
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.strongswan.android.R
|
||||
import org.strongswan.android.logic.CharonVpnService
|
||||
import org.strongswan.android.logic.VpnStateService
|
||||
import org.strongswan.android.logic.routing.RouteCheck
|
||||
import org.strongswan.android.logic.routing.RoutingManager
|
||||
import org.strongswan.android.ui.compose.design.ShxButton
|
||||
import org.strongswan.android.ui.compose.design.ShxIcons
|
||||
import org.strongswan.android.ui.compose.design.ShxNote
|
||||
import org.strongswan.android.ui.compose.design.ShxPageHeader
|
||||
import org.strongswan.android.ui.compose.design.ShxPanel
|
||||
import org.strongswan.android.ui.compose.design.ShxSectionHeader
|
||||
import org.strongswan.android.ui.compose.design.ShxTabs
|
||||
import org.strongswan.android.ui.compose.design.ShxTone
|
||||
import org.strongswan.android.ui.compose.routing.AppIcon
|
||||
import org.strongswan.android.ui.compose.routing.DirectApps
|
||||
import org.strongswan.android.ui.compose.routing.InstalledApp
|
||||
import org.strongswan.android.ui.compose.routing.templateLabel
|
||||
import org.strongswan.android.ui.compose.theme.LocalShxColors
|
||||
import org.strongswan.android.ui.compose.theme.ShxType
|
||||
import java.net.InetAddress
|
||||
import java.net.InetSocketAddress
|
||||
import java.net.Socket
|
||||
|
||||
/** What the route check looks at. */
|
||||
enum class RouteCheckMode { SITE, IP, APP }
|
||||
|
||||
/** TCP 443 reachability over one network: rtt in ms, or null if it did not open. */
|
||||
data class Reach(val viaDefault: Long?, val direct: Long?, val vpn: Long?)
|
||||
|
||||
private sealed interface CheckResult
|
||||
{
|
||||
data class Site(val report: RouteCheck.Report, val verdicts: Map<String, RouteCheck.Verdict>, val reach: Reach) : CheckResult
|
||||
data class Ip(val ip: String, val verdict: RouteCheck.Verdict, val reach: Reach) : CheckResult
|
||||
data class App(val app: InstalledApp) : CheckResult
|
||||
data object BadInput : CheckResult
|
||||
}
|
||||
|
||||
private const val PREF_RECENT = "route_check_recent"
|
||||
|
||||
/** «How traffic goes» (F_Check): route of a site, an address or an app, why, and a one-tap change. */
|
||||
@Composable
|
||||
fun RouteCheckScreen(onBack: () -> Unit, initialMode: RouteCheckMode = RouteCheckMode.SITE, initialQuery: String = "")
|
||||
{
|
||||
val context = LocalContext.current
|
||||
val c = LocalShxColors.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val manager = remember { RoutingManager.getInstance(context) }
|
||||
val prefs = remember { PreferenceManager.getDefaultSharedPreferences(context) }
|
||||
var mode by rememberSaveable { mutableStateOf(initialMode) }
|
||||
var query by rememberSaveable { mutableStateOf(initialQuery) }
|
||||
var checking by remember { mutableStateOf(false) }
|
||||
var result by remember { mutableStateOf<CheckResult?>(null) }
|
||||
var recent by remember { mutableStateOf(prefs.getString(PREF_RECENT, "")!!.split('\n').filter { it.isNotBlank() }) }
|
||||
val rulesActive = remember { CharonVpnService.isUserspaceRoutingEnabled(context) }
|
||||
val apps by produceState<List<InstalledApp>>(emptyList(), mode) {
|
||||
if (mode == RouteCheckMode.APP && value.isEmpty())
|
||||
{
|
||||
value = withContext(Dispatchers.IO) { DirectApps.loadInstalledApps(context.packageManager, context.packageName) }
|
||||
}
|
||||
}
|
||||
|
||||
fun rememberQuery(q: String)
|
||||
{
|
||||
recent = (listOf(q) + recent.filter { it != q }).take(4)
|
||||
prefs.edit().putString(PREF_RECENT, recent.joinToString("\n")).apply()
|
||||
}
|
||||
|
||||
fun run()
|
||||
{
|
||||
val input = query.trim()
|
||||
if (input.isEmpty())
|
||||
{
|
||||
return
|
||||
}
|
||||
checking = true
|
||||
scope.launch {
|
||||
result = when (mode)
|
||||
{
|
||||
RouteCheckMode.SITE -> checkSite(context, manager, input, rulesActive)
|
||||
RouteCheckMode.IP -> checkIp(context, manager, input, rulesActive)
|
||||
RouteCheckMode.APP -> apps.firstOrNull { it.packageName == input || it.label.equals(input, ignoreCase = true) }
|
||||
?.let { CheckResult.App(it) } ?: CheckResult.BadInput
|
||||
}
|
||||
if (result !is CheckResult.BadInput)
|
||||
{
|
||||
rememberQuery(if (result is CheckResult.App) (result as CheckResult.App).app.label else input)
|
||||
}
|
||||
checking = false
|
||||
}
|
||||
}
|
||||
|
||||
fun rerun(wait: Long)
|
||||
{
|
||||
scope.launch {
|
||||
delay(wait)
|
||||
run()
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit)
|
||||
{
|
||||
if (initialQuery.isNotBlank())
|
||||
{
|
||||
if (mode == RouteCheckMode.APP)
|
||||
{
|
||||
delay(300)
|
||||
}
|
||||
run()
|
||||
}
|
||||
}
|
||||
|
||||
Column(Modifier.fillMaxSize().statusBarsPadding().verticalScroll(rememberScrollState()))
|
||||
{
|
||||
ShxPageHeader(stringResource(R.string.route_check_title), onBack)
|
||||
Column(Modifier.padding(start = 16.dp, end = 16.dp, top = 4.dp, bottom = 24.dp), verticalArrangement = Arrangement.spacedBy(12.dp))
|
||||
{
|
||||
ShxTabs(
|
||||
options = listOf(
|
||||
stringResource(R.string.route_check_mode_site),
|
||||
stringResource(R.string.route_check_mode_ip),
|
||||
stringResource(R.string.route_check_mode_app),
|
||||
),
|
||||
selected = mode.ordinal,
|
||||
onSelect = {
|
||||
val m = RouteCheckMode.entries[it]
|
||||
if (m != mode)
|
||||
{
|
||||
mode = m
|
||||
query = ""
|
||||
result = null
|
||||
}
|
||||
},
|
||||
)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically)
|
||||
{
|
||||
val shape = RoundedCornerShape(16.dp)
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier.weight(1f).height(52.dp).clip(shape).background(c.card).border(2.dp, c.accent, shape).padding(horizontal = 14.dp),
|
||||
)
|
||||
{
|
||||
Icon(
|
||||
when (mode)
|
||||
{
|
||||
RouteCheckMode.SITE -> ShxIcons.Globe
|
||||
RouteCheckMode.IP -> ShxIcons.Route
|
||||
RouteCheckMode.APP -> ShxIcons.Apps
|
||||
},
|
||||
contentDescription = null, tint = c.textSub, modifier = Modifier.size(18.dp),
|
||||
)
|
||||
Box(Modifier.weight(1f))
|
||||
{
|
||||
if (query.isEmpty())
|
||||
{
|
||||
Text(
|
||||
stringResource(
|
||||
when (mode)
|
||||
{
|
||||
RouteCheckMode.SITE -> R.string.route_check_field
|
||||
RouteCheckMode.IP -> R.string.route_check_field_ip
|
||||
RouteCheckMode.APP -> R.string.route_check_field_app
|
||||
}
|
||||
),
|
||||
style = ShxType.body.copy(fontSize = 16.sp),
|
||||
color = c.textSub,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
BasicTextField(
|
||||
value = query,
|
||||
onValueChange = {
|
||||
query = it
|
||||
if (mode == RouteCheckMode.APP)
|
||||
{
|
||||
result = null
|
||||
}
|
||||
},
|
||||
singleLine = true,
|
||||
textStyle = ShxType.body.copy(fontSize = 16.sp, fontWeight = FontWeight.Bold, color = c.text),
|
||||
cursorBrush = SolidColor(c.accent),
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = when (mode)
|
||||
{
|
||||
RouteCheckMode.SITE -> KeyboardType.Uri
|
||||
RouteCheckMode.IP -> KeyboardType.Ascii
|
||||
RouteCheckMode.APP -> KeyboardType.Text
|
||||
},
|
||||
imeAction = ImeAction.Search,
|
||||
),
|
||||
keyboardActions = KeyboardActions(onSearch = { run() }),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
}
|
||||
if (mode != RouteCheckMode.APP)
|
||||
{
|
||||
ShxButton(
|
||||
stringResource(if (checking) R.string.route_check_checking else R.string.route_check_button),
|
||||
::run, height = 52.dp, enabled = query.isNotBlank() && !checking,
|
||||
)
|
||||
}
|
||||
}
|
||||
if (recent.isNotEmpty() && result == null && query.isEmpty())
|
||||
{
|
||||
FlowRow(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalArrangement = Arrangement.spacedBy(6.dp))
|
||||
{
|
||||
recent.forEach { item ->
|
||||
val shape = RoundedCornerShape(10.dp)
|
||||
Text(
|
||||
item,
|
||||
style = ShxType.meta,
|
||||
color = c.textSoft,
|
||||
modifier = Modifier
|
||||
.clip(shape)
|
||||
.border(1.dp, c.outline, shape)
|
||||
.clickable(role = Role.Button) {
|
||||
query = item
|
||||
run()
|
||||
}
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (mode == RouteCheckMode.APP && result == null && query.isNotBlank())
|
||||
{
|
||||
AppSuggestions(apps, query) { app ->
|
||||
query = app.label
|
||||
result = CheckResult.App(app)
|
||||
rememberQuery(app.label)
|
||||
}
|
||||
}
|
||||
when (val r = result)
|
||||
{
|
||||
is CheckResult.Site -> SiteResult(r, rulesActive, onAlways = { direct ->
|
||||
manager.addSiteRule(r.report.domain, direct)
|
||||
rerun(1500)
|
||||
})
|
||||
is CheckResult.Ip -> IpResult(r, rulesActive, onAlways = { direct ->
|
||||
if (direct) manager.addDirectPrefix(r.ip) else manager.addForcePrefix(r.ip)
|
||||
rerun(300)
|
||||
})
|
||||
is CheckResult.App -> AppResult(r.app, manager)
|
||||
CheckResult.BadInput -> ShxNote(
|
||||
stringResource(
|
||||
when (mode)
|
||||
{
|
||||
RouteCheckMode.IP -> R.string.route_check_bad_ip
|
||||
RouteCheckMode.APP -> R.string.route_check_bad_app
|
||||
RouteCheckMode.SITE -> R.string.route_check_resolve_failed
|
||||
}
|
||||
),
|
||||
tone = ShxTone.ERROR,
|
||||
)
|
||||
null -> if (query.isEmpty()) Text(stringResource(R.string.route_check_hint), style = ShxType.note, color = c.textSub, modifier = Modifier.padding(horizontal = 6.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------ checks ------------------------------ */
|
||||
|
||||
private fun networks(context: Context): Pair<Network?, Network?>
|
||||
{
|
||||
val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager ?: return null to null
|
||||
var vpn: Network? = null
|
||||
var direct: Network? = null
|
||||
for (n in cm.allNetworks)
|
||||
{
|
||||
val caps = runCatching { cm.getNetworkCapabilities(n) }.getOrNull() ?: continue
|
||||
if (caps.hasTransport(NetworkCapabilities.TRANSPORT_VPN))
|
||||
{
|
||||
vpn = n
|
||||
}
|
||||
else if (caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) && direct == null)
|
||||
{
|
||||
direct = n
|
||||
}
|
||||
}
|
||||
return vpn to direct
|
||||
}
|
||||
|
||||
/** TCP connect to ip:443; through [network] when given, else the app's default route. */
|
||||
private fun connectMs(ip: String, network: Network?): Long? = try
|
||||
{
|
||||
val socket = network?.socketFactory?.createSocket() ?: Socket()
|
||||
socket.use {
|
||||
val start = System.nanoTime()
|
||||
it.connect(InetSocketAddress(InetAddress.getByName(ip), 443), 3000)
|
||||
(System.nanoTime() - start) / 1_000_000
|
||||
}
|
||||
}
|
||||
catch (_: Exception)
|
||||
{
|
||||
null
|
||||
}
|
||||
|
||||
private suspend fun reach(context: Context, ip: String?): Reach = coroutineScope {
|
||||
if (ip == null)
|
||||
{
|
||||
return@coroutineScope Reach(null, null, null)
|
||||
}
|
||||
val (vpn, direct) = networks(context)
|
||||
val d = async(Dispatchers.IO) { connectMs(ip, null) }
|
||||
val dir = async(Dispatchers.IO) { direct?.let { connectMs(ip, it) } }
|
||||
val v = async(Dispatchers.IO) { vpn?.let { connectMs(ip, it) } }
|
||||
Reach(d.await(), dir.await(), v.await())
|
||||
}
|
||||
|
||||
private suspend fun checkSite(context: Context, manager: RoutingManager, input: String, rulesActive: Boolean): CheckResult
|
||||
{
|
||||
val domain = RoutingManager.normalizeDomain(input) ?: return CheckResult.BadInput
|
||||
val report = RouteCheck.check(
|
||||
context, domain,
|
||||
CharonVpnService.sAppliedGeoExclusionsV4,
|
||||
CharonVpnService.sAppliedGeoExclusionsV6,
|
||||
CharonVpnService.sDnsPunchIps,
|
||||
)
|
||||
val force = RouteCheck.ranges(manager.forceTunnelCidrs())
|
||||
val direct = RouteCheck.ranges(manager.directCidrs())
|
||||
val verdicts = withContext(Dispatchers.Default) {
|
||||
report.routes.associate { route ->
|
||||
route.ip to RouteCheck.verdict(
|
||||
InetAddress.getByName(route.ip),
|
||||
CharonVpnService.sAppliedGeoExclusionsV4, CharonVpnService.sAppliedGeoExclusionsV6,
|
||||
CharonVpnService.sDnsPunchIps, force, direct, rulesActive,
|
||||
)
|
||||
}
|
||||
}
|
||||
return CheckResult.Site(report, verdicts, reach(context, report.vpnDnsIps.firstOrNull() ?: report.routes.firstOrNull()?.ip))
|
||||
}
|
||||
|
||||
private suspend fun checkIp(context: Context, manager: RoutingManager, input: String, rulesActive: Boolean): CheckResult
|
||||
{
|
||||
val ip = input.substringBefore('/').trim()
|
||||
/* only literal addresses: getByName would otherwise resolve a host name */
|
||||
if (!ip.matches(Regex("[0-9.]+")) && !ip.contains(':'))
|
||||
{
|
||||
return CheckResult.BadInput
|
||||
}
|
||||
val address = withContext(Dispatchers.IO) { runCatching { InetAddress.getByName(ip) }.getOrNull() } ?: return CheckResult.BadInput
|
||||
val verdict = RouteCheck.verdict(
|
||||
address,
|
||||
CharonVpnService.sAppliedGeoExclusionsV4, CharonVpnService.sAppliedGeoExclusionsV6,
|
||||
CharonVpnService.sDnsPunchIps,
|
||||
RouteCheck.ranges(manager.forceTunnelCidrs()), RouteCheck.ranges(manager.directCidrs()), rulesActive,
|
||||
)
|
||||
val host = address.hostAddress ?: ip
|
||||
return CheckResult.Ip(host, verdict, reach(context, host))
|
||||
}
|
||||
|
||||
/* ------------------------------ results ------------------------------ */
|
||||
|
||||
@Composable
|
||||
private fun reasonText(reason: RouteCheck.Reason): String = stringResource(
|
||||
when (reason)
|
||||
{
|
||||
RouteCheck.Reason.DNS -> R.string.route_reason_dns
|
||||
RouteCheck.Reason.RULE_TUNNEL -> R.string.route_reason_rule_vpn
|
||||
RouteCheck.Reason.RULE_DIRECT -> R.string.route_reason_rule_direct
|
||||
RouteCheck.Reason.RU_LIST -> R.string.route_reason_ru
|
||||
RouteCheck.Reason.DEFAULT -> R.string.route_reason_default
|
||||
}
|
||||
)
|
||||
|
||||
/** The verdict card of the mockup: label, big route, reachability pill, path diagram, reason. */
|
||||
@Composable
|
||||
private fun VerdictCard(direct: Boolean, mixed: Boolean, subject: String, reason: String, reach: Long?, checkedReach: Boolean)
|
||||
{
|
||||
val c = LocalShxColors.current
|
||||
val bg = if (direct) c.successContainer else c.accentContainer
|
||||
val strong = if (direct) c.onSuccessStrong else c.onAccentContainerStrong
|
||||
val soft = if (direct) c.onSuccess else c.onAccentContainer
|
||||
val line = if (direct) c.success else c.accent
|
||||
val country = remember { VpnStateService.getLiveInstance()?.profile?.name?.take(12) }
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(14.dp),
|
||||
modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(24.dp)).background(bg).padding(16.dp),
|
||||
)
|
||||
{
|
||||
Row(verticalAlignment = Alignment.CenterVertically)
|
||||
{
|
||||
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp))
|
||||
{
|
||||
Text(stringResource(R.string.route_check_result).uppercase(), style = ShxType.section.copy(fontSize = 12.sp), color = soft)
|
||||
Text(
|
||||
stringResource(
|
||||
when
|
||||
{
|
||||
mixed -> R.string.route_check_verdict_mixed
|
||||
direct -> R.string.route_check_verdict_direct
|
||||
else -> R.string.route_check_verdict_vpn
|
||||
}
|
||||
),
|
||||
style = ShxType.pageTitle,
|
||||
color = strong,
|
||||
)
|
||||
}
|
||||
if (checkedReach)
|
||||
{
|
||||
Text(
|
||||
if (reach != null) stringResource(R.string.route_check_opens_ms, reach) else stringResource(R.string.route_check_not_opens),
|
||||
style = ShxType.chip.copy(fontWeight = FontWeight.ExtraBold),
|
||||
color = if (reach != null) soft else c.onErrorContainer,
|
||||
modifier = Modifier.clip(RoundedCornerShape(999.dp)).background(c.card).padding(horizontal = 12.dp, vertical = 6.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp))
|
||||
{
|
||||
PathNode(stringResource(R.string.route_check_node_phone), filled = false, color = line)
|
||||
Box(Modifier.weight(1f).height(3.dp).clip(RoundedCornerShape(2.dp)).background(line))
|
||||
PathNode(
|
||||
if (direct) stringResource(R.string.route_check_node_provider)
|
||||
else listOfNotNull(stringResource(R.string.route_check_node_vpn), country).joinToString(" · "),
|
||||
filled = true, color = line,
|
||||
)
|
||||
Box(Modifier.weight(1f).height(3.dp).clip(RoundedCornerShape(2.dp)).background(line))
|
||||
PathNode(subject, filled = false, color = line)
|
||||
}
|
||||
Text(
|
||||
buildAnnotatedString {
|
||||
withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { append(stringResource(R.string.route_check_why)) }
|
||||
append(' ')
|
||||
append(reason)
|
||||
},
|
||||
style = ShxType.note,
|
||||
color = soft,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PathNode(text: String, filled: Boolean, color: Color)
|
||||
{
|
||||
val c = LocalShxColors.current
|
||||
Text(
|
||||
text,
|
||||
style = ShxType.meta.copy(fontWeight = FontWeight.ExtraBold),
|
||||
color = if (filled) Color.White else c.text,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.clip(RoundedCornerShape(12.dp)).background(if (filled) color else c.card).padding(horizontal = 10.dp, vertical = 8.dp),
|
||||
)
|
||||
}
|
||||
|
||||
/** "Direct path for comparison": TCP 443 directly and through the tunnel. */
|
||||
@Composable
|
||||
private fun CompareCard(reach: Reach)
|
||||
{
|
||||
val c = LocalShxColors.current
|
||||
ShxSectionHeader(stringResource(R.string.route_check_compare))
|
||||
ShxPanel(padding = PaddingValues(horizontal = 16.dp, vertical = 4.dp))
|
||||
{
|
||||
val rows = listOf(
|
||||
stringResource(R.string.route_check_verdict_direct) to reach.direct,
|
||||
stringResource(R.string.route_check_verdict_vpn) to reach.vpn,
|
||||
)
|
||||
rows.forEachIndexed { i, (title, ms) ->
|
||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().height(52.dp))
|
||||
{
|
||||
Text(title, style = ShxType.body, color = c.text, modifier = Modifier.weight(1f))
|
||||
Text(
|
||||
if (ms != null) stringResource(R.string.diag_ms, ms) else stringResource(R.string.route_check_not_opens),
|
||||
style = ShxType.sub.copy(fontWeight = FontWeight.ExtraBold),
|
||||
color = if (ms != null) c.onSuccess else c.error,
|
||||
)
|
||||
}
|
||||
if (i < rows.lastIndex)
|
||||
{
|
||||
Box(Modifier.fillMaxWidth().height(1.dp).background(c.divider))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SiteResult(result: CheckResult.Site, rulesActive: Boolean, onAlways: (Boolean) -> Unit)
|
||||
{
|
||||
val c = LocalShxColors.current
|
||||
val report = result.report
|
||||
if (report.routes.isEmpty())
|
||||
{
|
||||
ShxNote(stringResource(R.string.route_check_resolve_failed), tone = ShxTone.ERROR)
|
||||
return
|
||||
}
|
||||
val verdicts = result.verdicts.values
|
||||
val anyDirect = verdicts.any { it.direct }
|
||||
val allDirect = verdicts.all { it.direct }
|
||||
val main = verdicts.firstOrNull { it.reason == RouteCheck.Reason.RULE_TUNNEL || it.reason == RouteCheck.Reason.RULE_DIRECT } ?: verdicts.first()
|
||||
VerdictCard(allDirect, anyDirect && !allDirect, report.domain, reasonText(main.reason), result.reach.viaDefault, checkedReach = true)
|
||||
if (!report.vpnNetworkFound)
|
||||
{
|
||||
ShxNote(stringResource(R.string.route_check_no_vpn), tone = ShxTone.WARNING)
|
||||
}
|
||||
else if (result.reach.direct != null || result.reach.vpn != null)
|
||||
{
|
||||
CompareCard(result.reach)
|
||||
}
|
||||
ShxSectionHeader(stringResource(R.string.route_check_dns_section))
|
||||
ShxPanel(padding = PaddingValues(horizontal = 16.dp, vertical = 12.dp))
|
||||
{
|
||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp))
|
||||
{
|
||||
DnsBlock(stringResource(R.string.route_check_via_vpn_dns), report.vpnDnsIps)
|
||||
DnsBlock(stringResource(R.string.route_check_via_direct_dns), report.directDnsIps)
|
||||
if (report.vpnDnsIps.isNotEmpty() && report.directDnsIps.isNotEmpty() && report.vpnDnsIps.toSet() != report.directDnsIps.toSet())
|
||||
{
|
||||
Text(
|
||||
buildAnnotatedString {
|
||||
withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { append(stringResource(R.string.route_check_dns_differ_title)) }
|
||||
append(' ')
|
||||
append(stringResource(R.string.route_check_dns_differ))
|
||||
},
|
||||
style = ShxType.note,
|
||||
color = c.onWarningContainer,
|
||||
modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(12.dp)).background(c.warningContainer).padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
ShxSectionHeader(stringResource(R.string.route_check_addresses, report.routes.size))
|
||||
ShxPanel()
|
||||
{
|
||||
report.routes.forEachIndexed { i, route ->
|
||||
val verdict = result.verdicts[route.ip] ?: return@forEachIndexed
|
||||
AddressRow(route.ip, verdict)
|
||||
if (i < report.routes.lastIndex)
|
||||
{
|
||||
Box(Modifier.fillMaxWidth().height(1.dp).background(c.divider))
|
||||
}
|
||||
}
|
||||
}
|
||||
ChangeButtons(report.domain, rulesActive, onAlways)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun IpResult(result: CheckResult.Ip, rulesActive: Boolean, onAlways: (Boolean) -> Unit)
|
||||
{
|
||||
VerdictCard(result.verdict.direct, false, result.ip, reasonText(result.verdict.reason), result.reach.viaDefault, checkedReach = true)
|
||||
if (result.reach.direct != null || result.reach.vpn != null)
|
||||
{
|
||||
CompareCard(result.reach)
|
||||
}
|
||||
ChangeButtons(result.ip, rulesActive, onAlways)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AppResult(app: InstalledApp, manager: RoutingManager)
|
||||
{
|
||||
val c = LocalShxColors.current
|
||||
val state by manager.state.collectAsStateWithLifecycle()
|
||||
val config = state.config
|
||||
val excluded by produceState(emptySet<String>(), config) {
|
||||
value = withContext(Dispatchers.IO) { manager.computeExcludedPackages() }
|
||||
}
|
||||
val direct = config.enabled && app.packageName in excluded
|
||||
val match = RoutingManager.templateMatch(app.packageName, config)
|
||||
val reason = when
|
||||
{
|
||||
!config.enabled -> stringResource(R.string.route_reason_app_routing_off)
|
||||
app.packageName in config.tunnelOverrides -> stringResource(R.string.route_reason_app_override)
|
||||
app.packageName in config.directPackages -> stringResource(R.string.route_reason_app_added)
|
||||
match != null -> stringResource(R.string.route_reason_app_template, templateLabel(match))
|
||||
else -> stringResource(R.string.route_reason_app_default)
|
||||
}
|
||||
VerdictCard(direct, false, app.label, reason, null, checkedReach = false)
|
||||
ShxNote(stringResource(R.string.route_check_app_note), tone = ShxTone.NEUTRAL)
|
||||
Text(stringResource(R.string.route_check_change_for, app.label), style = ShxType.sub.copy(fontWeight = FontWeight.Bold), color = c.textSoft, modifier = Modifier.padding(horizontal = 6.dp))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp))
|
||||
{
|
||||
ChangeButton(stringResource(R.string.route_check_always_direct), c.successContainer, c.onSuccess, Modifier.weight(1f)) { manager.setAppDirect(app.packageName, true) }
|
||||
ChangeButton(stringResource(R.string.route_check_always_vpn), c.accentMuted, c.textTonal, Modifier.weight(1f)) { manager.setAppDirect(app.packageName, false) }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ChangeButtons(subject: String, rulesActive: Boolean, onAlways: (Boolean) -> Unit)
|
||||
{
|
||||
val c = LocalShxColors.current
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.padding(top = 4.dp))
|
||||
{
|
||||
Text(stringResource(R.string.route_check_change_for, subject), style = ShxType.sub.copy(fontWeight = FontWeight.Bold), color = c.textSoft, modifier = Modifier.padding(horizontal = 6.dp))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp))
|
||||
{
|
||||
ChangeButton(stringResource(R.string.route_check_always_direct), c.successContainer, c.onSuccess, Modifier.weight(1f)) { onAlways(true) }
|
||||
ChangeButton(stringResource(R.string.route_check_always_vpn), c.accentMuted, c.textTonal, Modifier.weight(1f)) { onAlways(false) }
|
||||
}
|
||||
if (!rulesActive)
|
||||
{
|
||||
ShxNote(stringResource(R.string.route_rules_need_flexible), tone = ShxTone.WARNING)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ChangeButton(text: String, bg: Color, fg: Color, modifier: Modifier, onClick: () -> Unit)
|
||||
{
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = modifier.height(52.dp).clip(RoundedCornerShape(16.dp)).background(bg).clickable(role = Role.Button, onClick = onClick),
|
||||
)
|
||||
{
|
||||
Text(text, style = ShxType.body.copy(fontWeight = FontWeight.ExtraBold), color = fg)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AddressRow(ip: String, verdict: RouteCheck.Verdict)
|
||||
{
|
||||
val c = LocalShxColors.current
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
modifier = Modifier.fillMaxWidth().padding(start = 16.dp, end = 14.dp, top = 10.dp, bottom = 10.dp),
|
||||
)
|
||||
{
|
||||
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp))
|
||||
{
|
||||
Text(ip, style = ShxType.mono.copy(fontSize = 14.sp, fontWeight = FontWeight.Bold), color = c.text)
|
||||
Text(reasonText(verdict.reason), style = ShxType.meta.copy(fontWeight = FontWeight.Normal), color = c.textSub, maxLines = 2)
|
||||
}
|
||||
Text(
|
||||
stringResource(if (verdict.direct) R.string.route_verdict_direct else R.string.route_verdict_tunnel),
|
||||
style = ShxType.meta.copy(fontWeight = FontWeight.ExtraBold),
|
||||
color = if (verdict.direct) c.onSuccess else c.onAccentContainer,
|
||||
modifier = Modifier.clip(RoundedCornerShape(999.dp)).background(if (verdict.direct) c.successContainer else c.accentContainer).padding(horizontal = 10.dp, vertical = 4.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DnsBlock(label: String, ips: List<String>)
|
||||
{
|
||||
val c = LocalShxColors.current
|
||||
Column(verticalArrangement = Arrangement.spacedBy(3.dp))
|
||||
{
|
||||
Text(label, style = ShxType.sub.copy(fontWeight = FontWeight.Bold), color = c.textSoft)
|
||||
Text(ips.joinToString(" · ").ifEmpty { stringResource(R.string.route_check_none) }, style = ShxType.mono.copy(fontSize = 14.sp), color = c.text)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AppSuggestions(apps: List<InstalledApp>, query: String, onPick: (InstalledApp) -> Unit)
|
||||
{
|
||||
val c = LocalShxColors.current
|
||||
val matches = remember(apps, query) {
|
||||
val q = query.trim()
|
||||
apps.filter { it.label.contains(q, ignoreCase = true) || it.packageName.contains(q, ignoreCase = true) }.take(6)
|
||||
}
|
||||
ShxPanel()
|
||||
{
|
||||
if (matches.isEmpty())
|
||||
{
|
||||
Text(stringResource(R.string.route_check_bad_app), style = ShxType.body, color = c.textSub, modifier = Modifier.padding(16.dp))
|
||||
}
|
||||
matches.forEachIndexed { i, app ->
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
modifier = Modifier.fillMaxWidth().clickable { onPick(app) }.padding(horizontal = 16.dp, vertical = 10.dp),
|
||||
)
|
||||
{
|
||||
AppIcon(app.packageName, 36.dp)
|
||||
Text(app.label, style = ShxType.rowTitle, color = c.text)
|
||||
}
|
||||
if (i < matches.lastIndex)
|
||||
{
|
||||
Box(Modifier.fillMaxWidth().height(1.dp).background(c.divider))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Loaded 100 of 738 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user