android: Full Compose rewrite + two-mode RU split with userspace routing
Android / pre-check (push) Failing after 5s
Android / android (push) Skipped
CIFuzz / pre-check (push) Failing after 3s
CIFuzz / fuzzing (address) (push) Skipped
CIFuzz / fuzzing (undefined) (push) Skipped
CodeQL / pre-check (push) Failing after 3s
CodeQL / analyze (python) (push) Skipped
CodeQL / analyze (ruby) (push) Skipped
CodeQL / analyze-cpp (push) Skipped
FreeBSD / pre-check (push) Failing after 3s
FreeBSD / freebsd (14.4) (push) Skipped
FreeBSD / freebsd (15.0) (push) Skipped
Linux / pre-check (push) Failing after 3s
Linux / latest (apidoc) (push) Skipped
Linux / latest (clang, no, all) (push) Skipped
Linux / latest (clang, no, default) (push) Skipped
Linux / latest (clang, no, printf-builtin) (push) Skipped
Linux / latest (clang, no-dbg) (push) Skipped
Linux / latest (clang, no-testable-ke) (push) Skipped
Linux / latest (clang, yes, all) (push) Skipped
Linux / latest (clang, yes, default) (push) Skipped
Linux / latest (clang, yes, fuzzing) (push) Skipped
Linux / latest (coverage) (push) Skipped
Linux / latest (dist) (push) Skipped
Linux / latest (gcc, no, all) (push) Skipped
Linux / latest (gcc, no, default) (push) Skipped
Linux / latest (gcc, no, printf-builtin) (push) Skipped
Linux / latest (gcc, yes, all) (push) Skipped
Linux / latest (gcc, yes, default) (push) Skipped
Linux / latest (nm) (push) Skipped
Linux / latest (no-dbg) (push) Skipped
Linux / latest (no-testable-ke) (push) Skipped
Linux / latest (yes, ld) (push) Skipped
Linux / crypto (ubuntu-22.04, openssl-sys) (push) Skipped
Linux / crypto (ubuntu-latest, botan) (push) Skipped
Linux / crypto (ubuntu-latest, openssl-3) (push) Skipped
Linux / crypto (ubuntu-latest, openssl-4) (push) Skipped
Linux / crypto (ubuntu-latest, openssl-awslc) (push) Skipped
Linux / crypto (ubuntu-latest, openssl-sys) (push) Skipped
Linux / crypto (ubuntu-latest, wolfssl) (push) Skipped
Linux / older (clang, ubuntu-22.04, all) (push) Skipped
Linux / older (gcc, ubuntu-22.04, all) (push) Skipped
Linux / older (gcc, ubuntu-22.04, nm) (push) Skipped
Linux / alpine (push) Skipped
macOS / pre-check (push) Failing after 3s
macOS / macos (macos-14) (push) Skipped
macOS / macos (macos-latest) (push) Skipped
SonarCloud / pre-check (push) Failing after 4s
SonarCloud / sonarcloud (push) Skipped
TKM / pre-check (push) Failing after 3s
TKM / tkm (push) Skipped
Windows / pre-check (push) Failing after 4s
Windows / cross-compile (win32) (push) Skipped
Windows / cross-compile (win64) (push) Skipped
Windows / native (i686, mingw32, win32) (push) Skipped
Windows / native (x86_64, mingw64, win64) (push) Skipped
Android / pre-check (push) Failing after 5s
Android / android (push) Skipped
CIFuzz / pre-check (push) Failing after 3s
CIFuzz / fuzzing (address) (push) Skipped
CIFuzz / fuzzing (undefined) (push) Skipped
CodeQL / pre-check (push) Failing after 3s
CodeQL / analyze (python) (push) Skipped
CodeQL / analyze (ruby) (push) Skipped
CodeQL / analyze-cpp (push) Skipped
FreeBSD / pre-check (push) Failing after 3s
FreeBSD / freebsd (14.4) (push) Skipped
FreeBSD / freebsd (15.0) (push) Skipped
Linux / pre-check (push) Failing after 3s
Linux / latest (apidoc) (push) Skipped
Linux / latest (clang, no, all) (push) Skipped
Linux / latest (clang, no, default) (push) Skipped
Linux / latest (clang, no, printf-builtin) (push) Skipped
Linux / latest (clang, no-dbg) (push) Skipped
Linux / latest (clang, no-testable-ke) (push) Skipped
Linux / latest (clang, yes, all) (push) Skipped
Linux / latest (clang, yes, default) (push) Skipped
Linux / latest (clang, yes, fuzzing) (push) Skipped
Linux / latest (coverage) (push) Skipped
Linux / latest (dist) (push) Skipped
Linux / latest (gcc, no, all) (push) Skipped
Linux / latest (gcc, no, default) (push) Skipped
Linux / latest (gcc, no, printf-builtin) (push) Skipped
Linux / latest (gcc, yes, all) (push) Skipped
Linux / latest (gcc, yes, default) (push) Skipped
Linux / latest (nm) (push) Skipped
Linux / latest (no-dbg) (push) Skipped
Linux / latest (no-testable-ke) (push) Skipped
Linux / latest (yes, ld) (push) Skipped
Linux / crypto (ubuntu-22.04, openssl-sys) (push) Skipped
Linux / crypto (ubuntu-latest, botan) (push) Skipped
Linux / crypto (ubuntu-latest, openssl-3) (push) Skipped
Linux / crypto (ubuntu-latest, openssl-4) (push) Skipped
Linux / crypto (ubuntu-latest, openssl-awslc) (push) Skipped
Linux / crypto (ubuntu-latest, openssl-sys) (push) Skipped
Linux / crypto (ubuntu-latest, wolfssl) (push) Skipped
Linux / older (clang, ubuntu-22.04, all) (push) Skipped
Linux / older (gcc, ubuntu-22.04, all) (push) Skipped
Linux / older (gcc, ubuntu-22.04, nm) (push) Skipped
Linux / alpine (push) Skipped
macOS / pre-check (push) Failing after 3s
macOS / macos (macos-14) (push) Skipped
macOS / macos (macos-latest) (push) Skipped
SonarCloud / pre-check (push) Failing after 4s
SonarCloud / sonarcloud (push) Skipped
TKM / pre-check (push) Failing after 3s
TKM / tkm (push) Skipped
Windows / pre-check (push) Failing after 4s
Windows / cross-compile (win32) (push) Skipped
Windows / cross-compile (win64) (push) Skipped
Windows / native (i686, mingw32, win32) (push) Skipped
Windows / native (x86_64, mingw64, win64) (push) Skipped
Replaces the fragment-based UI with a full Jetpack Compose app (HOME/profiles/routing/settings/trusted networks in Compose, adaptive brand theming, notification/vector drawables), adds the SHX release pipeline (build-local.sh, UpdateManager self-update from the S3 bucket, DiagLog diagnostics rotated into diag.log with crash capture) and the watchdog/auto-connect/reconnect hardening series. RU-GeoIP split now has two modes (Routing screen): - OS routes (default): coarsened complement of the RU list installed as system routes, bounded by the binder budget (2.12.x behavior) - Userspace routing (beta): only the default routes point into the TUN; every packet is classified natively against the exact RU list (ring-buffer queue, fixed flow tables - allocation-free hot path) and RU TCP/UDP leaves through protected sockets. TCP is terminated by a vendored lwIP 2.2.0 (tun2socks model, err_t renamed to avoid the strongSwan typedef clash, wildcard-port listener patch, asserts into diag.log). IPv4 addresses are compared in Java's numeric byte order and ranges sorted natively; direct flows re-protect on roam; relay errors log errno. Route diagnostics block 12s after connect. Also: SIM2-crash hardening, connectivity watchdog with wakeup alarm, profile export, connectivity self-check screen, tests. Published as 2.13.9 (versionCode 146).
This commit is contained in:
1 parent
9dcc56c821
commit
d12fb96dc2
494 files changed
+156372
-5520
No files matched your search
@@ -0,0 +1,16 @@
|
||||
# Память проекта
|
||||
|
||||
## Android-приложение: сборка и выкладка (src/frontends/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`.
|
||||
- **`.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` → диалог обновления в приложении); обновлять под релиз.
|
||||
- Автообновление приложения: `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,16 @@ 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
|
||||
@@ -0,0 +1,174 @@
|
||||
apply plugin: 'com.android.application'
|
||||
apply plugin: 'kotlin-android'
|
||||
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'
|
||||
|
||||
defaultConfig {
|
||||
applicationId "one.shx.strongswan.ext"
|
||||
compileSdk = 36
|
||||
minSdkVersion 21
|
||||
targetSdkVersion 36
|
||||
|
||||
versionCode 146
|
||||
versionName "2.13.9"
|
||||
|
||||
/* 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()
|
||||
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.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 1.8
|
||||
sourceCompatibility 1.8
|
||||
}
|
||||
kotlinOptions {
|
||||
jvmTarget = '1.8'
|
||||
}
|
||||
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 2025.12.01 (compose 1.10) требует minSdk 23 — приложение поддерживает API 21.
|
||||
2025.09.01 = первый BOM с material3 1.4.0 (Expressive) на compose 1.9 (minSdk 21). */
|
||||
def composeBom = platform('androidx.compose:compose-bom:2025.09.01')
|
||||
implementation composeBom
|
||||
implementation 'androidx.compose.ui:ui'
|
||||
implementation 'androidx.compose.material3:material3'
|
||||
implementation 'androidx.compose.material3:material3-window-size-class'
|
||||
/* adaptive-navigation-suite требует minSdk 23 (adaptive:1.2.0) — подключить на этапе 3
|
||||
вместе с решением о minSdk; сейчас приложение поддерживает API 21 */
|
||||
implementation 'androidx.compose.material:material-icons-extended'
|
||||
implementation 'androidx.activity:activity-compose:1.11.0'
|
||||
implementation 'androidx.navigation:navigation-compose:2.9.4'
|
||||
|
||||
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'
|
||||
}
|
||||
+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()
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,14 @@
|
||||
<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"/>
|
||||
<!-- 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" />
|
||||
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
|
||||
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
|
||||
@@ -53,6 +61,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">
|
||||
@@ -79,6 +88,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" >
|
||||
@@ -103,10 +123,13 @@
|
||||
android:name=".ui.TrustedNetworksActivity"
|
||||
android:label="@string/auto_connect_title">
|
||||
</activity>
|
||||
<activity
|
||||
android:name=".ui.ConnectivityCheckActivity"
|
||||
android:label="@string/health_check_title">
|
||||
</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"
|
||||
@@ -225,6 +248,41 @@
|
||||
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" />
|
||||
<!-- 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;
|
||||
}
|
||||
}
|
||||
+2357
File diff suppressed because it is too large.
Load diff
@@ -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.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.
|
||||
*
|
||||
* Every line is flushed and closed on write so events survive a process kill
|
||||
* at any point. The file rotates at [MAX_SIZE] (one previous generation is
|
||||
* kept). 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 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)
|
||||
{
|
||||
val file = File(base, FILE_NAME)
|
||||
if (file.length() > MAX_SIZE)
|
||||
{
|
||||
File(base, OLD_FILE_NAME).delete()
|
||||
file.renameTo(File(base, OLD_FILE_NAME))
|
||||
}
|
||||
FileWriter(file, true).use { writer ->
|
||||
writer.write("${format.format(Date())} $tag: $message\n")
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (_: Exception)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
/** 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");
|
||||
}
|
||||
}
|
||||
+42
@@ -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,24 @@ 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();
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
+281
-9
@@ -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,6 +27,7 @@ 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;
|
||||
@@ -33,6 +35,7 @@ import org.strongswan.android.data.VpnProfileDataSource;
|
||||
import org.strongswan.android.data.VpnType;
|
||||
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;
|
||||
@@ -43,11 +46,18 @@ 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 +66,36 @@ 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;
|
||||
|
||||
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 +141,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 +156,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;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -266,11 +315,46 @@ public class VpnStateService extends Service
|
||||
* started.
|
||||
*/
|
||||
public void disconnect()
|
||||
{
|
||||
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 */
|
||||
resetRetryTimer();
|
||||
setError(ErrorState.NO_ERROR);
|
||||
|
||||
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 */
|
||||
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
|
||||
* bound until the file descriptor of the TUN device is closed. thus
|
||||
@@ -291,6 +375,20 @@ 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();
|
||||
@@ -311,8 +409,51 @@ 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) */
|
||||
String uuid = profileInfo.getString(VpnProfileDataSource.KEY_UUID);
|
||||
if (uuid == null && mProfile != null)
|
||||
{
|
||||
uuid = mProfile.getUUID().toString();
|
||||
}
|
||||
if (uuid != null)
|
||||
{
|
||||
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 +487,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.
|
||||
@@ -402,6 +600,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 +627,15 @@ public class VpnStateService extends Service
|
||||
if (VpnStateService.this.mState != state)
|
||||
{
|
||||
VpnStateService.this.mState = state;
|
||||
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;
|
||||
@@ -525,11 +733,13 @@ 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");
|
||||
if (mRetryTimeout <= 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
mHandler.sendMessageAtTime(mHandler.obtainMessage(RETRY_MSG), SystemClock.uptimeMillis() + RETRY_INTERVAL);
|
||||
mHandler.sendMessageAtTime(mHandler.obtainMessage(RETRY_MSG), SystemClock.uptimeMillis() + retryStep(mRetryIn));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -539,6 +749,27 @@ public class VpnStateService extends Service
|
||||
{
|
||||
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,18 +788,46 @@ 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)
|
||||
for (VpnStateListener listener : service.mListeners)
|
||||
{
|
||||
listener.stateChanged();
|
||||
}
|
||||
@@ -576,7 +835,20 @@ public class VpnStateService extends Service
|
||||
}
|
||||
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;
|
||||
for (VpnStateListener listener : service.mListeners)
|
||||
{
|
||||
listener.stateChanged();
|
||||
}
|
||||
sendMessageAtTime(obtainMessage(RETRY_MSG), SystemClock.uptimeMillis() + RETRY_SUPPRESS_MS);
|
||||
return;
|
||||
}
|
||||
service.connect(null, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+344
@@ -0,0 +1,344 @@
|
||||
/*
|
||||
* 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.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). 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, and within minutes even if
|
||||
* it never is.
|
||||
*
|
||||
* 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;
|
||||
|
||||
/** 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;
|
||||
}
|
||||
|
||||
@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;
|
||||
}
|
||||
schedule(context);
|
||||
if (WhiteListManager.getInstance(context).shouldBlockReconnect())
|
||||
{ /* the ISP whitelist blocks the server; the in-process retry
|
||||
* machinery parks itself in that case, so should we */
|
||||
DiagLog.i(TAG, "whitelist blocks the server, not restarting");
|
||||
return;
|
||||
}
|
||||
VpnStateService service = VpnStateService.getLiveInstance();
|
||||
boolean processAlive = service != null;
|
||||
boolean serviceTracking = processAlive && service.getState() != VpnStateService.State.DISABLED;
|
||||
boolean wifiPresent = isWifiPresent(context);
|
||||
boolean autoProfileConfigured = AutoConnectManager.getInstance(context).getCurrentConfig().getProfileUuid() != null;
|
||||
Action action = decideAction(enabled, uuid != null, autoExpected, false, serviceTracking,
|
||||
processAlive, wifiPresent, autoProfileConfigured);
|
||||
DiagLog.i(TAG, "check: uuid=" + (uuid != null) + " autoExpected=" + autoExpected +
|
||||
" processAlive=" + processAlive + " tracking=" + serviceTracking +
|
||||
" wifi=" + wifiPresent + " -> " + action);
|
||||
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;
|
||||
}
|
||||
}
|
||||
/* 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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. 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.
|
||||
*/
|
||||
public static void schedule(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;
|
||||
}
|
||||
arm(am, pendingIntent(context, REQUEST_CODE), SystemClock.elapsedRealtime() + WATCHDOG_INTERVAL_MS, false);
|
||||
arm(am, pendingIntent(context, WAKEUP_REQUEST_CODE), SystemClock.elapsedRealtime() + WATCHDOG_WAKEUP_INTERVAL_MS, 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);
|
||||
}
|
||||
}
|
||||
+52
-7
@@ -28,12 +28,18 @@ 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.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
|
||||
|
||||
/**
|
||||
@@ -145,6 +151,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()
|
||||
@@ -215,7 +233,16 @@ 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)
|
||||
evaluateDecision(
|
||||
config, network, vpnActive, autoSession,
|
||||
whiteListBlocksReconnect = WhiteListManager.getInstance(context).shouldBlockReconnect(),
|
||||
)
|
||||
|
||||
/** Re-runs the connect decision, e.g. when the white-list gate clears. */
|
||||
fun reevaluate()
|
||||
{
|
||||
evaluate()
|
||||
}
|
||||
|
||||
private fun evaluate()
|
||||
{
|
||||
@@ -232,8 +259,14 @@ 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()
|
||||
/* 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)
|
||||
}
|
||||
Action.IGNORE ->
|
||||
{
|
||||
@@ -279,8 +312,15 @@ class AutoConnectManager private constructor(
|
||||
}
|
||||
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)
|
||||
/* automatic start: keep the previous session's charon log */
|
||||
extras.putBoolean(CharonVpnService.KEY_PRESERVE_LOG, true)
|
||||
svc.connect(extras, true)
|
||||
return
|
||||
}
|
||||
@@ -356,14 +396,17 @@ class AutoConnectManager private constructor(
|
||||
private const val CONNECT_DELAY_MS = 3000L
|
||||
|
||||
/**
|
||||
* 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 +416,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 (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 (!vpnActive && !whiteListBlocksReconnect) Action.CONNECT else Action.IGNORE
|
||||
NetworkKind.UNTRUSTED_WIFI, NetworkKind.CELLULAR ->
|
||||
if (!vpnActive && !whiteListBlocksReconnect) Action.CONNECT else Action.IGNORE
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+2
@@ -24,6 +24,7 @@ import android.os.Build
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
import org.strongswan.android.logic.DiagLog
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
/**
|
||||
@@ -275,6 +276,7 @@ class NetworkMonitor(
|
||||
if (kind != lastReported)
|
||||
{
|
||||
Log.i(TAG, "Network kind $lastReported -> $kind")
|
||||
DiagLog.i(TAG, "network kind $lastReported -> $kind")
|
||||
lastReported = kind
|
||||
onNetworkChanged(kind)
|
||||
}
|
||||
|
||||
+168
@@ -0,0 +1,168 @@
|
||||
/*
|
||||
* 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.util.concurrent.TimeUnit
|
||||
|
||||
/** 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,
|
||||
)
|
||||
|
||||
/**
|
||||
* 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()
|
||||
socket.tcpNoDelay = true
|
||||
socket.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()
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
+759
@@ -0,0 +1,759 @@
|
||||
/*
|
||||
* 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.Handler
|
||||
import android.os.IBinder
|
||||
import android.os.Looper
|
||||
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.VpnStateService
|
||||
|
||||
/** 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
|
||||
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()
|
||||
|
||||
/* 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
|
||||
resetCounters()
|
||||
}
|
||||
|
||||
fun reloadConfig()
|
||||
{
|
||||
config = loadConfig()
|
||||
if (config.enabled)
|
||||
{
|
||||
start()
|
||||
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()
|
||||
onStateChanged()
|
||||
}
|
||||
else
|
||||
{
|
||||
stop()
|
||||
}
|
||||
}
|
||||
|
||||
override fun stateChanged()
|
||||
{
|
||||
handler.post { onStateChanged() }
|
||||
}
|
||||
|
||||
private fun onStateChanged()
|
||||
{
|
||||
if (!running)
|
||||
{
|
||||
return
|
||||
}
|
||||
when (service?.state)
|
||||
{
|
||||
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 targets = config.targets
|
||||
val checkServer = config.serverCheckMode != ServerCheckMode.OFF
|
||||
val timeout = config.timeoutMs
|
||||
|
||||
val results = coroutineScope {
|
||||
val serverDeferred = if (checkServer)
|
||||
{
|
||||
async { ConnectivityProbe.probe(ProbeTarget.SERVER, timeout, context, gateway) }
|
||||
}
|
||||
else
|
||||
{
|
||||
null
|
||||
}
|
||||
val external = targets.map { target ->
|
||||
async { ConnectivityProbe.probe(target, timeout, context, gateway) }
|
||||
}
|
||||
(listOfNotNull(serverDeferred) + external).map { it.await() }
|
||||
}
|
||||
|
||||
val serverResult = results.firstOrNull { it.target == ProbeTarget.SERVER }
|
||||
val externalResults = results.filter { it.target != ProbeTarget.SERVER }
|
||||
val externalOk = externalResults.count { it.ok }
|
||||
val externalTotal = externalResults.size
|
||||
val serverDown = serverResult?.let { !it.ok }
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
Decision.RECONNECT_TUNNEL ->
|
||||
{
|
||||
if (tunnelAttempts < config.tunnelDeadMaxAttempts)
|
||||
{
|
||||
tunnelAttempts++
|
||||
Log.i(TAG, "Reconnecting, tunnel dead (attempt $tunnelAttempts/${config.tunnelDeadMaxAttempts})")
|
||||
service?.reconnectPreservingLog()
|
||||
}
|
||||
}
|
||||
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 = false,
|
||||
)
|
||||
/* BATTERY_LOW/OKAY are sticky, so registration immediately reflects the current state */
|
||||
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 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
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
/** 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
|
||||
*/
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
+690
@@ -0,0 +1,690 @@
|
||||
/*
|
||||
* 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
|
||||
|
||||
/** 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
|
||||
|
||||
/* 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)
|
||||
{
|
||||
onNetworkEvent()
|
||||
}
|
||||
|
||||
override fun onLost(network: 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()
|
||||
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
|
||||
scope.launch { runCheck(force = true) }
|
||||
}
|
||||
|
||||
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 (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)
|
||||
runCheck(force = false)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
/**
|
||||
* 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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
+216
@@ -0,0 +1,216 @@
|
||||
/*
|
||||
* 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,
|
||||
)
|
||||
}
|
||||
|
||||
/** 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
+448
@@ -0,0 +1,448 @@
|
||||
/*
|
||||
* 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.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
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.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.logic.VpnStateService
|
||||
import java.util.UUID
|
||||
|
||||
/** A named set of applications that bypass the VPN tunnel. */
|
||||
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(),
|
||||
)
|
||||
|
||||
data class RoutingConfig(
|
||||
val enabled: Boolean = true,
|
||||
val ruleSets: List<RuleSet> = emptyList(),
|
||||
)
|
||||
|
||||
data class RoutingState(
|
||||
val config: RoutingConfig = RoutingConfig(),
|
||||
)
|
||||
|
||||
/**
|
||||
* Global per-app routing: named rule sets of applications that must NOT use
|
||||
* the VPN tunnel (they go direct), independent of the per-profile split
|
||||
* tunneling settings. [CharonVpnService.BuilderCache] merges the effective
|
||||
* package list into the TUN builder on every (re)connect.
|
||||
*
|
||||
* When rules change while the VPN is connected the connection is restarted
|
||||
* automatically (debounced), because per-app routing only applies when the
|
||||
* TUN interface is created.
|
||||
*/
|
||||
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)
|
||||
private var config = RoutingConfig()
|
||||
private var service: VpnStateService? = null
|
||||
|
||||
/* package cache: expanded prefixes 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
|
||||
|
||||
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)
|
||||
{
|
||||
Log.i(TAG, "Routing rules changed, reconnecting to apply them")
|
||||
svc.reconnectPreservingLog()
|
||||
}
|
||||
}
|
||||
|
||||
init
|
||||
{
|
||||
config = loadConfig()
|
||||
publish()
|
||||
}
|
||||
|
||||
fun start()
|
||||
{
|
||||
context.bindService(Intent(context, VpnStateService::class.java), connection, Service.BIND_AUTO_CREATE)
|
||||
}
|
||||
|
||||
fun updateConfig(transform: (RoutingConfig) -> RoutingConfig)
|
||||
{
|
||||
config = transform(config)
|
||||
saveConfig(config)
|
||||
invalidateCache()
|
||||
publish()
|
||||
if (config.enabled)
|
||||
{ /* warm the package cache so the connect path stays fast */
|
||||
scope.launch { computeExcludedPackages() }
|
||||
scheduleReconnect()
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------- UI-facing helpers ------------------------- */
|
||||
|
||||
fun setGlobalEnabled(enabled: Boolean)
|
||||
{
|
||||
updateConfig { it.copy(enabled = enabled) }
|
||||
}
|
||||
|
||||
/** Adds an empty rule set and returns its id so the UI can expand and focus it. */
|
||||
fun addRuleSet(): String
|
||||
{
|
||||
val id = UUID.randomUUID().toString()
|
||||
updateConfig { config ->
|
||||
config.copy(ruleSets = config.ruleSets + RuleSet(id = id, name = ""))
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
fun deleteRuleSet(id: String)
|
||||
{
|
||||
updateConfig { config -> config.copy(ruleSets = config.ruleSets.filter { it.id != id }) }
|
||||
}
|
||||
|
||||
/** Re-inserts a just-deleted rule set (the snackbar "Undo" action). */
|
||||
fun restoreRuleSet(ruleSet: RuleSet)
|
||||
{
|
||||
updateConfig { config -> config.copy(ruleSets = config.ruleSets + ruleSet) }
|
||||
}
|
||||
|
||||
fun updateRuleSetName(id: String, name: String)
|
||||
{
|
||||
updateConfig { config -> config.copy(ruleSets = config.ruleSets.map { if (it.id == id) it.copy(name = name) else it }) }
|
||||
}
|
||||
|
||||
fun setRuleSetEnabled(id: String, enabled: Boolean)
|
||||
{
|
||||
updateConfig { config -> config.copy(ruleSets = config.ruleSets.map { if (it.id == id) it.copy(enabled = enabled) else it }) }
|
||||
}
|
||||
|
||||
fun updateRuleSetPackages(id: String, packages: Set<String>)
|
||||
{
|
||||
updateConfig { config -> config.copy(ruleSets = config.ruleSets.map { if (it.id == id) it.copy(packages = packages) else it }) }
|
||||
}
|
||||
|
||||
/** Toggles a preset on a rule set: adds its prefixes/keywords, or removes them if all present. */
|
||||
fun togglePreset(id: String, preset: RoutingPresets.Preset)
|
||||
{
|
||||
updateConfig { config ->
|
||||
config.copy(ruleSets = config.ruleSets.map { ruleSet ->
|
||||
if (ruleSet.id != id)
|
||||
{
|
||||
ruleSet
|
||||
}
|
||||
else if (ruleSet.prefixes.containsAll(preset.prefixes) && ruleSet.keywords.containsAll(preset.keywords))
|
||||
{
|
||||
ruleSet.copy(
|
||||
prefixes = ruleSet.prefixes - preset.prefixes.toSet(),
|
||||
keywords = ruleSet.keywords - preset.keywords,
|
||||
)
|
||||
}
|
||||
else
|
||||
{
|
||||
ruleSet.copy(
|
||||
prefixes = ruleSet.prefixes + preset.prefixes,
|
||||
keywords = ruleSet.keywords + preset.keywords.map { it.lowercase() },
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/** Quick action: enable every preset of the RF bundle as its own rule set. */
|
||||
fun applyRfBundle()
|
||||
{
|
||||
updateConfig { config ->
|
||||
val sets = config.ruleSets.toMutableList()
|
||||
for (preset in RoutingPresets.RF_BUNDLE)
|
||||
{
|
||||
val index = sets.indexOfFirst { it.name == preset.name }
|
||||
if (index < 0)
|
||||
{
|
||||
sets.add(preset.asRuleSet())
|
||||
}
|
||||
else if (!sets[index].enabled)
|
||||
{
|
||||
sets[index] = sets[index].copy(enabled = true)
|
||||
}
|
||||
}
|
||||
config.copy(enabled = true, ruleSets = sets)
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------- 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>
|
||||
{
|
||||
if (!config.enabled || config.ruleSets.none { it.enabled })
|
||||
{
|
||||
return emptySet()
|
||||
}
|
||||
val version = configVersion
|
||||
packageCache?.let { (cachedVersion, packages) ->
|
||||
if (cachedVersion == version)
|
||||
{
|
||||
return packages
|
||||
}
|
||||
}
|
||||
synchronized(this)
|
||||
{
|
||||
packageCache?.let { (cachedVersion, packages) ->
|
||||
if (cachedVersion == version)
|
||||
{
|
||||
return packages
|
||||
}
|
||||
}
|
||||
val packages = effectiveExcludes(config.ruleSets, loadInstalledPackages())
|
||||
packageCache = version to packages
|
||||
return packages
|
||||
}
|
||||
}
|
||||
|
||||
private fun invalidateCache()
|
||||
{
|
||||
configVersion++
|
||||
packageCache = null
|
||||
}
|
||||
|
||||
private fun loadInstalledPackages(): Set<String>
|
||||
{
|
||||
val pm = context.packageManager
|
||||
return try
|
||||
{
|
||||
pm.getInstalledApplications(PackageManager.GET_META_DATA)
|
||||
.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()
|
||||
}
|
||||
}
|
||||
|
||||
private fun publish()
|
||||
{
|
||||
_state.value = RoutingState(config = config)
|
||||
}
|
||||
|
||||
private fun scheduleReconnect()
|
||||
{
|
||||
handler.removeCallbacks(reconnectRunnable)
|
||||
handler.postDelayed(reconnectRunnable, RECONNECT_DEBOUNCE_MS)
|
||||
}
|
||||
|
||||
private fun loadConfig(): RoutingConfig
|
||||
{
|
||||
val json = prefs.getString(KEY_CONFIG, null) ?: return RoutingConfig()
|
||||
return try
|
||||
{
|
||||
val obj = JSONObject(json)
|
||||
RoutingConfig(
|
||||
enabled = obj.optBoolean("enabled", true),
|
||||
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(),
|
||||
)
|
||||
}
|
||||
catch (_: JSONException)
|
||||
{
|
||||
RoutingConfig()
|
||||
}
|
||||
}
|
||||
|
||||
private fun saveConfig(config: RoutingConfig)
|
||||
{
|
||||
val obj = JSONObject()
|
||||
obj.put("enabled", config.enabled)
|
||||
val sets = JSONArray()
|
||||
for (ruleSet in config.ruleSets)
|
||||
{
|
||||
val o = JSONObject()
|
||||
o.put("id", ruleSet.id)
|
||||
o.put("name", ruleSet.name)
|
||||
o.put("enabled", ruleSet.enabled)
|
||||
o.put("packages", JSONArray(ruleSet.packages.sorted()))
|
||||
o.put("prefixes", JSONArray(ruleSet.prefixes.sorted()))
|
||||
o.put("keywords", JSONArray(ruleSet.keywords.map { it.lowercase() }.sorted()))
|
||||
sets.put(o)
|
||||
}
|
||||
obj.put("ruleSets", sets)
|
||||
prefs.edit().putString(KEY_CONFIG, obj.toString()).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 RECONNECT_DEBOUNCE_MS = 2000L
|
||||
|
||||
/**
|
||||
* All packages matched by the enabled rule sets: explicit packages,
|
||||
* prefixes and keywords matched against the installed packages.
|
||||
*/
|
||||
fun effectiveExcludes(ruleSets: List<RuleSet>, installed: Set<String>): Set<String>
|
||||
{
|
||||
val result = HashSet<String>()
|
||||
for (ruleSet in ruleSets)
|
||||
{
|
||||
if (!ruleSet.enabled)
|
||||
{
|
||||
continue
|
||||
}
|
||||
result.addAll(ruleSet.packages)
|
||||
result.addAll(matchPrefixes(ruleSet.prefixes, installed))
|
||||
result.addAll(matchKeywords(ruleSet.keywords, installed))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/** Installed packages whose name starts with any of the prefixes. */
|
||||
fun matchPrefixes(prefixes: Set<String>, installed: Set<String>): Set<String> =
|
||||
if (prefixes.isEmpty())
|
||||
{
|
||||
emptySet()
|
||||
}
|
||||
else
|
||||
{
|
||||
installed.filter { pkg -> prefixes.any { pkg.startsWith(it) } }.toSet()
|
||||
}
|
||||
|
||||
/** Installed packages whose name contains any keyword (case-insensitive, anywhere). */
|
||||
fun matchKeywords(keywords: Set<String>, installed: Set<String>): Set<String> =
|
||||
if (keywords.isEmpty())
|
||||
{
|
||||
emptySet()
|
||||
}
|
||||
else
|
||||
{
|
||||
val lower = keywords.map { it.lowercase() }
|
||||
installed.filter { pkg -> lower.any { pkg.lowercase().contains(it) } }.toSet()
|
||||
}
|
||||
|
||||
/**
|
||||
* Merges the global excludes into a profile's per-app split tunneling
|
||||
* settings (applied in-memory when the TUN is built, the stored profile
|
||||
* is untouched):
|
||||
* - EXCLUDE (incl. DISABLE, converted by the caller): global packages
|
||||
* are added to the excluded set;
|
||||
* - ONLY: global packages are removed from the allowed set - in ONLY
|
||||
* mode everything else goes direct anyway, so this alone is enough.
|
||||
*
|
||||
* @return the effective handling (DISABLE is normalized to EXCLUDE)
|
||||
*/
|
||||
@JvmStatic
|
||||
fun applyGlobalExcludes(handling: SelectedAppsHandling, selected: MutableSet<String>, global: Set<String>): SelectedAppsHandling
|
||||
{
|
||||
if (global.isEmpty())
|
||||
{
|
||||
return if (handling == SelectedAppsHandling.SELECTED_APPS_DISABLE)
|
||||
{
|
||||
SelectedAppsHandling.SELECTED_APPS_EXCLUDE
|
||||
}
|
||||
else
|
||||
{
|
||||
handling
|
||||
}
|
||||
}
|
||||
return when (handling)
|
||||
{
|
||||
SelectedAppsHandling.SELECTED_APPS_ONLY ->
|
||||
{
|
||||
selected.removeAll(global)
|
||||
SelectedAppsHandling.SELECTED_APPS_ONLY
|
||||
}
|
||||
else ->
|
||||
{
|
||||
selected.addAll(global)
|
||||
SelectedAppsHandling.SELECTED_APPS_EXCLUDE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Volatile
|
||||
private var instance: RoutingManager? = null
|
||||
|
||||
@JvmStatic
|
||||
fun getInstance(context: Context): RoutingManager =
|
||||
instance ?: synchronized(this) {
|
||||
instance ?: RoutingManager(context.applicationContext).also { instance = it }
|
||||
}
|
||||
}
|
||||
}
|
||||
+102
@@ -0,0 +1,102 @@
|
||||
/*
|
||||
* 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 java.util.UUID
|
||||
|
||||
/**
|
||||
* Built-in rule set templates.
|
||||
* - 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
|
||||
{
|
||||
data class Preset(val name: String, val prefixes: List<String> = emptyList(), val keywords: List<String> = emptyList())
|
||||
|
||||
val RF_BUNDLE: List<Preset> = listOf(
|
||||
/* namespace-wide */
|
||||
Preset("Все RU.*", prefixes = listOf("ru.")),
|
||||
/* ecosystems */
|
||||
Preset("Яндекс", prefixes = listOf("ru.yandex."), keywords = listOf("yandex")),
|
||||
Preset("VK", prefixes = listOf("com.vkontakte", "ru.vk", "com.vk"), keywords = listOf("vkontakte")),
|
||||
Preset("Одноклассники", prefixes = listOf("ru.ok.")),
|
||||
Preset("Дзен", keywords = listOf("dzen")),
|
||||
Preset("Rutube", keywords = listOf("rutube")),
|
||||
Preset("Звук", keywords = listOf("zvuk", "zvooq")),
|
||||
Preset("Иви", prefixes = listOf("ru.ivi")),
|
||||
/* banks & finance */
|
||||
Preset("Сбер", keywords = listOf("sber")),
|
||||
Preset("Т-Банк", keywords = listOf("tinkoff", "tbank")),
|
||||
Preset("ВТБ", keywords = listOf("vtb")),
|
||||
Preset("Альфа-Банк", keywords = listOf("alfabank", "alpha-bank")),
|
||||
Preset("Газпромбанк", keywords = listOf("gazprombank")),
|
||||
Preset("Совкомбанк", keywords = listOf("sovcombank")),
|
||||
Preset("Почта Банк", keywords = listOf("pochtabank")),
|
||||
Preset("РСХБ", keywords = listOf("rshb", "rosselkhozbank")),
|
||||
Preset("ОТП-Банк", prefixes = listOf("ru.otpbank"), keywords = listOf("otpbank")),
|
||||
Preset("МКБ", prefixes = listOf("ru.mkb")),
|
||||
Preset("Уралсиб", keywords = listOf("uralsib")),
|
||||
Preset("ЮMoney", keywords = listOf("yoomoney")),
|
||||
Preset("Золотая Корона", keywords = listOf("koronapay")),
|
||||
Preset("Мой налог", keywords = listOf("nalog")),
|
||||
/* retail & marketplaces */
|
||||
Preset("Ozon", keywords = listOf("ozon")),
|
||||
Preset("Wildberries", keywords = listOf("wildberries")),
|
||||
Preset("Мегамаркет", keywords = listOf("megamarket")),
|
||||
Preset("Пятёрочка", keywords = listOf("pyaterochka")),
|
||||
Preset("Перекрёсток", keywords = listOf("perekrestok")),
|
||||
Preset("Магнит", keywords = listOf("magnit")),
|
||||
Preset("Лента", keywords = listOf("lenta")),
|
||||
Preset("М.Видео", keywords = listOf("mvideo")),
|
||||
Preset("Спортмастер", keywords = listOf("sportmaster")),
|
||||
Preset("Лемана ПРО", keywords = listOf("leroymerlin", "lemana")),
|
||||
Preset("Самокат", keywords = listOf("samokat")),
|
||||
Preset("ВкусВилл", keywords = listOf("vkusvill")),
|
||||
/* telecom */
|
||||
Preset("МТС", prefixes = listOf("ru.mts", "com.mts")),
|
||||
Preset("Мегафон", keywords = listOf("megafon")),
|
||||
Preset("Билайн", keywords = listOf("beeline")),
|
||||
Preset("T2/Tele2", prefixes = listOf("ru.t2"), keywords = listOf("tele2")),
|
||||
Preset("Yota", prefixes = listOf("ru.yota")),
|
||||
/* transport & travel */
|
||||
Preset("РЖД", keywords = listOf("rzd")),
|
||||
Preset("Аэрофлот", keywords = listOf("aeroflot")),
|
||||
Preset("Туту.ру", keywords = listOf("tutu.ru")),
|
||||
Preset("Aviasales", keywords = listOf("aviasales")),
|
||||
Preset("Ситимобил", keywords = listOf("sitimobil")),
|
||||
Preset("Делимобиль", keywords = listOf("delimobil")),
|
||||
/* services */
|
||||
Preset("2ГИС", keywords = listOf("2gis", "dublgis")),
|
||||
Preset("ЦИАН", prefixes = listOf("ru.cian")),
|
||||
Preset("Дром", keywords = listOf("drom")),
|
||||
Preset("Юла", keywords = listOf("youla")),
|
||||
Preset("HeadHunter", prefixes = listOf("ru.hh"), keywords = listOf("headhunter")),
|
||||
Preset("Почта России", keywords = listOf("pochta", "russianpost")),
|
||||
Preset("СДЭК", keywords = listOf("cdek")),
|
||||
)
|
||||
}
|
||||
|
||||
fun RoutingPresets.Preset.asRuleSet(): RuleSet = RuleSet(
|
||||
id = UUID.randomUUID().toString(),
|
||||
name = name,
|
||||
enabled = true,
|
||||
prefixes = prefixes.toSet(),
|
||||
keywords = keywords.map { it.lowercase() }.toSet(),
|
||||
)
|
||||
+762
@@ -0,0 +1,762 @@
|
||||
/*
|
||||
* 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)
|
||||
{
|
||||
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() }
|
||||
val parsed = withContext(Dispatchers.IO) { 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)
|
||||
.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)
|
||||
publish(status = RuIpListStatus.ERROR)
|
||||
false
|
||||
}
|
||||
finally
|
||||
{
|
||||
updating = false
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------ internals ------------------------------ */
|
||||
|
||||
private fun loadFromDisk()
|
||||
{
|
||||
val file = listFile()
|
||||
if (file.isFile)
|
||||
{
|
||||
try
|
||||
{
|
||||
val parsed = 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
|
||||
}
|
||||
val last = prefs.getLong(PREF_LAST_UPDATE, 0L)
|
||||
if (System.currentTimeMillis() - last >= Constants.RUSPLIT_CHECK_INTERVAL_MS)
|
||||
{
|
||||
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 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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
+211
@@ -0,0 +1,211 @@
|
||||
/*
|
||||
* 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
|
||||
{
|
||||
private val TAG = VpnShortcutManager::class.java.simpleName
|
||||
|
||||
/** 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
+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) }
|
||||
}
|
||||
}
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
/*
|
||||
* 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
|
||||
{
|
||||
UpdateManager.getInstance(applicationContext).checkIfDue()
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+143
@@ -0,0 +1,143 @@
|
||||
/*
|
||||
* 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
|
||||
{
|
||||
private val TAG = UpdateInstaller::class.java.simpleName
|
||||
|
||||
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))
|
||||
}
|
||||
}
|
||||
+648
@@ -0,0 +1,648 @@
|
||||
/*
|
||||
* 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.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.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.delay
|
||||
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.StrongSwanApplication
|
||||
import org.strongswan.android.logic.VpnStateService
|
||||
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
|
||||
{
|
||||
private val TAG = UpdateManager::class.java.simpleName
|
||||
|
||||
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 var mInstance: UpdateManager? = null
|
||||
|
||||
@JvmStatic
|
||||
fun getInstance(context: Context): UpdateManager
|
||||
{
|
||||
return 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
|
||||
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>(15, TimeUnit.MINUTES)
|
||||
.setConstraints(Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build())
|
||||
.build()
|
||||
WorkManager.getInstance(context).enqueueUniquePeriodicWork(
|
||||
UpdateCheckWorker.WORK_NAME, ExistingPeriodicWorkPolicy.KEEP, request)
|
||||
mScope.launch {
|
||||
while (true)
|
||||
{
|
||||
checkIfDue()
|
||||
delay(Constants.UPDATE_CHECK_INTERVAL_MS)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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()
|
||||
{
|
||||
if (!isAutoCheckEnabled() || mCheckJob?.isActive == true || mDownloadJob?.isActive == true)
|
||||
{
|
||||
return
|
||||
}
|
||||
val last = mPrefs.getLong(Constants.PREF_LAST_UPDATE_CHECK, 0L)
|
||||
if (System.currentTimeMillis() - last >= Constants.UPDATE_CHECK_INTERVAL_MS)
|
||||
{
|
||||
check()
|
||||
}
|
||||
}
|
||||
|
||||
fun isAutoCheckEnabled(): Boolean
|
||||
{
|
||||
return mPrefs.getBoolean(Constants.PREF_AUTO_UPDATE_CHECK, true)
|
||||
}
|
||||
|
||||
/**
|
||||
* Manual check from the settings screen (always allowed)
|
||||
*/
|
||||
fun checkNow()
|
||||
{
|
||||
check()
|
||||
}
|
||||
|
||||
private fun check()
|
||||
{
|
||||
if (mCheckJob?.isActive == true || mDownloadJob?.isActive == true)
|
||||
{
|
||||
return
|
||||
}
|
||||
mCheckJob = mScope.launch {
|
||||
mState.value = UpdateState.Checking
|
||||
val info = fetchManifest() ?: return@launch
|
||||
mPrefs.edit().putLong(Constants.PREF_LAST_UPDATE_CHECK, System.currentTimeMillis()).apply()
|
||||
if (info.versionCode > currentVersionCode())
|
||||
{
|
||||
mState.value = UpdateState.Available(info)
|
||||
maybeDownloadSilently(info)
|
||||
}
|
||||
else
|
||||
{
|
||||
mState.value = UpdateState.UpToDate
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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)
|
||||
*/
|
||||
private fun maybeDownloadSilently(info: UpdateInfo)
|
||||
{
|
||||
if (!isSilentUpdatesEnabled() || isNetworkMetered() || mDownloadJob?.isActive == true)
|
||||
{
|
||||
return
|
||||
}
|
||||
mDownloadJob = mScope.launch {
|
||||
try
|
||||
{
|
||||
val apk = downloadApk(info)
|
||||
verifySignature(apk)
|
||||
if (isVpnDown() && canInstallSilently())
|
||||
{
|
||||
Log.i(TAG, "Applying the downloaded update silently")
|
||||
UpdateInstaller.install(context, apk)
|
||||
mState.value = UpdateState.Idle
|
||||
}
|
||||
else
|
||||
{
|
||||
notifyUpdateReady(info)
|
||||
}
|
||||
}
|
||||
catch (e: Exception)
|
||||
{
|
||||
Log.e(TAG, "Silent update download failed", e)
|
||||
/* the manual download from the settings screen still works */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 cm?.isActiveNetworkMetered ?: true
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
/** Posts a notification that the downloaded update is ready to install */
|
||||
private fun notifyUpdateReady(info: UpdateInfo)
|
||||
{
|
||||
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)
|
||||
.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)
|
||||
*/
|
||||
fun downloadAndInstall()
|
||||
{
|
||||
val info = (mState.value as? UpdateState.Available)?.info ?: return
|
||||
if (mDownloadJob?.isActive == true)
|
||||
{
|
||||
return
|
||||
}
|
||||
mDownloadJob = mScope.launch {
|
||||
try
|
||||
{
|
||||
val apk = downloadApk(info)
|
||||
verifySignature(apk)
|
||||
UpdateInstaller.install(context, apk)
|
||||
mState.value = UpdateState.Idle
|
||||
}
|
||||
catch (e: UpdateException)
|
||||
{
|
||||
Log.e(TAG, "Update failed: ${e.kind}", e)
|
||||
mState.value = UpdateState.Failed(e.kind, e.message)
|
||||
cleanup()
|
||||
}
|
||||
catch (e: Exception)
|
||||
{
|
||||
Log.e(TAG, "Update failed", e)
|
||||
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()
|
||||
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)
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 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()
|
||||
}
|
||||
}
|
||||
-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();
|
||||
}
|
||||
}
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
/*
|
||||
* Copyright (C) 2026 SHX VPN
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or modify it
|
||||
* under the terms of the GNU General Public License as published by the
|
||||
* Free Software Foundation; either version 2 of the License, or (at your
|
||||
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful, but
|
||||
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
||||
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
|
||||
* for more details.
|
||||
*/
|
||||
|
||||
package org.strongswan.android.ui
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import org.strongswan.android.R
|
||||
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.theme.ShxTheme
|
||||
|
||||
class ConnectivityCheckActivity : AppCompatActivity()
|
||||
{
|
||||
override fun onCreate(savedInstanceState: Bundle?)
|
||||
{
|
||||
super.onCreate(savedInstanceState)
|
||||
enableEdgeToEdge()
|
||||
HealthCheckManager.getInstance(this).reloadConfig()
|
||||
setContent {
|
||||
ShxTheme {
|
||||
SecondaryScaffold(title = stringResource(R.string.health_check_title), onBack = { finish() }) { padding ->
|
||||
Box(Modifier.padding(padding)) {
|
||||
ConnectivityCheckScreen()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
-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);
|
||||
}
|
||||
}
|
||||
@@ -49,6 +49,7 @@ import kotlinx.coroutines.withContext
|
||||
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
|
||||
@@ -70,8 +71,13 @@ class LogActivity : AppCompatActivity()
|
||||
|
||||
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()
|
||||
return
|
||||
@@ -84,11 +90,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)))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
+38
-1
@@ -16,15 +16,24 @@
|
||||
|
||||
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 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";
|
||||
|
||||
/**
|
||||
* Use "bring your own device" (BYOD) features
|
||||
@@ -34,7 +43,35 @@ 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());
|
||||
ShxAppKt.installShxUi(this);
|
||||
}
|
||||
|
||||
@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)
|
||||
{
|
||||
super.onNewIntent(intent);
|
||||
handleInstallUpdate(intent);
|
||||
}
|
||||
|
||||
private void handleInstallUpdate(Intent intent)
|
||||
{
|
||||
if (intent != null && intent.getBooleanExtra(EXTRA_INSTALL_UPDATE, false))
|
||||
{
|
||||
intent.removeExtra(EXTRA_INSTALL_UPDATE);
|
||||
UpdateManager.getInstance(this).downloadAndInstall();
|
||||
}
|
||||
}
|
||||
}
|
||||
-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());
|
||||
}
|
||||
}
|
||||
}
|
||||
+249
@@ -0,0 +1,249 @@
|
||||
/*
|
||||
* 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.material.icons.Icons
|
||||
import androidx.compose.material.icons.rounded.CheckCircle
|
||||
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
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.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.ScreenPadding
|
||||
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 = MaterialTheme.colorScheme.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,
|
||||
)
|
||||
{
|
||||
BackHandler(enabled = selected != null) { onSelect(null) }
|
||||
Column(Modifier.fillMaxSize())
|
||||
{
|
||||
TopAppBar(
|
||||
title = {
|
||||
Text(
|
||||
selected?.getTitle() ?: stringResource(R.string.remediation_instructions_title),
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
)
|
||||
},
|
||||
navigationIcon = {
|
||||
IconButton(onClick = { if (selected != null) onSelect(null) else onFinish() }) {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Rounded.ArrowBack,
|
||||
contentDescription = stringResource(R.string.back),
|
||||
)
|
||||
}
|
||||
},
|
||||
colors = androidx.compose.material3.TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.background,
|
||||
),
|
||||
)
|
||||
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 = Icons.Rounded.CheckCircle,
|
||||
message = stringResource(R.string.remediation_instructions_title),
|
||||
modifier = Modifier.padding(ScreenPadding),
|
||||
)
|
||||
}
|
||||
else
|
||||
{
|
||||
LazyColumn(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
) {
|
||||
itemsIndexed(instructions) { index, item ->
|
||||
ShxCard(
|
||||
modifier = Modifier
|
||||
.padding(horizontal = ScreenPadding, vertical = 6.dp)
|
||||
.animateItem(),
|
||||
) {
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { onSelect(item) }
|
||||
.padding(16.dp),
|
||||
) {
|
||||
Text(item.getTitle(), style = MaterialTheme.typography.titleMedium)
|
||||
if (!item.getDescription().isNullOrEmpty())
|
||||
{
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(
|
||||
item.getDescription(),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = ScreenPadding),
|
||||
) {
|
||||
Text(
|
||||
instruction.getDescription(),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.padding(top = 8.dp),
|
||||
)
|
||||
if (!instruction.getHeader().isNullOrEmpty())
|
||||
{
|
||||
Text(
|
||||
instruction.getHeader(),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 20.dp, bottom = 8.dp),
|
||||
)
|
||||
ShxCard {
|
||||
Column(Modifier.padding(vertical = 4.dp)) {
|
||||
instruction.getItems().forEachIndexed { index, item ->
|
||||
Text(
|
||||
item,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp),
|
||||
)
|
||||
if (index < instruction.getItems().lastIndex)
|
||||
{
|
||||
ShxSectionDivider(indent = 16.dp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(24.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
-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);
|
||||
}
|
||||
}
|
||||
-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();
|
||||
}
|
||||
}
|
||||
}
|
||||
+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();
|
||||
}
|
||||
}
|
||||
-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 androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
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.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)
|
||||
{
|
||||
AlertDialog(
|
||||
onDismissRequest = { finish() },
|
||||
title = { Text(stringResource(R.string.import_certificate)) },
|
||||
text = { Text(certificate.getSubjectDN().toString()) },
|
||||
confirmButton = {
|
||||
TextButton(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 = {
|
||||
TextButton(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);
|
||||
}
|
||||
}
|
||||
+19
-3
@@ -20,9 +20,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.logic.autoconnect.AutoConnectManager
|
||||
import org.strongswan.android.logic.autoconnect.SsidReader
|
||||
@@ -37,14 +42,25 @@ class TrustedNetworksActivity : AppCompatActivity()
|
||||
super.onCreate(savedInstanceState)
|
||||
enableEdgeToEdge()
|
||||
AutoConnectManager.getInstance(this).reloadConfig()
|
||||
val source = VpnProfileSource(this).open()
|
||||
val profiles = source.allVpnProfiles
|
||||
source.close()
|
||||
val currentSsid = SsidReader.from(this)
|
||||
setContent {
|
||||
ShxTheme {
|
||||
SecondaryScaffold(title = stringResource(R.string.auto_connect_title), onBack = { finish() }) { padding ->
|
||||
Box(Modifier.padding(padding)) {
|
||||
/* profile list comes from SQLite, load off the main thread */
|
||||
val profiles by produceState<List<VpnProfile>>(emptyList()) {
|
||||
value = withContext(Dispatchers.IO) {
|
||||
val source = VpnProfileSource(applicationContext).open()
|
||||
try
|
||||
{
|
||||
source.allVpnProfiles
|
||||
}
|
||||
finally
|
||||
{
|
||||
source.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
TrustedNetworksScreen(profiles = profiles, currentSsid = currentSsid)
|
||||
}
|
||||
}
|
||||
|
||||
+205
-284
@@ -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,12 @@ 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;
|
||||
/* 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
|
||||
@@ -104,7 +107,7 @@ public class VpnProfileControlActivity extends AppCompatActivity
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -139,6 +142,8 @@ public class VpnProfileControlActivity extends AppCompatActivity
|
||||
}
|
||||
this.bindService(new Intent(this, VpnStateService.class),
|
||||
mServiceConnection, Service.BIND_AUTO_CREATE);
|
||||
VpnDialogBus.INSTANCE.setListener(this);
|
||||
VpnDialogsKt.installVpnDialogHost(this);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -152,6 +157,8 @@ public class VpnProfileControlActivity extends AppCompatActivity
|
||||
protected void onDestroy()
|
||||
{
|
||||
super.onDestroy();
|
||||
VpnDialogBus.INSTANCE.setListener(null);
|
||||
mProfileExecutor.shutdown();
|
||||
if (mService != null)
|
||||
{
|
||||
this.unbindService(mServiceConnection);
|
||||
@@ -198,13 +205,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 */
|
||||
@@ -222,7 +229,7 @@ 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);
|
||||
showNotSupported(R.string.vpn_not_supported);
|
||||
mWaitingForResult = false;
|
||||
}
|
||||
}
|
||||
@@ -289,14 +296,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 +337,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 +397,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 +419,46 @@ 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 (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 +468,33 @@ 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(() -> 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 +502,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 +518,137 @@ public class VpnProfileControlActivity extends AppCompatActivity
|
||||
{
|
||||
Intent intent = getIntent();
|
||||
|
||||
if (START_PROFILE.equals(intent.getAction()))
|
||||
String action = normalizeAction(intent);
|
||||
|
||||
if (START_PROFILE.equals(action))
|
||||
{
|
||||
startVpnProfile(intent);
|
||||
}
|
||||
else if (DISCONNECT.equals(intent.getAction()))
|
||||
else if (DISCONNECT.equals(action))
|
||||
{
|
||||
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();
|
||||
}
|
||||
}
|
||||
+24
-7
@@ -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,26 @@ 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
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?)
|
||||
{
|
||||
super.onCreate(savedInstanceState)
|
||||
@@ -53,20 +71,19 @@ 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() },
|
||||
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()
|
||||
|
||||
+76
-164
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright (C) 2016-2025 Tobias Brunner
|
||||
* Copyright (C) 2012-2020 Tobias Brunner
|
||||
*
|
||||
* Copyright (C) secunet Security Networks AG
|
||||
*
|
||||
@@ -28,15 +28,6 @@ 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;
|
||||
@@ -51,7 +42,8 @@ import org.strongswan.android.data.VpnType;
|
||||
import org.strongswan.android.data.VpnType.VpnTypeFeature;
|
||||
import org.strongswan.android.logic.TrustedCertificateManager;
|
||||
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 +69,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 +88,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 +173,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);
|
||||
|
||||
Intent intent = getIntent();
|
||||
String action = intent.getAction();
|
||||
@@ -280,7 +208,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,41 +230,24 @@ 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 true;
|
||||
}
|
||||
|
||||
@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);
|
||||
return;
|
||||
}
|
||||
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);
|
||||
}
|
||||
|
||||
private void loadProfile(Uri uri)
|
||||
{
|
||||
mProgressBar.show();
|
||||
mUi.setLoading(true);
|
||||
|
||||
Bundle args = new Bundle();
|
||||
args.putParcelable(PROFILE_URI, uri);
|
||||
@@ -342,7 +256,7 @@ public class VpnProfileImportActivity extends AppCompatActivity
|
||||
|
||||
public void handleProfile(ProfileLoadResult data)
|
||||
{
|
||||
mProgressBar.hide();
|
||||
mUi.setLoading(false);
|
||||
|
||||
mProfile = null;
|
||||
if (data != null && data.ThrownException == null)
|
||||
@@ -354,10 +268,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;
|
||||
}
|
||||
}
|
||||
@@ -399,32 +311,29 @@ public class VpnProfileImportActivity extends AppCompatActivity
|
||||
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 +355,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 +378,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;
|
||||
@@ -748,15 +676,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;
|
||||
@@ -769,8 +697,8 @@ public class VpnProfileImportActivity extends AppCompatActivity
|
||||
{
|
||||
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 +834,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)
|
||||
{
|
||||
|
||||
+18
-3
@@ -22,12 +22,16 @@ 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.data.VpnProfile
|
||||
import org.strongswan.android.data.VpnProfileSource
|
||||
@@ -41,11 +45,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() },
|
||||
|
||||
+2
-11
@@ -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
|
||||
@@ -121,13 +118,7 @@ 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))
|
||||
{
|
||||
uuid = pref.getString(Constants.PREF_MRU_VPN_PROFILE, null);
|
||||
}
|
||||
return mDataSource != null ? mDataSource.getVpnProfile(uuid) : null;
|
||||
return mDataSource != null ? mDataSource.getVpnProfile(DefaultVpnProfile.getDefaultProfileUuid(this)) : null;
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
-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;
|
||||
}
|
||||
}
|
||||
-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;
|
||||
}
|
||||
}
|
||||
+355
@@ -0,0 +1,355 @@
|
||||
/*
|
||||
* 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 androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.rounded.Add
|
||||
import androidx.compose.material.icons.rounded.BatterySaver
|
||||
import androidx.compose.material.icons.rounded.HealthAndSafety
|
||||
import androidx.compose.material.icons.rounded.NetworkCheck
|
||||
import androidx.compose.material.icons.rounded.Remove
|
||||
import androidx.compose.material.icons.rounded.Public
|
||||
import androidx.compose.material.icons.rounded.Speed
|
||||
import androidx.compose.material.icons.rounded.Timer
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.RadioButton
|
||||
import androidx.compose.material3.SegmentedButton
|
||||
import androidx.compose.material3.SegmentedButtonDefaults
|
||||
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
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.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.ScreenPadding
|
||||
|
||||
/**
|
||||
* 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 = 24.dp),
|
||||
)
|
||||
{
|
||||
ShxSection(title = stringResource(R.string.settings_section_general))
|
||||
{
|
||||
ShxSettingToggleRow(
|
||||
icon = Icons.Rounded.NetworkCheck,
|
||||
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 = Icons.Rounded.Timer,
|
||||
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 = Icons.Rounded.Speed,
|
||||
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 = Icons.Rounded.BatterySaver,
|
||||
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 = Icons.Rounded.HealthAndSafety,
|
||||
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 = Icons.Rounded.Public,
|
||||
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 = 16.dp, vertical = 10.dp))
|
||||
{
|
||||
Text(
|
||||
stringResource(R.string.health_check_mode),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = if (config.enabled) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
SingleChoiceSegmentedButtonRow(Modifier.fillMaxWidth().padding(top = 8.dp))
|
||||
{
|
||||
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 = Icons.Rounded.NetworkCheck,
|
||||
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 = Icons.Rounded.NetworkCheck,
|
||||
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 = Icons.Rounded.NetworkCheck,
|
||||
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)
|
||||
AlertDialog(
|
||||
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 = 12.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(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 = 16.dp, vertical = 10.dp),
|
||||
)
|
||||
{
|
||||
Text(
|
||||
title,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = if (enabled) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
IconButton(onClick = onDecrement, enabled = enabled && attempts > 1) {
|
||||
Icon(Icons.Rounded.Remove, contentDescription = stringResource(R.string.health_check_attempt_minus))
|
||||
}
|
||||
Text(
|
||||
text = attempts.toString(),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
modifier = Modifier.padding(horizontal = 8.dp),
|
||||
)
|
||||
IconButton(onClick = onIncrement, enabled = enabled && attempts < 5) {
|
||||
Icon(Icons.Rounded.Add, contentDescription = stringResource(R.string.health_check_attempt_plus))
|
||||
}
|
||||
}
|
||||
}
|
||||
+483
-97
@@ -14,19 +14,26 @@ import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.animation.core.LinearEasing
|
||||
import androidx.compose.animation.core.RepeatMode
|
||||
import androidx.compose.animation.core.Spring
|
||||
import androidx.compose.animation.core.animateFloat
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.infiniteRepeatable
|
||||
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||
import androidx.compose.animation.core.spring
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.expandVertically
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.shrinkVertically
|
||||
import androidx.compose.animation.slideInVertically
|
||||
import androidx.compose.animation.slideOutVertically
|
||||
import androidx.compose.animation.togetherWith
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.interaction.collectIsPressedAsState
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -38,18 +45,23 @@ 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.LazyListState
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowRight
|
||||
import androidx.compose.material.icons.rounded.Add
|
||||
import androidx.compose.material.icons.rounded.ArrowDownward
|
||||
import androidx.compose.material.icons.rounded.ArrowUpward
|
||||
import androidx.compose.material.icons.rounded.Delete
|
||||
import androidx.compose.material.icons.rounded.Dns
|
||||
import androidx.compose.material.icons.rounded.ExpandLess
|
||||
import androidx.compose.material.icons.rounded.GppMaybe
|
||||
import androidx.compose.material.icons.rounded.Language
|
||||
import androidx.compose.material.icons.rounded.PlayArrow
|
||||
import androidx.compose.material.icons.rounded.Refresh
|
||||
import androidx.compose.material.icons.rounded.Schedule
|
||||
import androidx.compose.material.icons.rounded.Share
|
||||
import androidx.compose.material.icons.rounded.Edit
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
@@ -62,21 +74,28 @@ import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.mutableStateMapOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.snapshots.SnapshotStateList
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.CornerRadius
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
@@ -89,7 +108,12 @@ import org.strongswan.android.R
|
||||
import org.strongswan.android.data.VpnProfile
|
||||
import org.strongswan.android.logic.VpnStateService.ErrorState
|
||||
import org.strongswan.android.logic.VpnStateService.State
|
||||
import org.strongswan.android.ui.compose.theme.ConnectionCardRadius
|
||||
import org.strongswan.android.logic.connectivity.ProbeResult
|
||||
import org.strongswan.android.logic.connectivity.ProbeTarget
|
||||
import org.strongswan.android.logic.connectivity.WhiteListState
|
||||
import org.strongswan.android.logic.connectivity.WhiteListStatus
|
||||
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
|
||||
|
||||
@@ -99,14 +123,39 @@ fun HomeScreen(
|
||||
state: HomeUiState,
|
||||
trafficFlow: StateFlow<TrafficUi>,
|
||||
viewModel: HomeViewModel,
|
||||
listState: LazyListState = rememberLazyListState(),
|
||||
onAddProfile: () -> Unit,
|
||||
onEditProfile: (VpnProfile) -> Unit,
|
||||
onShareProfile: (VpnProfile) -> Unit,
|
||||
onShowLog: () -> Unit,
|
||||
)
|
||||
{
|
||||
val context = LocalContext.current
|
||||
/* collected here (not in ShxApp) so 500ms traffic ticks only recompose this screen */
|
||||
val traffic by trafficFlow.collectAsStateWithLifecycle()
|
||||
val probes by viewModel.probes.collectAsStateWithLifecycle()
|
||||
val probing by viewModel.probing.collectAsStateWithLifecycle()
|
||||
val whiteList by viewModel.whiteList.collectAsStateWithLifecycle()
|
||||
/* rolling RTT history per target, rendered as sparklines in the availability card */
|
||||
val probeHistory = remember { mutableStateMapOf<ProbeTarget, SnapshotStateList<ProbeResult?>>() }
|
||||
LaunchedEffect(probes)
|
||||
{
|
||||
if (probes.isEmpty())
|
||||
{
|
||||
probeHistory.clear()
|
||||
}
|
||||
else
|
||||
{
|
||||
probes.forEach { (target, result) ->
|
||||
val list = probeHistory.getOrPut(target) { mutableStateListOf() }
|
||||
list.add(result)
|
||||
while (list.size > RTT_HISTORY)
|
||||
{
|
||||
list.removeAt(0)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
var pendingDelete by remember { mutableStateOf<VpnProfile?>(null) }
|
||||
var sheetProfile by remember { mutableStateOf<VpnProfile?>(null) }
|
||||
val connected = state.vpnState == State.CONNECTED
|
||||
@@ -141,29 +190,34 @@ fun HomeScreen(
|
||||
{
|
||||
StatusGlowBackground(color = statusColor, active = connected || connecting)
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
)
|
||||
{
|
||||
item(key = "status") {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 24.dp, bottom = 12.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
AnimatedContent(
|
||||
targetState = statusText,
|
||||
transitionSpec = { fadeIn(tween(220)) togetherWith fadeOut(tween(160)) },
|
||||
label = "statusText",
|
||||
) { text ->
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = statusColor,
|
||||
)
|
||||
}
|
||||
item(key = "status") {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 24.dp, bottom = 12.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
AnimatedContent(
|
||||
targetState = statusText,
|
||||
transitionSpec = {
|
||||
(slideInVertically(spring(dampingRatio = 0.9f, stiffness = Spring.StiffnessMediumLow)) { it / 3 } +
|
||||
fadeIn(tween(120))) togetherWith
|
||||
(slideOutVertically(spring(dampingRatio = 0.9f, stiffness = Spring.StiffnessMedium)) { -it / 3 } +
|
||||
fadeOut(tween(90)))
|
||||
},
|
||||
label = "statusText",
|
||||
) { text ->
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
color = statusColor,
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(6.dp))
|
||||
Text(
|
||||
text = subtitle,
|
||||
@@ -178,13 +232,34 @@ fun HomeScreen(
|
||||
profile = selected,
|
||||
vpnState = state.vpnState,
|
||||
traffic = traffic,
|
||||
enabled = selected != null && !selected.isReadOnly,
|
||||
enabled = selected != null,
|
||||
onConnect = { selected?.let { viewModel.connect(context, it) } },
|
||||
onDisconnect = { viewModel.disconnect() },
|
||||
onCancel = { viewModel.disconnect() },
|
||||
onOpenDetails = { if (connected) viewModel.openDetails(true) else selected?.let { onEditProfile(it) } },
|
||||
)
|
||||
}
|
||||
if (whiteList.config.enabled && whiteList.status == WhiteListStatus.ACTIVE)
|
||||
{
|
||||
item(key = "whitelist") {
|
||||
WhiteListBanner(
|
||||
modifier = Modifier.padding(top = 12.dp, start = ScreenPadding, end = ScreenPadding),
|
||||
)
|
||||
}
|
||||
}
|
||||
if (connected)
|
||||
{
|
||||
item(key = "availability") {
|
||||
AvailabilityCard(
|
||||
modifier = Modifier.padding(top = 12.dp),
|
||||
probes = probes,
|
||||
probing = probing,
|
||||
whiteList = whiteList,
|
||||
history = probeHistory,
|
||||
onRefresh = { viewModel.refreshProbes() },
|
||||
)
|
||||
}
|
||||
}
|
||||
if (hasError)
|
||||
{
|
||||
item(key = "error") {
|
||||
@@ -216,30 +291,39 @@ fun HomeScreen(
|
||||
if (state.profiles.isEmpty())
|
||||
{
|
||||
item(key = "empty") {
|
||||
EmptyProfiles(
|
||||
ShxCard(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = ScreenPadding, vertical = 12.dp),
|
||||
onAdd = onAddProfile,
|
||||
)
|
||||
) {
|
||||
ShxEmptyState(
|
||||
icon = Icons.Rounded.Language,
|
||||
message = stringResource(R.string.no_profiles),
|
||||
actionLabel = stringResource(R.string.add_profile_cta),
|
||||
onAction = onAddProfile,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
itemsIndexed(state.profiles, key = { _, p -> p.getUUID().toString() }) { index, profile ->
|
||||
val active = connected && profile.getUUID() == (state.activeProfile ?: selected)?.getUUID()
|
||||
ShxProfileRow(
|
||||
profile = profile,
|
||||
active = active,
|
||||
onClick = {
|
||||
viewModel.select(profile)
|
||||
viewModel.connect(context, profile)
|
||||
},
|
||||
onLongClick = { sheetProfile = profile },
|
||||
)
|
||||
if (index < state.profiles.lastIndex)
|
||||
{
|
||||
ShxDivider()
|
||||
Column(Modifier.animateItem()) {
|
||||
ShxProfileRow(
|
||||
profile = profile,
|
||||
active = active,
|
||||
onClick = {
|
||||
viewModel.select(profile)
|
||||
viewModel.connect(context, profile)
|
||||
},
|
||||
onLongClick = { sheetProfile = profile },
|
||||
onLongClickLabel = stringResource(R.string.profile_actions),
|
||||
)
|
||||
if (index < state.profiles.lastIndex)
|
||||
{
|
||||
ShxDivider()
|
||||
}
|
||||
}
|
||||
}
|
||||
item(key = "space") { Spacer(Modifier.height(24.dp)) }
|
||||
@@ -253,6 +337,7 @@ fun HomeScreen(
|
||||
profile = state.activeProfile ?: state.selected,
|
||||
traffic = traffic,
|
||||
durationSec = durationSec,
|
||||
probes = probes,
|
||||
onDismiss = { viewModel.openDetails(false) },
|
||||
onDisconnect = {
|
||||
viewModel.openDetails(false)
|
||||
@@ -275,6 +360,10 @@ fun HomeScreen(
|
||||
sheetProfile = null
|
||||
onEditProfile(profile)
|
||||
},
|
||||
onShare = {
|
||||
sheetProfile = null
|
||||
onShareProfile(profile)
|
||||
},
|
||||
onDelete = {
|
||||
sheetProfile = null
|
||||
pendingDelete = profile
|
||||
@@ -357,42 +446,6 @@ private fun ErrorBanner(modifier: Modifier = Modifier, onRetry: () -> Unit, onSh
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EmptyProfiles(modifier: Modifier = Modifier, onAdd: () -> Unit)
|
||||
{
|
||||
Surface(
|
||||
shape = MaterialTheme.shapes.large,
|
||||
color = MaterialTheme.colorScheme.surfaceContainerLow,
|
||||
modifier = modifier,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Icon(
|
||||
Icons.Rounded.Language,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(40.dp),
|
||||
)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Text(
|
||||
stringResource(R.string.no_profiles),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
OutlinedButton(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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TrafficStat(down: Boolean, total: Long, rate: Long)
|
||||
{
|
||||
@@ -430,6 +483,38 @@ private fun TrafficStat(down: Boolean, total: Long, rate: Long)
|
||||
}
|
||||
}
|
||||
|
||||
/** The full-width rounded action button of the connection card, with a springy press-scale. */
|
||||
@Composable
|
||||
private fun HeroButton(
|
||||
onClick: () -> Unit,
|
||||
enabled: Boolean = true,
|
||||
container: Color = MaterialTheme.colorScheme.surfaceContainerHighest,
|
||||
content: Color = MaterialTheme.colorScheme.onSurface,
|
||||
block: @Composable androidx.compose.foundation.layout.RowScope.() -> Unit,
|
||||
)
|
||||
{
|
||||
val interaction = remember { MutableInteractionSource() }
|
||||
val pressed by interaction.collectIsPressedAsState()
|
||||
val scale by animateFloatAsState(
|
||||
targetValue = if (pressed) 0.97f else 1f,
|
||||
animationSpec = spring(dampingRatio = 0.55f, stiffness = Spring.StiffnessMediumLow),
|
||||
label = "pressScale",
|
||||
)
|
||||
Button(
|
||||
onClick = onClick,
|
||||
enabled = enabled,
|
||||
interactionSource = interaction,
|
||||
colors = ButtonDefaults.buttonColors(containerColor = container, contentColor = content),
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(48.dp)
|
||||
.graphicsLayer {
|
||||
scaleX = scale
|
||||
scaleY = scale
|
||||
},
|
||||
) { block() }
|
||||
}
|
||||
@Composable
|
||||
private fun ConnectionCard(
|
||||
modifier: Modifier = Modifier,
|
||||
@@ -467,8 +552,7 @@ private fun ConnectionCard(
|
||||
) {
|
||||
Text(
|
||||
text = profile?.name ?: stringResource(R.string.no_profiles),
|
||||
style = MaterialTheme.typography.titleMedium.copy(fontSize = 18.sp),
|
||||
fontWeight = FontWeight.Medium,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
maxLines = 1,
|
||||
)
|
||||
Text(
|
||||
@@ -505,35 +589,21 @@ private fun ConnectionCard(
|
||||
}
|
||||
when (vpnState)
|
||||
{
|
||||
State.CONNECTED -> FilledTonalButton(
|
||||
State.CONNECTED -> HeroButton(
|
||||
onClick = onDisconnect,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(48.dp),
|
||||
colors = ButtonDefaults.filledTonalButtonColors(
|
||||
containerColor = MaterialTheme.colorScheme.errorContainer,
|
||||
contentColor = MaterialTheme.colorScheme.onErrorContainer,
|
||||
),
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
container = MaterialTheme.colorScheme.errorContainer,
|
||||
content = MaterialTheme.colorScheme.onErrorContainer,
|
||||
) { Text(stringResource(R.string.disconnect), fontSize = 16.sp) }
|
||||
State.CONNECTING, State.DISCONNECTING -> FilledTonalButton(
|
||||
onClick = onCancel,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(48.dp),
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
) {
|
||||
State.CONNECTING, State.DISCONNECTING -> HeroButton(onClick = onCancel) {
|
||||
CircularProgressIndicator(Modifier.size(18.dp), strokeWidth = 2.dp)
|
||||
Spacer(Modifier.size(12.dp))
|
||||
Text(stringResource(android.R.string.cancel), fontSize = 16.sp)
|
||||
}
|
||||
else -> Button(
|
||||
else -> HeroButton(
|
||||
onClick = onConnect,
|
||||
enabled = enabled,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(48.dp),
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
container = MaterialTheme.colorScheme.primary,
|
||||
content = MaterialTheme.colorScheme.onPrimary,
|
||||
) { Text(stringResource(R.string.connect), fontSize = 16.sp) }
|
||||
}
|
||||
}
|
||||
@@ -546,6 +616,7 @@ private fun ConnectionDetailsSheet(
|
||||
profile: VpnProfile?,
|
||||
traffic: TrafficUi,
|
||||
durationSec: Long,
|
||||
probes: Map<ProbeTarget, ProbeResult>,
|
||||
onDismiss: () -> Unit,
|
||||
onDisconnect: () -> Unit,
|
||||
)
|
||||
@@ -587,6 +658,16 @@ private fun ConnectionDetailsSheet(
|
||||
label = stringResource(R.string.traffic_up),
|
||||
value = "${formatBytes(traffic.bytesOut)} · ${formatRate(traffic.rateOut)}",
|
||||
)
|
||||
if (probes.isNotEmpty())
|
||||
{
|
||||
probes.sortedForDisplay().forEach { result ->
|
||||
DetailRow(
|
||||
icon = Icons.Rounded.Dns,
|
||||
label = stringResource(result.target.labelRes()),
|
||||
value = result.rttText(),
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(24.dp))
|
||||
FilledTonalButton(
|
||||
onClick = onDisconnect,
|
||||
@@ -636,6 +717,7 @@ fun ProfileActionsSheet(
|
||||
onDismiss: () -> Unit,
|
||||
onConnect: () -> Unit,
|
||||
onEdit: () -> Unit,
|
||||
onShare: () -> Unit,
|
||||
onDelete: () -> Unit,
|
||||
)
|
||||
{
|
||||
@@ -653,6 +735,8 @@ fun ProfileActionsSheet(
|
||||
ShxDivider(indent = false)
|
||||
SheetAction(icon = Icons.Rounded.Edit, label = stringResource(R.string.edit_profile), onClick = onEdit)
|
||||
ShxDivider(indent = false)
|
||||
SheetAction(icon = Icons.Rounded.Share, label = stringResource(R.string.share_profile), onClick = onShare)
|
||||
ShxDivider(indent = false)
|
||||
SheetAction(
|
||||
icon = Icons.Rounded.Delete,
|
||||
label = stringResource(R.string.delete_profile),
|
||||
@@ -680,3 +764,305 @@ private fun SheetAction(icon: ImageVector, label: String, onClick: () -> Unit, t
|
||||
Text(label, style = MaterialTheme.typography.bodyLarge)
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------- availability card ---------------- */
|
||||
|
||||
private val PROBE_ORDER = listOf(ProbeTarget.SERVER, ProbeTarget.GOOGLE, ProbeTarget.YOUTUBE, ProbeTarget.CLOUDFLARE)
|
||||
|
||||
/** Server first, then external targets in fixed order. */
|
||||
private fun Map<ProbeTarget, ProbeResult>.sortedForDisplay(): List<ProbeResult> =
|
||||
PROBE_ORDER.filter { containsKey(it) }.mapNotNull { this[it] }
|
||||
|
||||
private fun ProbeTarget.labelRes(): Int = when (this)
|
||||
{
|
||||
ProbeTarget.SERVER -> R.string.probe_target_server
|
||||
ProbeTarget.GOOGLE -> R.string.probe_target_google
|
||||
ProbeTarget.YOUTUBE -> R.string.probe_target_youtube
|
||||
ProbeTarget.CLOUDFLARE -> R.string.probe_target_cloudflare
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ProbeResult.rttText(): String = when
|
||||
{
|
||||
ok && rttMs != null -> stringResource(R.string.probe_rtt_fmt, rttMs)
|
||||
else -> stringResource(R.string.value_no_data)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AvailabilityCard(
|
||||
modifier: Modifier = Modifier,
|
||||
probes: Map<ProbeTarget, ProbeResult>,
|
||||
probing: Boolean,
|
||||
whiteList: WhiteListState,
|
||||
history: Map<ProbeTarget, List<ProbeResult?>>,
|
||||
onRefresh: () -> Unit,
|
||||
)
|
||||
{
|
||||
Surface(
|
||||
shape = MaterialTheme.shapes.large,
|
||||
color = MaterialTheme.colorScheme.surfaceContainerLow,
|
||||
border = androidx.compose.foundation.BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant),
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = ScreenPadding),
|
||||
)
|
||||
{
|
||||
PullToRefreshBox(isRefreshing = probing, onRefresh = onRefresh)
|
||||
{
|
||||
Column(Modifier.padding(16.dp))
|
||||
{
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
{
|
||||
Text(
|
||||
text = stringResource(R.string.availability_title),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
if (probing)
|
||||
{
|
||||
CircularProgressIndicator(Modifier.size(14.dp), strokeWidth = 2.dp)
|
||||
Spacer(Modifier.size(12.dp))
|
||||
}
|
||||
IconButton(onClick = onRefresh, enabled = !probing) {
|
||||
Icon(
|
||||
Icons.Rounded.Refresh,
|
||||
contentDescription = stringResource(R.string.availability_refresh),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(4.dp))
|
||||
val rows = probes.sortedForDisplay()
|
||||
if (rows.isEmpty())
|
||||
{
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
modifier = Modifier.padding(top = 4.dp),
|
||||
)
|
||||
{
|
||||
CircularProgressIndicator(Modifier.size(14.dp), strokeWidth = 2.dp)
|
||||
Text(
|
||||
text = stringResource(R.string.availability_measuring),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
rows.forEachIndexed { index, result ->
|
||||
ProbeRow(result = result, measuring = probing, history = history[result.target].orEmpty())
|
||||
if (index < rows.lastIndex)
|
||||
{
|
||||
ShxDivider()
|
||||
}
|
||||
}
|
||||
}
|
||||
if (whiteList.config.enabled)
|
||||
{
|
||||
Spacer(Modifier.height(8.dp))
|
||||
ShxDivider()
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.whitelist_direct_section),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
DirectProbeRow(
|
||||
label = stringResource(R.string.whitelist_foreign_direct),
|
||||
ok = whiteList.foreignDirectOk,
|
||||
rttMs = whiteList.foreignDirectRttMs,
|
||||
)
|
||||
ShxDivider()
|
||||
DirectProbeRow(
|
||||
label = stringResource(R.string.whitelist_domestic_direct),
|
||||
ok = whiteList.domesticDirectOk,
|
||||
rttMs = whiteList.domesticDirectRttMs,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DirectProbeRow(label: String, ok: Boolean?, rttMs: Long?)
|
||||
{
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 8.dp),
|
||||
)
|
||||
{
|
||||
val status = LocalShxStatusColors.current
|
||||
/* tri-state: null = not checked yet, false = checked and unreachable */
|
||||
val dotColor = when (ok)
|
||||
{
|
||||
true -> status.connected
|
||||
false -> status.disconnected
|
||||
null -> MaterialTheme.colorScheme.outlineVariant
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(8.dp)
|
||||
.background(dotColor, CircleShape),
|
||||
)
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(horizontal = 12.dp),
|
||||
)
|
||||
Text(
|
||||
text = when
|
||||
{
|
||||
ok == true && rttMs != null -> stringResource(R.string.probe_rtt_fmt, rttMs)
|
||||
ok == false -> stringResource(R.string.value_unreachable)
|
||||
else -> stringResource(R.string.value_no_data)
|
||||
},
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = when (ok)
|
||||
{
|
||||
true -> MaterialTheme.colorScheme.onSurface
|
||||
false -> status.disconnected
|
||||
null -> MaterialTheme.colorScheme.onSurfaceVariant
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun WhiteListBanner(modifier: Modifier = Modifier)
|
||||
{
|
||||
val status = LocalShxStatusColors.current
|
||||
Surface(
|
||||
shape = MaterialTheme.shapes.large,
|
||||
color = status.connectingContainer,
|
||||
contentColor = status.onConnectingContainer,
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
)
|
||||
{
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(16.dp),
|
||||
)
|
||||
{
|
||||
Icon(Icons.Rounded.GppMaybe, contentDescription = null)
|
||||
Spacer(Modifier.size(12.dp))
|
||||
Column {
|
||||
Text(
|
||||
text = stringResource(R.string.whitelist_active_title),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.whitelist_active_subtitle),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ProbeRow(result: ProbeResult, measuring: Boolean, history: List<ProbeResult?> = emptyList())
|
||||
{
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 8.dp),
|
||||
)
|
||||
{
|
||||
val status = LocalShxStatusColors.current
|
||||
val dotColor = when
|
||||
{
|
||||
!result.ok -> status.disconnected
|
||||
result.rttMs != null && result.rttMs < WARN_RTT_MS -> status.connected
|
||||
result.rttMs != null && result.rttMs < FAIL_RTT_MS -> status.connecting
|
||||
else -> status.disconnected
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(8.dp)
|
||||
.background(dotColor, CircleShape),
|
||||
)
|
||||
Text(
|
||||
text = stringResource(result.target.labelRes()),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(horizontal = 12.dp),
|
||||
)
|
||||
RttSparkline(samples = history)
|
||||
Spacer(Modifier.size(8.dp))
|
||||
if (result.ok && result.rttMs == null && measuring)
|
||||
{
|
||||
CircularProgressIndicator(Modifier.size(12.dp), strokeWidth = 1.5.dp)
|
||||
}
|
||||
else
|
||||
{
|
||||
Text(
|
||||
text = result.rttText(),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = if (result.ok) MaterialTheme.colorScheme.onSurface else status.disconnected,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private const val RTT_HISTORY = 16
|
||||
|
||||
/** Tiny bar chart of the last RTT samples; missing samples render as dim ticks. */
|
||||
@Composable
|
||||
private fun RttSparkline(samples: List<ProbeResult?>)
|
||||
{
|
||||
val status = LocalShxStatusColors.current
|
||||
val idle = MaterialTheme.colorScheme.outlineVariant
|
||||
Canvas(modifier = Modifier.size(width = 52.dp, height = 16.dp))
|
||||
{
|
||||
val barW = size.width / RTT_HISTORY
|
||||
val barActual = barW * 0.6f
|
||||
val shown = samples.takeLast(RTT_HISTORY)
|
||||
shown.forEachIndexed { index, sample ->
|
||||
val x = size.width - (shown.size - index) * barW
|
||||
val rtt = sample?.rttMs
|
||||
if (sample == null || rtt == null)
|
||||
{
|
||||
drawRoundRect(
|
||||
color = idle.copy(alpha = 0.5f),
|
||||
topLeft = Offset(x + (barW - barActual) / 2, size.height - 2.dp.toPx()),
|
||||
size = Size(barActual, 2.dp.toPx()),
|
||||
cornerRadius = CornerRadius(1.dp.toPx()),
|
||||
)
|
||||
}
|
||||
else
|
||||
{
|
||||
val fraction = (rtt.toFloat() / FAIL_RTT_MS).coerceIn(0.12f, 1f)
|
||||
val barH = size.height * fraction
|
||||
val color = when
|
||||
{
|
||||
!sample.ok -> status.disconnected
|
||||
rtt < WARN_RTT_MS -> status.connected
|
||||
else -> status.connecting
|
||||
}
|
||||
drawRoundRect(
|
||||
color = color,
|
||||
topLeft = Offset(x + (barW - barActual) / 2, size.height - barH),
|
||||
size = Size(barActual, barH),
|
||||
cornerRadius = CornerRadius(2.dp.toPx()),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private const val WARN_RTT_MS = 150L
|
||||
private const val FAIL_RTT_MS = 400L
|
||||
+148
-2
@@ -16,6 +16,7 @@ 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
|
||||
@@ -24,6 +25,7 @@ import android.content.IntentFilter
|
||||
import android.content.ServiceConnection
|
||||
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,6 +33,7 @@ import androidx.lifecycle.repeatOnLifecycle
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.localbroadcastmanager.content.LocalBroadcastManager
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
@@ -38,12 +41,22 @@ 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.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.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.ui.VpnProfileControlActivity
|
||||
import org.strongswan.android.utils.Constants
|
||||
|
||||
@@ -59,6 +72,7 @@ enum class ShxTab
|
||||
{
|
||||
HOME,
|
||||
PROFILES,
|
||||
ROUTING,
|
||||
SETTINGS,
|
||||
}
|
||||
|
||||
@@ -82,6 +96,17 @@ 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
|
||||
|
||||
private var service: VpnStateService? = null
|
||||
|
||||
/* written from the main thread (pullState) and the poll coroutine (Default) */
|
||||
@@ -122,6 +147,7 @@ class HomeViewModel(app: Application) : AndroidViewModel(app), VpnStateService.V
|
||||
IntentFilter(Constants.VPN_PROFILES_CHANGED)
|
||||
)
|
||||
reloadProfiles()
|
||||
WhiteListManager.getInstance(ctx).refreshIfStale()
|
||||
/* poll traffic only while the app is visible, and off the main thread */
|
||||
viewModelScope.launch {
|
||||
ProcessLifecycleOwner.get().lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
|
||||
@@ -132,6 +158,80 @@ class HomeViewModel(app: Application) : AndroidViewModel(app), VpnStateService.V
|
||||
}
|
||||
}
|
||||
}
|
||||
/* probe availability while the app is visible and the VPN is connected */
|
||||
viewModelScope.launch {
|
||||
ProcessLifecycleOwner.get().lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
|
||||
var lastProbeAt = 0L
|
||||
while (isActive)
|
||||
{
|
||||
val connected = _ui.value.vpnState == State.CONNECTED
|
||||
if (connected && SystemClock.elapsedRealtime() - lastProbeAt >= PROBE_INTERVAL_MS)
|
||||
{
|
||||
lastProbeAt = SystemClock.elapsedRealtime()
|
||||
probeNow()
|
||||
}
|
||||
delay(PROBE_TICK_MS)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Immediately reruns the availability probe round (refresh button). */
|
||||
fun refreshProbes()
|
||||
{
|
||||
WhiteListManager.getInstance(getApplication()).checkNow()
|
||||
viewModelScope.launch { probeNow() }
|
||||
}
|
||||
|
||||
private suspend fun probeNow()
|
||||
{
|
||||
/* 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)
|
||||
}
|
||||
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()
|
||||
@@ -201,6 +301,46 @@ class HomeViewModel(app: Application) : AndroidViewModel(app), VpnStateService.V
|
||||
}
|
||||
}
|
||||
|
||||
/** Re-inserts a just-deleted profile (the snackbar "Undo" action). */
|
||||
fun restore(profile: VpnProfile)
|
||||
{
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
val source = VpnProfileSource(getApplication())
|
||||
source.open()
|
||||
source.insertProfile(profile)
|
||||
source.close()
|
||||
val intent = Intent(Constants.VPN_PROFILES_CHANGED)
|
||||
LocalBroadcastManager.getInstance(getApplication()).sendBroadcast(intent)
|
||||
reloadProfiles()
|
||||
}
|
||||
}
|
||||
|
||||
/** 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)
|
||||
{
|
||||
Toast.makeText(context, R.string.share_profile_failed, Toast.LENGTH_LONG).show()
|
||||
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)
|
||||
{
|
||||
Toast.makeText(context, R.string.share_profile_failed, Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun reloadProfiles()
|
||||
{
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
@@ -232,15 +372,19 @@ class HomeViewModel(app: Application) : AndroidViewModel(app), VpnStateService.V
|
||||
{
|
||||
connectedAt = 0
|
||||
_traffic.value = TrafficUi()
|
||||
_probes.value = emptyMap()
|
||||
}
|
||||
/* 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,
|
||||
activeProfile = svc.profile,
|
||||
selected = svc.profile ?: it.selected,
|
||||
)
|
||||
if (next == it) it else next
|
||||
}
|
||||
}
|
||||
|
||||
@@ -283,6 +427,8 @@ class HomeViewModel(app: Application) : AndroidViewModel(app), VpnStateService.V
|
||||
|
||||
companion object
|
||||
{
|
||||
private const val TRAFFIC_POLL_MS = 500L
|
||||
private const val TRAFFIC_POLL_MS = 1000L
|
||||
private const val PROBE_TICK_MS = 1000L
|
||||
private const val PROBE_INTERVAL_MS = 10_000L
|
||||
}
|
||||
}
|
||||
+331
@@ -0,0 +1,331 @@
|
||||
/*
|
||||
* Copyright (C) 2026 SHX VPN
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or modify it
|
||||
* under the terms of the GNU General Public License as published by the
|
||||
* Free Software Foundation; either version 2 of the License, or (at your
|
||||
* option) any later version.
|
||||
*/
|
||||
|
||||
package org.strongswan.android.ui.compose
|
||||
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.compose.foundation.clickable
|
||||
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.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.rounded.Close
|
||||
import androidx.compose.material.icons.rounded.Dns
|
||||
import androidx.compose.material.icons.rounded.FileDownload
|
||||
import androidx.compose.material.icons.rounded.Language
|
||||
import androidx.compose.material.icons.rounded.VpnKey
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.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.components.ShxCard
|
||||
import org.strongswan.android.ui.compose.theme.ScreenPadding
|
||||
import org.strongswan.android.ui.compose.theme.ShxTheme
|
||||
|
||||
/**
|
||||
* 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)
|
||||
}
|
||||
|
||||
/** Renders the import screen in the transparent import activity. */
|
||||
fun ComponentActivity.installImportScreen(model: ImportUiModel, onImport: Runnable, onClose: Runnable,
|
||||
onImportPkcs12: Runnable, onSelectUserCert: Runnable)
|
||||
{
|
||||
setContent {
|
||||
ShxTheme {
|
||||
ImportScreen(
|
||||
model = model,
|
||||
onImport = onImport::run,
|
||||
onClose = onClose::run,
|
||||
onImportPkcs12 = onImportPkcs12::run,
|
||||
onSelectUserCert = onSelectUserCert::run,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ImportScreen(
|
||||
model: ImportUiModel,
|
||||
onImport: () -> Unit,
|
||||
onClose: () -> Unit,
|
||||
onImportPkcs12: () -> Unit,
|
||||
onSelectUserCert: () -> Unit,
|
||||
)
|
||||
{
|
||||
Column(Modifier.fillMaxSize())
|
||||
{
|
||||
TopAppBar(
|
||||
title = { Text(stringResource(R.string.profile_import), fontWeight = FontWeight.SemiBold) },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onClose) {
|
||||
Icon(Icons.Rounded.Close, contentDescription = stringResource(android.R.string.cancel))
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
if (model.showForm && !model.hideImport)
|
||||
{
|
||||
TextButton(onClick = onImport) { Text(stringResource(R.string.profile_edit_import)) }
|
||||
}
|
||||
},
|
||||
colors = androidx.compose.material3.TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.background,
|
||||
),
|
||||
)
|
||||
if (model.loading)
|
||||
{
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
return
|
||||
}
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = ScreenPadding)
|
||||
.padding(bottom = 24.dp),
|
||||
)
|
||||
{
|
||||
if (model.existsWarning != null)
|
||||
{
|
||||
ImportBanner(
|
||||
text = model.existsWarning ?: "",
|
||||
container = MaterialTheme.colorScheme.errorContainer,
|
||||
content = MaterialTheme.colorScheme.onErrorContainer,
|
||||
)
|
||||
}
|
||||
ShxCard(modifier = Modifier.padding(top = 12.dp))
|
||||
{
|
||||
ImportInfoRow(Icons.Rounded.Language, stringResource(R.string.profile_name_label_simple), model.name)
|
||||
ImportInfoRow(Icons.Rounded.Dns, stringResource(R.string.profile_gateway_label), model.gateway)
|
||||
ImportInfoRow(Icons.Rounded.VpnKey, stringResource(R.string.profile_vpn_type_label), model.vpnType, divider = false)
|
||||
}
|
||||
|
||||
if (model.requireUserPass)
|
||||
{
|
||||
Text(
|
||||
stringResource(R.string.profile_username_label).uppercase().replace(":", ""),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(start = 16.dp, top = 20.dp, bottom = 8.dp),
|
||||
)
|
||||
ShxCard {
|
||||
Column(Modifier.padding(16.dp)) {
|
||||
OutlinedTextField(
|
||||
value = model.username,
|
||||
onValueChange = { model.username = it },
|
||||
label = { Text(stringResource(R.string.profile_username_label)) },
|
||||
singleLine = true,
|
||||
enabled = model.usernameEditable,
|
||||
isError = model.usernameError,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
OutlinedTextField(
|
||||
value = model.password,
|
||||
onValueChange = { model.password = it },
|
||||
label = { Text(stringResource(R.string.profile_password_label)) },
|
||||
supportingText = { Text(stringResource(R.string.profile_password_hint)) },
|
||||
singleLine = true,
|
||||
enabled = model.passwordEditable,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
}
|
||||
if (model.sharedSecretWarning)
|
||||
{
|
||||
ImportBanner(
|
||||
text = stringResource(R.string.profile_import_shared_secret),
|
||||
container = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||
content = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 8.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (model.requireUserCert)
|
||||
{
|
||||
Text(
|
||||
stringResource(R.string.profile_user_certificate_label).uppercase().replace(":", ""),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(start = 16.dp, top = 20.dp, bottom = 8.dp),
|
||||
)
|
||||
ShxCard {
|
||||
Column {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onSelectUserCert)
|
||||
.padding(horizontal = 16.dp, vertical = 14.dp),
|
||||
) {
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(
|
||||
model.userCertAlias ?: stringResource(R.string.profile_user_select_certificate_label),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = if (model.userCertError) MaterialTheme.colorScheme.error
|
||||
else MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
Text(
|
||||
model.userCertSubject
|
||||
?: stringResource(R.string.profile_user_select_certificate),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
if (model.userCertLoading)
|
||||
{
|
||||
CircularProgressIndicator(Modifier.size(18.dp), strokeWidth = 2.dp)
|
||||
}
|
||||
}
|
||||
if (model.showImportUserCert)
|
||||
{
|
||||
TextButton(onClick = onImportPkcs12, modifier = Modifier.padding(start = 8.dp, bottom = 8.dp)) {
|
||||
Icon(Icons.Rounded.FileDownload, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
Spacer(Modifier.size(8.dp))
|
||||
Text(stringResource(R.string.profile_cert_import))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (model.showRemoteCert)
|
||||
{
|
||||
Text(
|
||||
stringResource(R.string.profile_ca_label).uppercase().replace(":", ""),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(start = 16.dp, top = 20.dp, bottom = 8.dp),
|
||||
)
|
||||
ShxCard {
|
||||
ImportInfoRow(Icons.Rounded.Dns, model.remoteCertPrimary ?: "", model.remoteCertSecondary ?: "", divider = false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ImportBanner(
|
||||
text: String,
|
||||
container: androidx.compose.ui.graphics.Color,
|
||||
content: androidx.compose.ui.graphics.Color,
|
||||
modifier: Modifier = Modifier,
|
||||
)
|
||||
{
|
||||
Surface(
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
color = container,
|
||||
contentColor = content,
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 8.dp),
|
||||
) {
|
||||
Text(
|
||||
text,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ImportInfoRow(icon: ImageVector, label: String, value: String, divider: Boolean = true)
|
||||
{
|
||||
Column {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
) {
|
||||
Icon(
|
||||
icon,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
Column(Modifier.padding(start = 16.dp)) {
|
||||
Text(
|
||||
label,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Text(value, style = MaterialTheme.typography.bodyLarge)
|
||||
}
|
||||
}
|
||||
if (divider)
|
||||
{
|
||||
androidx.compose.material3.HorizontalDivider(
|
||||
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.6f),
|
||||
modifier = Modifier.padding(start = 52.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+30
-8
@@ -46,9 +46,9 @@ 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.ExposedDropdownMenuBox
|
||||
import androidx.compose.material3.ExposedDropdownMenuDefaults
|
||||
import androidx.compose.material3.MenuAnchorType
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
@@ -130,6 +130,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,
|
||||
)
|
||||
|
||||
@@ -228,7 +232,9 @@ fun VpnProfile.toFormState(): ProfileFormState
|
||||
|
||||
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 +255,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,6 +281,8 @@ 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
|
||||
}
|
||||
|
||||
@@ -318,7 +332,7 @@ fun ProfileFormScreen(
|
||||
title = { Text(title, maxLines = 1) },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onCancel) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null)
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back))
|
||||
}
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
@@ -396,7 +410,7 @@ fun ProfileFormScreen(
|
||||
enabled = enabled,
|
||||
label = { Text(stringResource(R.string.profile_vpn_type_label)) },
|
||||
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(typeExpanded) },
|
||||
modifier = Modifier.fillMaxWidth().menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable),
|
||||
modifier = Modifier.fillMaxWidth().menuAnchor(MenuAnchorType.PrimaryNotEditable),
|
||||
)
|
||||
ExposedDropdownMenu(expanded = typeExpanded, onDismissRequest = { typeExpanded = false }) {
|
||||
VpnType.values().forEachIndexed { index, type ->
|
||||
@@ -533,9 +547,13 @@ fun ProfileFormScreen(
|
||||
SwitchRow(stringResource(R.string.profile_ipv6_transport_label), state.ipv6Transport, enabled) { onChange(state.copy(ipv6Transport = it)) }
|
||||
OutlinedTextField(state.includedSubnets, { onChange(state.copy(includedSubnets = it)) },
|
||||
label = { Text(stringResource(R.string.profile_included_subnets_label)) },
|
||||
isError = state.includedSubnetsError != null,
|
||||
supportingText = { state.includedSubnetsError?.let { Text(it) } },
|
||||
enabled = enabled, modifier = Modifier.fillMaxWidth())
|
||||
OutlinedTextField(state.excludedSubnets, { onChange(state.copy(excludedSubnets = it)) },
|
||||
label = { Text(stringResource(R.string.profile_excluded_subnets_label)) },
|
||||
isError = state.excludedSubnetsError != null,
|
||||
supportingText = { state.excludedSubnetsError?.let { Text(it) } },
|
||||
enabled = enabled, modifier = Modifier.fillMaxWidth())
|
||||
SwitchRow(
|
||||
label = stringResource(R.string.profile_split_tunnelingv4_title),
|
||||
@@ -556,7 +574,7 @@ fun ProfileFormScreen(
|
||||
enabled = enabled,
|
||||
label = { Text(stringResource(R.string.profile_select_apps)) },
|
||||
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(appsExpanded) },
|
||||
modifier = Modifier.fillMaxWidth().menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable),
|
||||
modifier = Modifier.fillMaxWidth().menuAnchor(MenuAnchorType.PrimaryNotEditable),
|
||||
)
|
||||
ExposedDropdownMenu(expanded = appsExpanded, onDismissRequest = { appsExpanded = false }) {
|
||||
SelectedAppsHandling.values().forEachIndexed { index, handling ->
|
||||
@@ -582,9 +600,13 @@ fun ProfileFormScreen(
|
||||
}
|
||||
OutlinedTextField(state.ikeProposal, { onChange(state.copy(ikeProposal = it)) },
|
||||
label = { Text(stringResource(R.string.profile_proposals_ike_label)) },
|
||||
isError = state.ikeProposalError != null,
|
||||
supportingText = { state.ikeProposalError?.let { Text(it) } },
|
||||
enabled = enabled, modifier = Modifier.fillMaxWidth())
|
||||
OutlinedTextField(state.espProposal, { onChange(state.copy(espProposal = it)) },
|
||||
label = { Text(stringResource(R.string.profile_proposals_esp_label)) },
|
||||
isError = state.espProposalError != null,
|
||||
supportingText = { state.espProposalError?.let { Text(it) } },
|
||||
enabled = enabled, modifier = Modifier.fillMaxWidth())
|
||||
OutlinedTextField(state.proxyHost, { onChange(state.copy(proxyHost = it)) },
|
||||
label = { Text(stringResource(R.string.profile_proxy_host_label)) },
|
||||
|
||||
+7
-3
@@ -34,7 +34,6 @@ 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
|
||||
@@ -68,6 +67,7 @@ fun ShxProfileRow(
|
||||
leadingIcon: ImageVector = Icons.Rounded.Language,
|
||||
onClick: () -> Unit,
|
||||
onLongClick: () -> Unit = {},
|
||||
onLongClickLabel: String? = null,
|
||||
trailing: (@Composable () -> Unit)? = null,
|
||||
)
|
||||
{
|
||||
@@ -75,7 +75,11 @@ fun ShxProfileRow(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.combinedClickable(onClick = onClick, onLongClick = onLongClick)
|
||||
.combinedClickable(
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick,
|
||||
onLongClickLabel = onLongClickLabel,
|
||||
)
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
) {
|
||||
ProfileAvatar(icon = leadingIcon, connected = active)
|
||||
@@ -86,7 +90,7 @@ fun ShxProfileRow(
|
||||
) {
|
||||
Text(
|
||||
text = profile.name ?: "",
|
||||
style = MaterialTheme.typography.titleSmall.copy(fontSize = 16.sp),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
fontWeight = FontWeight.Medium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
|
||||
+182
-161
@@ -9,16 +9,11 @@
|
||||
|
||||
package org.strongswan.android.ui.compose
|
||||
|
||||
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.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.material.icons.Icons
|
||||
@@ -30,32 +25,43 @@ import androidx.compose.material.icons.rounded.Language
|
||||
import androidx.compose.material.icons.rounded.MoreVert
|
||||
import androidx.compose.material.icons.rounded.PlayArrow
|
||||
import androidx.compose.material.icons.rounded.Search
|
||||
import androidx.compose.material.icons.rounded.SearchOff
|
||||
import androidx.compose.material.icons.rounded.Share
|
||||
import androidx.compose.material.icons.rounded.Stop
|
||||
import androidx.compose.material3.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.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 org.strongswan.android.R
|
||||
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
|
||||
|
||||
@@ -67,12 +73,16 @@ fun ProfilesScreen(
|
||||
onDisconnect: () -> Unit,
|
||||
onEdit: (VpnProfile) -> Unit,
|
||||
onDelete: (VpnProfile) -> Unit,
|
||||
onRestore: (VpnProfile) -> Unit,
|
||||
onShare: (VpnProfile) -> Unit,
|
||||
onAdd: () -> Unit,
|
||||
)
|
||||
{
|
||||
val context = LocalContext.current
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
val scope = rememberCoroutineScope()
|
||||
var query by remember { mutableStateOf("") }
|
||||
var menuFor by remember { mutableStateOf<VpnProfile?>(null) }
|
||||
var pendingDelete by remember { mutableStateOf<VpnProfile?>(null) }
|
||||
val activeUuid = state.activeProfile?.getUUID()
|
||||
val connected = state.vpnState == State.CONNECTED
|
||||
val profiles = state.profiles
|
||||
@@ -84,186 +94,197 @@ fun ProfilesScreen(
|
||||
}
|
||||
}
|
||||
|
||||
Column(Modifier.fillMaxSize())
|
||||
fun deleteWithUndo(profile: VpnProfile)
|
||||
{
|
||||
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)
|
||||
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)
|
||||
}
|
||||
}
|
||||
else
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||
containerColor = MaterialTheme.colorScheme.background,
|
||||
) { _ ->
|
||||
Column(Modifier.fillMaxSize())
|
||||
{
|
||||
Surface(
|
||||
shape = MaterialTheme.shapes.large,
|
||||
color = MaterialTheme.colorScheme.surfaceContainerLow,
|
||||
TopAppBar(
|
||||
title = { Text(stringResource(R.string.nav_profiles), fontWeight = FontWeight.SemiBold) },
|
||||
actions = {
|
||||
IconButton(onClick = onAdd) {
|
||||
Icon(Icons.Rounded.Add, contentDescription = stringResource(R.string.add_profile_cta))
|
||||
}
|
||||
},
|
||||
colors = androidx.compose.material3.TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.background,
|
||||
),
|
||||
)
|
||||
SearchBar(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 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,
|
||||
.padding(horizontal = ScreenPadding, vertical = 4.dp),
|
||||
inputField = {
|
||||
SearchBarDefaults.InputField(
|
||||
query = query,
|
||||
onQueryChange = { query = it },
|
||||
onSearch = {},
|
||||
expanded = false,
|
||||
onExpandedChange = {},
|
||||
placeholder = { Text(stringResource(R.string.search_profiles)) },
|
||||
leadingIcon = { Icon(Icons.Rounded.Search, contentDescription = null) },
|
||||
trailingIcon = {
|
||||
if (query.isNotEmpty())
|
||||
{
|
||||
IconButton(onClick = { query = "" }) {
|
||||
Icon(Icons.Rounded.Close, contentDescription = stringResource(R.string.action_clear_search))
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
expanded = false,
|
||||
onExpandedChange = {},
|
||||
) {}
|
||||
if (filtered.isEmpty())
|
||||
{
|
||||
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
|
||||
Column(Modifier.animateItem()) {
|
||||
Box {
|
||||
ShxProfileRow(
|
||||
profile = profile,
|
||||
active = isActive,
|
||||
onClick = { if (isActive) onDisconnect() else onConnect(profile) },
|
||||
onLongClick = { menuFor = profile },
|
||||
trailing = {
|
||||
IconButton(onClick = { menuFor = profile }) {
|
||||
Icon(
|
||||
Icons.Rounded.MoreVert,
|
||||
contentDescription = stringResource(R.string.action_more_options),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
if (isActive) ActiveBadge()
|
||||
},
|
||||
)
|
||||
DropdownMenu(expanded = menuFor == profile, onDismissRequest = { menuFor = null })
|
||||
{
|
||||
if (isActive)
|
||||
{
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.disconnect)) },
|
||||
leadingIcon = { Icon(Icons.Rounded.Stop, contentDescription = null) },
|
||||
onClick = {
|
||||
menuFor = null
|
||||
onDisconnect()
|
||||
},
|
||||
)
|
||||
}
|
||||
else
|
||||
{
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.connect)) },
|
||||
leadingIcon = { Icon(Icons.Rounded.PlayArrow, contentDescription = null) },
|
||||
onClick = {
|
||||
menuFor = null
|
||||
onConnect(profile)
|
||||
},
|
||||
)
|
||||
}
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.edit_profile)) },
|
||||
leadingIcon = { Icon(Icons.Rounded.Edit, contentDescription = null) },
|
||||
onClick = {
|
||||
menuFor = null
|
||||
onEdit(profile)
|
||||
},
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.share_profile)) },
|
||||
leadingIcon = { Icon(Icons.Rounded.Share, contentDescription = null) },
|
||||
onClick = {
|
||||
menuFor = null
|
||||
onShare(profile)
|
||||
},
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = {
|
||||
Text(
|
||||
stringResource(R.string.delete_profile),
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
},
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
Icons.Rounded.Delete,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
},
|
||||
onClick = {
|
||||
menuFor = null
|
||||
deleteWithUndo(profile)
|
||||
},
|
||||
)
|
||||
}
|
||||
if (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
|
||||
if (index < filtered.lastIndex)
|
||||
{
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.connect)) },
|
||||
leadingIcon = { Icon(Icons.Rounded.PlayArrow, contentDescription = null) },
|
||||
onClick = {
|
||||
menuFor = null
|
||||
onConnect(profile)
|
||||
},
|
||||
)
|
||||
ShxDivider()
|
||||
}
|
||||
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
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
if (index < filtered.lastIndex)
|
||||
{
|
||||
ShxDivider()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EmptyProfilesState(query: String, onAdd: () -> Unit)
|
||||
{
|
||||
Column(
|
||||
ShxCard(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(32.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
.padding(horizontal = ScreenPadding, vertical = 12.dp),
|
||||
)
|
||||
{
|
||||
Icon(
|
||||
Icons.Rounded.Language,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(48.dp),
|
||||
)
|
||||
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))
|
||||
}
|
||||
}
|
||||
ShxEmptyState(
|
||||
icon = Icons.Rounded.Language,
|
||||
message = stringResource(R.string.no_profiles),
|
||||
actionLabel = stringResource(R.string.add_profile_cta),
|
||||
onAction = onAdd,
|
||||
)
|
||||
}
|
||||
else
|
||||
{
|
||||
ShxEmptyState(
|
||||
icon = Icons.Rounded.SearchOff,
|
||||
message = stringResource(R.string.nothing_found),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+960
@@ -0,0 +1,960 @@
|
||||
/*
|
||||
* 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.app.Activity
|
||||
import android.content.Intent
|
||||
import android.text.format.DateUtils
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.core.Spring
|
||||
import androidx.compose.animation.core.spring
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.expandVertically
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.shrinkVertically
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.RowScope
|
||||
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.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.rounded.Add
|
||||
import androidx.compose.material.icons.rounded.AltRoute
|
||||
import androidx.compose.material.icons.rounded.Block
|
||||
import androidx.compose.material.icons.rounded.Close
|
||||
import androidx.compose.material.icons.rounded.Delete
|
||||
import androidx.compose.material.icons.rounded.ExpandLess
|
||||
import androidx.compose.material.icons.rounded.ExpandMore
|
||||
import androidx.compose.material.icons.rounded.Refresh
|
||||
import androidx.compose.material3.AssistChip
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SnackbarDuration
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.SnackbarResult
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.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.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
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.logic.routing.RuleSet
|
||||
import org.strongswan.android.logic.routing.RouteCheck
|
||||
import org.strongswan.android.logic.routing.RoutingManager
|
||||
import org.strongswan.android.logic.routing.RoutingPresets
|
||||
import org.strongswan.android.logic.routing.RuIpListManager
|
||||
import org.strongswan.android.logic.routing.RuIpListState
|
||||
import org.strongswan.android.logic.routing.RuIpListStatus
|
||||
import org.strongswan.android.logic.CharonVpnService
|
||||
import org.strongswan.android.utils.Constants
|
||||
import org.strongswan.android.ui.SelectedApplicationsActivity
|
||||
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
|
||||
|
||||
@Composable
|
||||
fun RoutingScreen()
|
||||
{
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
|
||||
Scaffold(
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||
containerColor = MaterialTheme.colorScheme.background,
|
||||
) { _ ->
|
||||
RoutingContent(snackbarHostState)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun RoutingContent(snackbarHostState: SnackbarHostState)
|
||||
{
|
||||
val context = LocalContext.current
|
||||
val manager = remember { RoutingManager.getInstance(context) }
|
||||
val ruList = remember { RuIpListManager.getInstance(context) }
|
||||
val state by manager.state.collectAsStateWithLifecycle()
|
||||
val ruState by ruList.state.collectAsStateWithLifecycle()
|
||||
val config = state.config
|
||||
var expandedId by remember { mutableStateOf<String?>(null) }
|
||||
var focusNameId by remember { mutableStateOf<String?>(null) }
|
||||
var pickTarget by remember { mutableStateOf<String?>(null) }
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
val pickApps = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
||||
val id = pickTarget
|
||||
if (result.resultCode == Activity.RESULT_OK && id != null)
|
||||
{
|
||||
val packages = result.data?.getStringArrayListExtra(VpnProfileDataSource.KEY_SELECTED_APPS_LIST)
|
||||
?: arrayListOf()
|
||||
manager.updateRuleSetPackages(id, packages.toSet())
|
||||
}
|
||||
pickTarget = null
|
||||
}
|
||||
|
||||
/* real number of apps excluded from the tunnel: prefixes/keywords expanded
|
||||
* against installed apps (cached per config version inside the manager) */
|
||||
var excludedApps by remember { mutableStateOf(0) }
|
||||
LaunchedEffect(config)
|
||||
{
|
||||
excludedApps = if (config.enabled)
|
||||
{
|
||||
withContext(Dispatchers.IO) { manager.computeExcludedPackages().size }
|
||||
}
|
||||
else
|
||||
{
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteSetWithUndo(ruleSet: RuleSet)
|
||||
{
|
||||
scope.launch {
|
||||
manager.deleteRuleSet(ruleSet.id)
|
||||
if (expandedId == ruleSet.id)
|
||||
{
|
||||
expandedId = null
|
||||
}
|
||||
val result = snackbarHostState.showSnackbar(
|
||||
message = context.getString(R.string.routing_delete_snackbar),
|
||||
actionLabel = context.getString(R.string.action_undo),
|
||||
duration = SnackbarDuration.Long,
|
||||
)
|
||||
if (result == SnackbarResult.ActionPerformed)
|
||||
{
|
||||
manager.restoreRuleSet(ruleSet)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(bottom = 24.dp),
|
||||
)
|
||||
{
|
||||
TopAppBar(
|
||||
title = { Text(stringResource(R.string.routing_title), fontWeight = FontWeight.SemiBold) },
|
||||
colors = androidx.compose.material3.TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.background,
|
||||
),
|
||||
)
|
||||
|
||||
/* ---- hero: master toggle + live summary ---- */
|
||||
RoutingHeroCard(
|
||||
enabled = config.enabled,
|
||||
setCount = config.ruleSets.count { it.enabled },
|
||||
appCount = excludedApps,
|
||||
onToggle = { manager.setGlobalEnabled(it) },
|
||||
modifier = Modifier.padding(horizontal = ScreenPadding),
|
||||
)
|
||||
|
||||
/* ---- geoip RU-direct card with info chips ---- */
|
||||
val refreshGeoip = {
|
||||
scope.launch {
|
||||
val ok = ruList.updateNow()
|
||||
val message = if (ok)
|
||||
{
|
||||
val total = ruList.state.value.let { it.ipv4Ranges + it.ipv6Ranges }
|
||||
context.getString(R.string.rusplit_updated, total)
|
||||
}
|
||||
else
|
||||
{
|
||||
context.getString(R.string.rusplit_update_failed)
|
||||
}
|
||||
snackbarHostState.showSnackbar(message)
|
||||
}
|
||||
}
|
||||
RoutingCard(modifier = Modifier.padding(top = 12.dp, start = ScreenPadding, end = ScreenPadding))
|
||||
{
|
||||
RoutingToggleRow(
|
||||
icon = Icons.Rounded.Block,
|
||||
title = stringResource(R.string.rusplit_toggle_title), subtitle = stringResource(R.string.rusplit_toggle_summary),
|
||||
checked = ruState.enabled,
|
||||
onCheckedChange = { ruList.setEnabled(it) },
|
||||
actions = {
|
||||
if (ruState.status == RuIpListStatus.UPDATING)
|
||||
{
|
||||
CircularProgressIndicator(Modifier.size(14.dp), strokeWidth = 2.dp)
|
||||
Spacer(Modifier.width(12.dp))
|
||||
}
|
||||
else
|
||||
{
|
||||
IconButton(onClick = { refreshGeoip() })
|
||||
{
|
||||
Icon(
|
||||
Icons.Rounded.Refresh,
|
||||
contentDescription = stringResource(R.string.rusplit_update),
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
if (ruState.enabled)
|
||||
{
|
||||
RuModeSelector(
|
||||
mode = ruState.mode,
|
||||
onModeChange = { ruList.setMode(it) },
|
||||
)
|
||||
if (ruState.status == RuIpListStatus.UPDATING)
|
||||
{
|
||||
LinearProgressIndicator(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 4.dp),
|
||||
)
|
||||
}
|
||||
GeoipChipsRow(state = ruState)
|
||||
Text(
|
||||
text = stringResource(R.string.rusplit_source),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 6.dp),
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.rusplit_beta_note),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 4.dp, bottom = 12.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- route check: how does a domain go, tunnel or direct ---- */
|
||||
RouteCheckCard(modifier = Modifier.padding(top = 12.dp, start = ScreenPadding, end = ScreenPadding))
|
||||
|
||||
/* ---- quick action: RF bundle ---- */
|
||||
if (config.enabled)
|
||||
{
|
||||
RoutingCard(
|
||||
modifier = Modifier
|
||||
.padding(top = 12.dp, start = ScreenPadding, end = ScreenPadding)
|
||||
.fillMaxWidth(),
|
||||
onClick = { manager.applyRfBundle() },
|
||||
)
|
||||
{
|
||||
RoutingActionRow(
|
||||
icon = Icons.Rounded.Block,
|
||||
title = stringResource(R.string.routing_rf_quick_title),
|
||||
subtitle = stringResource(R.string.routing_rf_quick_summary),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- rule sets ---- */
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 24.dp, bottom = 4.dp, start = ScreenPadding, end = ScreenPadding),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
)
|
||||
{
|
||||
Text(
|
||||
text = stringResource(R.string.routing_rules_section),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
TextButton(
|
||||
onClick = {
|
||||
val newId = manager.addRuleSet()
|
||||
expandedId = newId
|
||||
focusNameId = newId
|
||||
},
|
||||
)
|
||||
{
|
||||
Icon(Icons.Rounded.Add, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(stringResource(R.string.routing_add_rule))
|
||||
}
|
||||
}
|
||||
|
||||
if (config.ruleSets.isEmpty())
|
||||
{
|
||||
ShxCard(modifier = Modifier.padding(horizontal = ScreenPadding))
|
||||
{
|
||||
ShxEmptyState(
|
||||
icon = Icons.Rounded.AltRoute,
|
||||
message = stringResource(R.string.routing_empty),
|
||||
actionLabel = stringResource(R.string.routing_add_rule),
|
||||
onAction = {
|
||||
val newId = manager.addRuleSet()
|
||||
expandedId = newId
|
||||
focusNameId = newId
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
RoutingCard(modifier = Modifier.padding(horizontal = ScreenPadding))
|
||||
{
|
||||
config.ruleSets.forEachIndexed { index, ruleSet ->
|
||||
RuleSetRow(
|
||||
ruleSet = ruleSet,
|
||||
expanded = expandedId == ruleSet.id,
|
||||
requestNameFocus = focusNameId == ruleSet.id,
|
||||
onToggle = { manager.setRuleSetEnabled(ruleSet.id, it) },
|
||||
onExpand = {
|
||||
expandedId = if (expandedId == ruleSet.id) null else ruleSet.id
|
||||
/* manual expansion cancels the auto-focus intent */
|
||||
focusNameId = null
|
||||
},
|
||||
onRename = { manager.updateRuleSetName(ruleSet.id, it) },
|
||||
onPickApps = {
|
||||
pickTarget = ruleSet.id
|
||||
val intent = Intent(context, SelectedApplicationsActivity::class.java)
|
||||
intent.putStringArrayListExtra(
|
||||
VpnProfileDataSource.KEY_SELECTED_APPS_LIST,
|
||||
ArrayList(ruleSet.packages),
|
||||
)
|
||||
pickApps.launch(intent)
|
||||
},
|
||||
onTogglePreset = { manager.togglePreset(ruleSet.id, it) },
|
||||
onAddPrefix = { prefix ->
|
||||
manager.updateConfig { cfg ->
|
||||
cfg.copy(ruleSets = cfg.ruleSets.map {
|
||||
if (it.id == ruleSet.id) it.copy(prefixes = it.prefixes + prefix) else it
|
||||
})
|
||||
}
|
||||
},
|
||||
onRemovePrefix = { prefix ->
|
||||
manager.updateConfig { cfg ->
|
||||
cfg.copy(ruleSets = cfg.ruleSets.map {
|
||||
if (it.id == ruleSet.id) it.copy(prefixes = it.prefixes - prefix) else it
|
||||
})
|
||||
}
|
||||
},
|
||||
onAddKeyword = { keyword ->
|
||||
manager.updateConfig { cfg ->
|
||||
cfg.copy(ruleSets = cfg.ruleSets.map {
|
||||
if (it.id == ruleSet.id) it.copy(keywords = it.keywords + keyword.lowercase()) else it
|
||||
})
|
||||
}
|
||||
},
|
||||
onRemoveKeyword = { keyword ->
|
||||
manager.updateConfig { cfg ->
|
||||
cfg.copy(ruleSets = cfg.ruleSets.map {
|
||||
if (it.id == ruleSet.id) it.copy(keywords = it.keywords - keyword) else it
|
||||
})
|
||||
}
|
||||
},
|
||||
onDelete = { deleteSetWithUndo(ruleSet) },
|
||||
)
|
||||
if (index < config.ruleSets.lastIndex)
|
||||
{
|
||||
ShxDivider()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Hero card: master switch plus a live summary of what the rules currently affect. */
|
||||
@Composable
|
||||
private fun RoutingHeroCard(
|
||||
enabled: Boolean,
|
||||
setCount: Int,
|
||||
appCount: Int,
|
||||
onToggle: (Boolean) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
)
|
||||
{
|
||||
Surface(
|
||||
shape = MaterialTheme.shapes.large,
|
||||
color = if (enabled) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceContainerLow,
|
||||
contentColor = if (enabled) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurface,
|
||||
border = if (enabled) null else BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant),
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
)
|
||||
{
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(start = 16.dp, top = 14.dp, bottom = 14.dp, end = 8.dp),
|
||||
)
|
||||
{
|
||||
Icon(
|
||||
Icons.Rounded.AltRoute,
|
||||
contentDescription = null,
|
||||
tint = if (enabled) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(Modifier.width(16.dp))
|
||||
Column(Modifier.weight(1f))
|
||||
{
|
||||
Text(
|
||||
text = stringResource(if (enabled) R.string.routing_hero_on else R.string.routing_hero_off),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
Text(
|
||||
text = if (enabled)
|
||||
{
|
||||
stringResource(R.string.routing_hero_summary, setCount, appCount)
|
||||
}
|
||||
else
|
||||
{
|
||||
stringResource(R.string.routing_hero_off_summary)
|
||||
},
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = if (enabled)
|
||||
{
|
||||
MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.8f)
|
||||
}
|
||||
else
|
||||
{
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
},
|
||||
)
|
||||
}
|
||||
Switch(checked = enabled, onCheckedChange = onToggle)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Selector for how the RU split is routed: OS routes with the coarsened
|
||||
* complement (as before) or per-packet classification in native code.
|
||||
*/
|
||||
@Composable
|
||||
private fun RuModeSelector(mode: String, onModeChange: (String) -> Unit)
|
||||
{
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 16.dp, end = 16.dp, top = 4.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
FilterChip(
|
||||
selected = mode == Constants.RUSPLIT_MODE_SYSTEM,
|
||||
onClick = { onModeChange(Constants.RUSPLIT_MODE_SYSTEM) },
|
||||
label = { Text(stringResource(R.string.rusplit_mode_system)) },
|
||||
)
|
||||
FilterChip(
|
||||
selected = mode == Constants.RUSPLIT_MODE_USERSPACE,
|
||||
onClick = { onModeChange(Constants.RUSPLIT_MODE_USERSPACE) },
|
||||
label = { Text(stringResource(R.string.rusplit_mode_userspace)) },
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = stringResource(
|
||||
if (mode == Constants.RUSPLIT_MODE_USERSPACE)
|
||||
{
|
||||
R.string.rusplit_mode_userspace_desc
|
||||
}
|
||||
else
|
||||
{
|
||||
R.string.rusplit_mode_system_desc
|
||||
}
|
||||
),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 6.dp),
|
||||
)
|
||||
}
|
||||
|
||||
/** Freshness/count chips of the geoip list. */
|
||||
@Composable
|
||||
private fun GeoipChipsRow(state: RuIpListState)
|
||||
{
|
||||
val status = LocalShxStatusColors.current
|
||||
val fresh = state.lastUpdatedMs > 0L &&
|
||||
System.currentTimeMillis() - state.lastUpdatedMs < 26 * 60 * 60 * 1000L
|
||||
FlowRow(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 16.dp, end = 16.dp, top = 8.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
)
|
||||
{
|
||||
InfoChip(text = stringResource(R.string.routing_geoip_ipv4, state.ipv4Ranges))
|
||||
InfoChip(text = stringResource(R.string.routing_geoip_ipv6, state.ipv6Ranges))
|
||||
val freshnessText = when
|
||||
{
|
||||
state.lastUpdatedMs > 0L ->
|
||||
stringResource(
|
||||
R.string.routing_geoip_updated,
|
||||
DateUtils.getRelativeTimeSpanString(state.lastUpdatedMs).toString(),
|
||||
)
|
||||
else -> stringResource(R.string.routing_geoip_never)
|
||||
}
|
||||
InfoChip(
|
||||
text = freshnessText,
|
||||
highlight = if (state.lastUpdatedMs > 0L && fresh) status.connected else null,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Small rounded info pill used across the screen. */
|
||||
@Composable
|
||||
private fun InfoChip(text: String, highlight: Color? = null)
|
||||
{
|
||||
Surface(
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
color = if (highlight != null) highlight.copy(alpha = 0.14f) else MaterialTheme.colorScheme.surfaceContainerHighest,
|
||||
contentColor = highlight ?: MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
{
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RoutingCard(
|
||||
modifier: Modifier = Modifier,
|
||||
onClick: (() -> Unit)? = null,
|
||||
content: @Composable () -> Unit,
|
||||
)
|
||||
{
|
||||
Surface(
|
||||
shape = MaterialTheme.shapes.large,
|
||||
color = MaterialTheme.colorScheme.surfaceContainerLow,
|
||||
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant),
|
||||
onClick = onClick ?: {},
|
||||
enabled = onClick != null,
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
)
|
||||
{
|
||||
Column { content() }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RoutingToggleRow(
|
||||
icon: ImageVector,
|
||||
title: String,
|
||||
subtitle: String,
|
||||
checked: Boolean,
|
||||
onCheckedChange: (Boolean) -> Unit,
|
||||
actions: (@Composable RowScope.() -> Unit)? = null,
|
||||
)
|
||||
{
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
)
|
||||
{
|
||||
Icon(icon, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
Spacer(Modifier.width(16.dp))
|
||||
Column(Modifier.weight(1f))
|
||||
{
|
||||
Text(title, style = MaterialTheme.typography.bodyLarge)
|
||||
Text(
|
||||
subtitle,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
if (actions != null)
|
||||
{
|
||||
actions()
|
||||
Spacer(Modifier.width(4.dp))
|
||||
}
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Switch(checked = checked, onCheckedChange = onCheckedChange)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RoutingActionRow(icon: ImageVector, title: String, subtitle: String)
|
||||
{
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
)
|
||||
{
|
||||
Icon(icon, contentDescription = null, tint = MaterialTheme.colorScheme.primary)
|
||||
Spacer(Modifier.width(16.dp))
|
||||
Column {
|
||||
Text(title, style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.SemiBold)
|
||||
Text(
|
||||
subtitle,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Compact input with an add action; commits on the IME "done" or the trailing button. */
|
||||
@Composable
|
||||
private fun AddChipField(
|
||||
label: String,
|
||||
onAdd: (String) -> Unit,
|
||||
)
|
||||
{
|
||||
var value by remember { mutableStateOf("") }
|
||||
val commit = {
|
||||
if (value.isNotBlank())
|
||||
{
|
||||
onAdd(value.trim())
|
||||
value = ""
|
||||
}
|
||||
}
|
||||
OutlinedTextField(
|
||||
value = value,
|
||||
onValueChange = { value = it },
|
||||
label = { Text(label) },
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
|
||||
keyboardActions = KeyboardActions(onDone = { commit() }),
|
||||
trailingIcon = {
|
||||
IconButton(onClick = { commit() }, enabled = value.isNotBlank())
|
||||
{
|
||||
Icon(Icons.Rounded.Add, contentDescription = null)
|
||||
}
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 8.dp),
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RuleSetRow(
|
||||
ruleSet: RuleSet,
|
||||
expanded: Boolean,
|
||||
requestNameFocus: Boolean = false,
|
||||
onToggle: (Boolean) -> Unit,
|
||||
onExpand: () -> Unit,
|
||||
onRename: (String) -> Unit,
|
||||
onPickApps: () -> Unit,
|
||||
onTogglePreset: (RoutingPresets.Preset) -> Unit,
|
||||
onAddPrefix: (String) -> Unit,
|
||||
onRemovePrefix: (String) -> Unit,
|
||||
onAddKeyword: (String) -> Unit,
|
||||
onRemoveKeyword: (String) -> Unit,
|
||||
onDelete: () -> Unit,
|
||||
)
|
||||
{
|
||||
val nameFocusRequester = remember { FocusRequester() }
|
||||
LaunchedEffect(expanded, requestNameFocus)
|
||||
{
|
||||
if (expanded && requestNameFocus)
|
||||
{
|
||||
nameFocusRequester.requestFocus()
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 4.dp),
|
||||
)
|
||||
{
|
||||
Switch(checked = ruleSet.enabled, onCheckedChange = onToggle)
|
||||
Spacer(Modifier.width(12.dp))
|
||||
Column(Modifier.weight(1f))
|
||||
{
|
||||
Text(
|
||||
ruleSet.name.ifBlank { stringResource(R.string.routing_rule_name) },
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
)
|
||||
Text(
|
||||
stringResource(
|
||||
R.string.routing_rule_subtitle,
|
||||
ruleSet.packages.size,
|
||||
ruleSet.prefixes.size,
|
||||
ruleSet.keywords.size,
|
||||
),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
IconButton(onClick = onExpand)
|
||||
{
|
||||
Icon(
|
||||
if (expanded) Icons.Rounded.ExpandLess else Icons.Rounded.ExpandMore,
|
||||
contentDescription = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = expanded,
|
||||
enter = expandVertically(spring(dampingRatio = 0.9f, stiffness = Spring.StiffnessMediumLow)) +
|
||||
fadeIn(tween(120)),
|
||||
exit = shrinkVertically(spring(dampingRatio = 0.9f, stiffness = Spring.StiffnessMedium)) +
|
||||
fadeOut(tween(90)),
|
||||
)
|
||||
{
|
||||
Column(Modifier.padding(horizontal = 16.dp))
|
||||
{
|
||||
OutlinedTextField(
|
||||
value = ruleSet.name,
|
||||
onValueChange = onRename,
|
||||
label = { Text(stringResource(R.string.routing_rule_name)) },
|
||||
singleLine = true,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.focusRequester(nameFocusRequester),
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
TextButton(onClick = onPickApps)
|
||||
{
|
||||
Text(stringResource(R.string.routing_pick_apps) + " (${ruleSet.packages.size})")
|
||||
}
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.routing_presets_label),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.horizontalScroll(rememberScrollState()),
|
||||
)
|
||||
{
|
||||
RoutingPresets.RF_BUNDLE.forEach { preset ->
|
||||
val selected = ruleSet.prefixes.containsAll(preset.prefixes) &&
|
||||
ruleSet.keywords.containsAll(preset.keywords)
|
||||
AssistChip(
|
||||
onClick = { onTogglePreset(preset) },
|
||||
label = { Text(preset.name) },
|
||||
border = BorderStroke(
|
||||
1.dp,
|
||||
if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outlineVariant,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
ruleSet.prefixes.sorted().forEach { prefix ->
|
||||
AssistChip(
|
||||
onClick = { onRemovePrefix(prefix) },
|
||||
label = { Text(prefix) },
|
||||
trailingIcon = {
|
||||
Icon(Icons.Rounded.Close, contentDescription = null, modifier = Modifier.size(16.dp))
|
||||
},
|
||||
modifier = Modifier.padding(top = 4.dp),
|
||||
)
|
||||
}
|
||||
AddChipField(label = stringResource(R.string.routing_add_prefix), onAdd = onAddPrefix)
|
||||
|
||||
ruleSet.keywords.sorted().forEach { keyword ->
|
||||
AssistChip(
|
||||
onClick = { onRemoveKeyword(keyword) },
|
||||
label = { Text("*$keyword*") },
|
||||
trailingIcon = {
|
||||
Icon(Icons.Rounded.Close, contentDescription = null, modifier = Modifier.size(16.dp))
|
||||
},
|
||||
modifier = Modifier.padding(top = 4.dp),
|
||||
)
|
||||
}
|
||||
AddChipField(label = stringResource(R.string.routing_add_keyword), onAdd = onAddKeyword)
|
||||
|
||||
TextButton(
|
||||
onClick = onDelete,
|
||||
modifier = Modifier.padding(top = 4.dp, bottom = 8.dp),
|
||||
)
|
||||
{
|
||||
Icon(
|
||||
Icons.Rounded.Delete,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.error,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(stringResource(R.string.routing_delete), color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Diagnostic card: shows how a domain resolves and which route its addresses take. */
|
||||
@Composable
|
||||
private fun RouteCheckCard(modifier: Modifier = Modifier)
|
||||
{
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
var domain by remember { mutableStateOf("") }
|
||||
var checking by remember { mutableStateOf(false) }
|
||||
var report by remember { mutableStateOf<RouteCheck.Report?>(null) }
|
||||
|
||||
ShxCard(modifier = modifier.fillMaxWidth())
|
||||
{
|
||||
Column(modifier = Modifier.padding(16.dp))
|
||||
{
|
||||
Text(
|
||||
text = stringResource(R.string.route_check_title),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.route_check_hint),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 4.dp),
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = domain,
|
||||
onValueChange = { domain = it },
|
||||
label = { Text(stringResource(R.string.route_check_field)) },
|
||||
singleLine = true,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 10.dp),
|
||||
)
|
||||
Button(
|
||||
onClick = {
|
||||
checking = true
|
||||
scope.launch {
|
||||
report = RouteCheck.check(
|
||||
context,
|
||||
domain.trim(),
|
||||
CharonVpnService.sAppliedGeoExclusionsV4,
|
||||
CharonVpnService.sAppliedGeoExclusionsV6,
|
||||
CharonVpnService.sDnsPunchIps,
|
||||
)
|
||||
checking = false
|
||||
}
|
||||
},
|
||||
enabled = domain.isNotBlank() && !checking,
|
||||
modifier = Modifier.padding(top = 10.dp),
|
||||
)
|
||||
{
|
||||
Text(stringResource(if (checking) R.string.route_check_checking else R.string.route_check_button))
|
||||
}
|
||||
report?.let { rep -> RouteCheckResult(rep) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RouteCheckResult(report: RouteCheck.Report)
|
||||
{
|
||||
Column(modifier = Modifier.padding(top = 12.dp))
|
||||
{
|
||||
if (!report.vpnNetworkFound)
|
||||
{
|
||||
Text(
|
||||
text = stringResource(R.string.route_check_no_vpn),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
RouteCheckRow(stringResource(R.string.route_check_via_vpn_dns), report.vpnDnsIps)
|
||||
RouteCheckRow(stringResource(R.string.route_check_via_direct_dns), report.directDnsIps)
|
||||
Text(
|
||||
text = stringResource(R.string.route_check_routes),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
modifier = Modifier.padding(top = 10.dp),
|
||||
)
|
||||
for (route in report.routes)
|
||||
{
|
||||
val label = stringResource(
|
||||
when (route.kind)
|
||||
{
|
||||
RouteCheck.RouteKind.TUNNEL -> R.string.route_verdict_tunnel
|
||||
RouteCheck.RouteKind.DIRECT_RU -> R.string.route_verdict_direct
|
||||
RouteCheck.RouteKind.TUNNEL_DNS -> R.string.route_verdict_dns
|
||||
},
|
||||
)
|
||||
Text(
|
||||
text = "${route.ip} — $label",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
modifier = Modifier.padding(top = 2.dp),
|
||||
)
|
||||
}
|
||||
if (report.routes.isEmpty())
|
||||
{
|
||||
Text(
|
||||
text = stringResource(R.string.route_check_resolve_failed),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RouteCheckRow(label: String, ips: List<String>)
|
||||
{
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
modifier = Modifier.padding(top = 8.dp),
|
||||
)
|
||||
Text(
|
||||
text = ips.joinToString(", ").ifEmpty { stringResource(R.string.route_check_none) },
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
)
|
||||
}
|
||||
+3
-1
@@ -22,6 +22,8 @@ import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import org.strongswan.android.R
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
|
||||
@@ -40,7 +42,7 @@ fun SecondaryScaffold(
|
||||
title = { Text(title, maxLines = 1, overflow = TextOverflow.Ellipsis, fontWeight = FontWeight.SemiBold) },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null)
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back))
|
||||
}
|
||||
},
|
||||
actions = actions,
|
||||
|
||||
+345
-147
@@ -14,63 +14,80 @@ import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.provider.Settings
|
||||
import android.widget.Toast
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
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.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowRight
|
||||
import androidx.compose.material.icons.rounded.Autorenew
|
||||
import androidx.compose.material.icons.rounded.BatteryChargingFull
|
||||
import androidx.compose.material.icons.rounded.Bedtime
|
||||
import androidx.compose.material.icons.rounded.Bolt
|
||||
import androidx.compose.material.icons.rounded.Cached
|
||||
import androidx.compose.material.icons.rounded.CloudDownload
|
||||
import androidx.compose.material.icons.rounded.CloudSync
|
||||
import androidx.compose.material.icons.rounded.Description
|
||||
import androidx.compose.material.icons.rounded.FileDownload
|
||||
import androidx.compose.material.icons.rounded.GppGood
|
||||
import androidx.compose.material.icons.rounded.GppMaybe
|
||||
import androidx.compose.material.icons.rounded.History
|
||||
import androidx.compose.material.icons.rounded.Key
|
||||
import androidx.compose.material.icons.rounded.NetworkCheck
|
||||
import androidx.compose.material.icons.rounded.Palette
|
||||
import androidx.compose.material.icons.rounded.RestartAlt
|
||||
import androidx.compose.material.icons.rounded.Security
|
||||
import androidx.compose.material.icons.rounded.SystemUpdateAlt
|
||||
import androidx.compose.material.icons.rounded.VerifiedUser
|
||||
import androidx.compose.material.icons.rounded.Wifi
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.RadioButton
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalLifecycleOwner
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.preference.PreferenceManager
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import org.strongswan.android.R
|
||||
import org.strongswan.android.data.VpnProfile
|
||||
import org.strongswan.android.logic.StrongSwanApplication
|
||||
import org.strongswan.android.logic.WatchdogReceiver
|
||||
import org.strongswan.android.logic.connectivity.WhiteListManager
|
||||
import org.strongswan.android.logic.shortcuts.VpnShortcutManager
|
||||
import org.strongswan.android.logic.updates.UpdateError
|
||||
import org.strongswan.android.logic.updates.UpdateInstaller
|
||||
import org.strongswan.android.logic.updates.UpdateManager
|
||||
import org.strongswan.android.logic.updates.UpdateState
|
||||
import org.strongswan.android.ui.LogActivity
|
||||
import org.strongswan.android.ui.TrustedCertificatesActivity
|
||||
import org.strongswan.android.ui.TrustedNetworksActivity
|
||||
import org.strongswan.android.ui.ConnectivityCheckActivity
|
||||
import org.strongswan.android.ui.VpnProfileImportActivity
|
||||
import org.strongswan.android.ui.compose.theme.ScreenPadding
|
||||
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.ui.compose.theme.ShxThemeSettings
|
||||
import org.strongswan.android.utils.Constants
|
||||
|
||||
@OptIn(androidx.compose.material3.ExperimentalMaterial3Api::class)
|
||||
@@ -102,6 +119,33 @@ fun SettingsScreen(profiles: List<VpnProfile>, showTitle: Boolean = true)
|
||||
else profiles.firstOrNull { it.getUUID().toString() == defaultUuid }?.name
|
||||
?: stringResource(R.string.profile_not_found)
|
||||
|
||||
val updateManager = remember { UpdateManager.getInstance(context) }
|
||||
val updateState by updateManager.state.collectAsStateWithLifecycle()
|
||||
var showUpdateConfirm by remember { mutableStateOf(false) }
|
||||
|
||||
val unknownSourcesLauncher = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.StartActivityForResult()
|
||||
) {
|
||||
if (UpdateInstaller.canInstall(context))
|
||||
{
|
||||
updateManager.downloadAndInstall()
|
||||
}
|
||||
else
|
||||
{
|
||||
Toast.makeText(context, R.string.update_permission_denied, Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
val startUpdate: () -> Unit = {
|
||||
if (UpdateInstaller.canInstall(context))
|
||||
{
|
||||
updateManager.downloadAndInstall()
|
||||
}
|
||||
else
|
||||
{
|
||||
unknownSourcesLauncher.launch(UpdateInstaller.createInstallPermissionIntent(context))
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
@@ -119,18 +163,38 @@ fun SettingsScreen(profiles: List<VpnProfile>, showTitle: Boolean = true)
|
||||
)
|
||||
}
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && managed.isAllowSettingsAccess)
|
||||
ShxSection(title = stringResource(R.string.settings_section_general))
|
||||
{
|
||||
SettingsSection(title = stringResource(R.string.settings_section_general))
|
||||
var addedRow = false
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S)
|
||||
{
|
||||
SettingsRow(
|
||||
var dynamicColors by remember { ShxThemeSettings.load(context); mutableStateOf(ShxThemeSettings.useDynamicColors.value) }
|
||||
ShxSettingToggleRow(
|
||||
icon = Icons.Rounded.Palette,
|
||||
title = stringResource(R.string.pref_dynamic_colors_title),
|
||||
subtitle = stringResource(R.string.pref_dynamic_colors_summary),
|
||||
checked = dynamicColors,
|
||||
onCheckedChange = {
|
||||
dynamicColors = it
|
||||
ShxThemeSettings.setUseDynamicColors(context, it)
|
||||
},
|
||||
)
|
||||
addedRow = true
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && managed.isAllowSettingsAccess)
|
||||
{
|
||||
if (addedRow)
|
||||
{
|
||||
ShxSectionDivider()
|
||||
}
|
||||
ShxSettingRow(
|
||||
icon = Icons.Rounded.History,
|
||||
title = stringResource(R.string.pref_default_vpn_profile),
|
||||
subtitle = defaultLabel,
|
||||
onClick = { showDefaultPicker = true },
|
||||
)
|
||||
SettingsDivider()
|
||||
SettingsToggleRow(
|
||||
ShxSectionDivider()
|
||||
ShxSettingToggleRow(
|
||||
icon = Icons.Rounded.BatteryChargingFull,
|
||||
title = stringResource(R.string.pref_power_whitelist_title),
|
||||
subtitle = stringResource(R.string.pref_power_whitelist_summary),
|
||||
@@ -141,11 +205,22 @@ fun SettingsScreen(profiles: List<VpnProfile>, showTitle: Boolean = true)
|
||||
},
|
||||
)
|
||||
}
|
||||
if (Build.MANUFACTURER.equals("samsung", ignoreCase = true))
|
||||
{ /* One UI puts unused apps to sleep even with battery optimizations
|
||||
* disabled, which breaks watchdog-based recovery of the tunnel */
|
||||
ShxSectionDivider()
|
||||
ShxSettingRow(
|
||||
icon = Icons.Rounded.Bedtime,
|
||||
title = stringResource(R.string.samsung_sleep_title),
|
||||
subtitle = stringResource(R.string.samsung_sleep_summary),
|
||||
onClick = { openSamsungBatterySettings(context) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
SettingsSection(title = stringResource(R.string.settings_section_automation))
|
||||
ShxSection(title = stringResource(R.string.settings_section_automation))
|
||||
{
|
||||
SettingsRow(
|
||||
ShxSettingRow(
|
||||
icon = Icons.Rounded.Wifi,
|
||||
title = stringResource(R.string.auto_connect_title),
|
||||
subtitle = stringResource(R.string.auto_connect_summary),
|
||||
@@ -153,12 +228,99 @@ fun SettingsScreen(profiles: List<VpnProfile>, showTitle: Boolean = true)
|
||||
context.startActivity(Intent(context, TrustedNetworksActivity::class.java))
|
||||
},
|
||||
)
|
||||
ShxSectionDivider()
|
||||
ShxSettingRow(
|
||||
icon = Icons.Rounded.NetworkCheck,
|
||||
title = stringResource(R.string.health_check_title),
|
||||
subtitle = stringResource(R.string.health_check_summary),
|
||||
onClick = {
|
||||
context.startActivity(Intent(context, ConnectivityCheckActivity::class.java))
|
||||
},
|
||||
)
|
||||
ShxSectionDivider()
|
||||
var whiteListEnabled by remember {
|
||||
mutableStateOf(WhiteListManager.getInstance(context).currentConfig.enabled)
|
||||
}
|
||||
ShxSettingToggleRow(
|
||||
icon = Icons.Rounded.GppMaybe,
|
||||
title = stringResource(R.string.whitelist_analysis_title),
|
||||
subtitle = stringResource(R.string.whitelist_analysis_summary),
|
||||
checked = whiteListEnabled,
|
||||
onCheckedChange = {
|
||||
whiteListEnabled = it
|
||||
WhiteListManager.getInstance(context).updateConfig { config -> config.copy(enabled = it) }
|
||||
},
|
||||
)
|
||||
ShxSectionDivider()
|
||||
var shortcutsEnabled by remember {
|
||||
mutableStateOf(VpnShortcutManager.getInstance(context).isEnabled())
|
||||
}
|
||||
ShxSettingToggleRow(
|
||||
icon = Icons.Rounded.Bolt,
|
||||
title = stringResource(R.string.pref_routines_shortcuts_title),
|
||||
subtitle = stringResource(R.string.pref_routines_shortcuts_summary),
|
||||
checked = shortcutsEnabled,
|
||||
onCheckedChange = {
|
||||
shortcutsEnabled = it
|
||||
VpnShortcutManager.getInstance(context).setEnabled(it)
|
||||
},
|
||||
)
|
||||
ShxSectionDivider()
|
||||
var watchdogEnabled by remember {
|
||||
mutableStateOf(prefs.getBoolean(Constants.PREF_WATCHDOG_ENABLED, true))
|
||||
}
|
||||
ShxSettingToggleRow(
|
||||
icon = Icons.Rounded.RestartAlt,
|
||||
title = stringResource(R.string.watchdog_title),
|
||||
subtitle = stringResource(R.string.watchdog_summary),
|
||||
checked = watchdogEnabled,
|
||||
onCheckedChange = {
|
||||
watchdogEnabled = it
|
||||
prefs.edit().putBoolean(Constants.PREF_WATCHDOG_ENABLED, it).apply()
|
||||
if (it)
|
||||
{ /* start watching right away if a connection is expected */
|
||||
WatchdogReceiver.schedule(context)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
SettingsSection(title = stringResource(R.string.settings_section_security))
|
||||
ShxSection(title = stringResource(R.string.settings_section_security))
|
||||
{
|
||||
val lockdownActive = remember { context.isVpnLockdownEnabled() }
|
||||
SettingsRow(
|
||||
/* re-read on every ON_RESUME so the summary reflects changes made in system settings */
|
||||
var lockdownActive by remember { mutableStateOf(context.isVpnLockdownEnabled()) }
|
||||
var showKillSwitchDialog by remember { mutableStateOf(false) }
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
DisposableEffect(lifecycleOwner) {
|
||||
val observer = LifecycleEventObserver { _, event ->
|
||||
if (event == Lifecycle.Event.ON_RESUME)
|
||||
{
|
||||
lockdownActive = context.isVpnLockdownEnabled()
|
||||
}
|
||||
}
|
||||
lifecycleOwner.lifecycle.addObserver(observer)
|
||||
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
|
||||
}
|
||||
if (showKillSwitchDialog)
|
||||
{
|
||||
AlertDialog(
|
||||
onDismissRequest = { showKillSwitchDialog = false },
|
||||
title = { Text(stringResource(R.string.kill_switch_dialog_title)) },
|
||||
text = { Text(stringResource(R.string.kill_switch_dialog_text)) },
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
showKillSwitchDialog = false
|
||||
context.startActivity(Intent(Settings.ACTION_VPN_SETTINGS))
|
||||
}) { Text(stringResource(R.string.kill_switch_dialog_enable)) }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { showKillSwitchDialog = false }) {
|
||||
Text(stringResource(android.R.string.cancel))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
ShxSettingRow(
|
||||
icon = Icons.Rounded.Security,
|
||||
title = stringResource(R.string.kill_switch_title),
|
||||
subtitle = stringResource(
|
||||
@@ -166,22 +328,38 @@ fun SettingsScreen(profiles: List<VpnProfile>, showTitle: Boolean = true)
|
||||
else R.string.kill_switch_summary_off
|
||||
),
|
||||
onClick = {
|
||||
context.startActivity(Intent(Settings.ACTION_VPN_SETTINGS))
|
||||
/* explain what this actually toggles before sending the user to system settings */
|
||||
if (lockdownActive) context.startActivity(Intent(Settings.ACTION_VPN_SETTINGS))
|
||||
else showKillSwitchDialog = true
|
||||
},
|
||||
)
|
||||
ShxSectionDivider()
|
||||
var safeIpv6 by remember {
|
||||
mutableStateOf(prefs.getBoolean(Constants.PREF_SAFE_IPV6, false))
|
||||
}
|
||||
ShxSettingToggleRow(
|
||||
icon = Icons.Rounded.GppGood,
|
||||
title = stringResource(R.string.safe_ipv6_title),
|
||||
subtitle = stringResource(R.string.safe_ipv6_summary),
|
||||
checked = safeIpv6,
|
||||
onCheckedChange = {
|
||||
safeIpv6 = it
|
||||
prefs.edit().putBoolean(Constants.PREF_SAFE_IPV6, it).apply()
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
SettingsSection(title = stringResource(R.string.settings_section_certs))
|
||||
ShxSection(title = stringResource(R.string.settings_section_certs))
|
||||
{
|
||||
SettingsRow(
|
||||
ShxSettingRow(
|
||||
icon = Icons.Rounded.VerifiedUser,
|
||||
title = stringResource(R.string.trusted_certs_title),
|
||||
onClick = {
|
||||
context.startActivity(Intent(context, TrustedCertificatesActivity::class.java))
|
||||
},
|
||||
)
|
||||
SettingsDivider()
|
||||
SettingsRow(
|
||||
ShxSectionDivider()
|
||||
ShxSettingRow(
|
||||
icon = Icons.Rounded.Cached,
|
||||
title = stringResource(R.string.crl_cache),
|
||||
onClick = { showCrl = true },
|
||||
@@ -190,9 +368,9 @@ fun SettingsScreen(profiles: List<VpnProfile>, showTitle: Boolean = true)
|
||||
|
||||
if (managed.isAllowProfileImport)
|
||||
{
|
||||
SettingsSection(title = stringResource(R.string.settings_section_profiles))
|
||||
ShxSection(title = stringResource(R.string.settings_section_profiles))
|
||||
{
|
||||
SettingsRow(
|
||||
ShxSettingRow(
|
||||
icon = Icons.Rounded.FileDownload,
|
||||
title = stringResource(R.string.profile_import),
|
||||
onClick = {
|
||||
@@ -202,9 +380,9 @@ fun SettingsScreen(profiles: List<VpnProfile>, showTitle: Boolean = true)
|
||||
}
|
||||
}
|
||||
|
||||
SettingsSection(title = stringResource(R.string.settings_section_support))
|
||||
ShxSection(title = stringResource(R.string.settings_section_support))
|
||||
{
|
||||
SettingsRow(
|
||||
ShxSettingRow(
|
||||
icon = Icons.Rounded.Description,
|
||||
title = stringResource(R.string.show_log),
|
||||
onClick = {
|
||||
@@ -213,6 +391,71 @@ fun SettingsScreen(profiles: List<VpnProfile>, showTitle: Boolean = true)
|
||||
)
|
||||
}
|
||||
|
||||
ShxSection(title = stringResource(R.string.settings_section_updates))
|
||||
{
|
||||
var autoUpdate by remember {
|
||||
mutableStateOf(prefs.getBoolean(Constants.PREF_AUTO_UPDATE_CHECK, true))
|
||||
}
|
||||
ShxSettingToggleRow(
|
||||
icon = Icons.Rounded.Autorenew,
|
||||
title = stringResource(R.string.update_auto_check_title),
|
||||
subtitle = stringResource(R.string.update_auto_check_summary),
|
||||
checked = autoUpdate,
|
||||
onCheckedChange = {
|
||||
autoUpdate = it
|
||||
prefs.edit().putBoolean(Constants.PREF_AUTO_UPDATE_CHECK, it).apply()
|
||||
},
|
||||
)
|
||||
ShxSectionDivider()
|
||||
var silentUpdates by remember {
|
||||
mutableStateOf(prefs.getBoolean(Constants.PREF_SILENT_UPDATES, false))
|
||||
}
|
||||
ShxSettingToggleRow(
|
||||
icon = Icons.Rounded.CloudSync,
|
||||
title = stringResource(R.string.update_silent_title),
|
||||
subtitle = stringResource(R.string.update_silent_summary),
|
||||
checked = silentUpdates,
|
||||
onCheckedChange = {
|
||||
silentUpdates = it
|
||||
prefs.edit().putBoolean(Constants.PREF_SILENT_UPDATES, it).apply()
|
||||
},
|
||||
)
|
||||
ShxSectionDivider()
|
||||
ShxSettingRow(
|
||||
icon = Icons.Rounded.SystemUpdateAlt,
|
||||
title = stringResource(R.string.update_check_title),
|
||||
subtitle = updateStatusText(updateState),
|
||||
onClick = {
|
||||
when (updateState)
|
||||
{
|
||||
is UpdateState.Checking, is UpdateState.Downloading -> Unit
|
||||
else -> updateManager.checkNow()
|
||||
}
|
||||
},
|
||||
)
|
||||
val updateInfo = (updateState as? UpdateState.Available)?.info
|
||||
?: (updateState as? UpdateState.Downloading)?.info
|
||||
if (updateInfo != null)
|
||||
{
|
||||
ShxSectionDivider()
|
||||
ShxSettingRow(
|
||||
icon = Icons.Rounded.CloudDownload,
|
||||
title = stringResource(R.string.update_download_install),
|
||||
subtitle = when (val state = updateState)
|
||||
{
|
||||
is UpdateState.Downloading -> stringResource(R.string.update_downloading_fmt, state.progress)
|
||||
else -> stringResource(R.string.update_available_fmt, updateInfo.versionName)
|
||||
},
|
||||
onClick = {
|
||||
if (updateState !is UpdateState.Downloading)
|
||||
{
|
||||
showUpdateConfirm = true
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.app_version_fmt, versionName),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
@@ -269,7 +512,7 @@ fun SettingsScreen(profiles: List<VpnProfile>, showTitle: Boolean = true)
|
||||
text = {
|
||||
Text(
|
||||
if (files.isEmpty()) stringResource(R.string.clear_crl_cache_msg_none)
|
||||
else stringResource(R.string.clear_crl_cache_title)
|
||||
else pluralStringResource(R.plurals.clear_crl_cache_msg, files.size, files.size, files.joinToString(", "))
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
@@ -292,133 +535,61 @@ fun SettingsScreen(profiles: List<VpnProfile>, showTitle: Boolean = true)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SettingsSection(title: String, content: @Composable () -> Unit)
|
||||
{
|
||||
Text(
|
||||
text = title.uppercase(),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = ScreenPadding + 16.dp, top = 20.dp, bottom = 8.dp),
|
||||
)
|
||||
Surface(
|
||||
shape = MaterialTheme.shapes.large,
|
||||
color = MaterialTheme.colorScheme.surfaceContainerLow,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = ScreenPadding),
|
||||
)
|
||||
if (showUpdateConfirm && updateState is UpdateState.Available)
|
||||
{
|
||||
Column { content() }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SettingsDivider()
|
||||
{
|
||||
HorizontalDivider(
|
||||
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.6f),
|
||||
modifier = Modifier.padding(start = 68.dp),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SettingsIconWrapper(icon: ImageVector, tint: androidx.compose.ui.graphics.Color)
|
||||
{
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(36.dp)
|
||||
.background(MaterialTheme.colorScheme.primaryContainer, CircleShape),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
tint = tint,
|
||||
modifier = Modifier.size(20.dp),
|
||||
val info = (updateState as UpdateState.Available).info
|
||||
val notes = info.notes["ru"] ?: info.notes["en"] ?: info.notes.values.firstOrNull() ?: ""
|
||||
AlertDialog(
|
||||
onDismissRequest = { showUpdateConfirm = false },
|
||||
title = { Text(stringResource(R.string.update_confirm_title, info.versionName)) },
|
||||
text = { if (notes.isNotEmpty()) { Text(notes) } },
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
showUpdateConfirm = false
|
||||
startUpdate()
|
||||
}) { Text(stringResource(R.string.update_action)) }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { showUpdateConfirm = false }) {
|
||||
Text(stringResource(android.R.string.cancel))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SettingsRow(
|
||||
icon: ImageVector,
|
||||
title: String,
|
||||
subtitle: String? = null,
|
||||
onClick: () -> Unit,
|
||||
)
|
||||
private fun updateStatusText(state: UpdateState): String
|
||||
{
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 16.dp, vertical = 14.dp),
|
||||
)
|
||||
return when (state)
|
||||
{
|
||||
SettingsIconWrapper(icon = icon, tint = MaterialTheme.colorScheme.primary)
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(horizontal = 16.dp),
|
||||
)
|
||||
is UpdateState.Checking -> stringResource(R.string.update_checking)
|
||||
is UpdateState.Idle -> stringResource(R.string.update_status_idle)
|
||||
is UpdateState.UpToDate -> stringResource(R.string.update_status_uptodate)
|
||||
is UpdateState.Available ->
|
||||
{
|
||||
Text(title, style = MaterialTheme.typography.bodyLarge)
|
||||
if (subtitle != null)
|
||||
if (state.info.size > 0)
|
||||
{
|
||||
Text(
|
||||
subtitle,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
stringResource(R.string.update_available_fmt, state.info.versionName) + " • " +
|
||||
stringResource(R.string.update_size_mb_fmt, (state.info.size / (1024 * 1024)).toInt())
|
||||
}
|
||||
else
|
||||
{
|
||||
stringResource(R.string.update_available_fmt, state.info.versionName)
|
||||
}
|
||||
}
|
||||
Icon(
|
||||
Icons.AutoMirrored.Rounded.KeyboardArrowRight,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SettingsToggleRow(
|
||||
icon: ImageVector,
|
||||
title: String,
|
||||
subtitle: String?,
|
||||
checked: Boolean,
|
||||
onCheckedChange: (Boolean) -> Unit,
|
||||
)
|
||||
{
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 14.dp),
|
||||
)
|
||||
{
|
||||
SettingsIconWrapper(icon = icon, tint = MaterialTheme.colorScheme.primary)
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(horizontal = 16.dp),
|
||||
)
|
||||
{
|
||||
Text(title, style = MaterialTheme.typography.bodyLarge)
|
||||
if (subtitle != null)
|
||||
is UpdateState.Downloading -> stringResource(R.string.update_downloading_fmt, state.progress)
|
||||
is UpdateState.Failed -> stringResource(
|
||||
when (state.error)
|
||||
{
|
||||
Text(
|
||||
subtitle,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
UpdateError.NETWORK -> R.string.update_error_network
|
||||
UpdateError.HASH -> R.string.update_error_hash
|
||||
UpdateError.SIGNATURE -> R.string.update_error_signature
|
||||
UpdateError.INSTALL -> R.string.update_error_install
|
||||
UpdateError.OTHER -> R.string.update_error_generic
|
||||
}
|
||||
}
|
||||
Switch(checked = checked, onCheckedChange = onCheckedChange)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -441,3 +612,30 @@ private fun Context.isVpnLockdownEnabled(): Boolean
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the Samsung battery settings where the user can exclude the app from
|
||||
* "sleeping apps" (One UI's own background restriction, independent of the
|
||||
* Android battery optimization). The component names differ across One UI
|
||||
* versions, so fall back through more generic entry points.
|
||||
*/
|
||||
private fun openSamsungBatterySettings(context: Context)
|
||||
{
|
||||
val candidates = listOf(
|
||||
Intent().setClassName("com.samsung.android.lool", "com.samsung.android.sm.battery.ui.BatteryActivity"),
|
||||
Intent().setClassName("com.samsung.android.lool", "com.samsung.android.sm.ui.battery.BatteryActivity"),
|
||||
Intent("com.samsung.android.action.SECURITY_SETTINGS_BATTERY").addFlags(Intent.FLAG_ACTIVITY_NEW_TASK),
|
||||
Intent(Settings.ACTION_SETTINGS).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK),
|
||||
)
|
||||
for (intent in candidates)
|
||||
{
|
||||
try
|
||||
{
|
||||
context.startActivity(intent)
|
||||
return
|
||||
}
|
||||
catch (_: Exception)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
+81
-9
@@ -20,16 +20,27 @@ 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.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.consumeWindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.AltRoute
|
||||
import androidx.compose.material.icons.filled.Home
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material.icons.filled.Storage
|
||||
import androidx.compose.material.icons.outlined.AltRoute
|
||||
import androidx.compose.material.icons.outlined.Home
|
||||
import androidx.compose.material.icons.outlined.Settings
|
||||
import androidx.compose.material.icons.outlined.Storage
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.NavigationBar
|
||||
@@ -38,8 +49,13 @@ import androidx.compose.material3.NavigationBarItemDefaults
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.compositeOver
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
@@ -85,6 +101,11 @@ fun ShxApp(viewModel: HomeViewModel = viewModel())
|
||||
intent.putExtra(VpnProfileDataSource.KEY_UUID, profile.getUUID().toString())
|
||||
context.startActivity(intent)
|
||||
}
|
||||
var pendingShare by remember { mutableStateOf<VpnProfile?>(null) }
|
||||
val shareProfile: (VpnProfile) -> Unit = { pendingShare = it }
|
||||
|
||||
/* the home list keeps its scroll state across tab switches */
|
||||
val homeListState = rememberLazyListState()
|
||||
|
||||
Scaffold(
|
||||
bottomBar = {
|
||||
@@ -111,31 +132,80 @@ fun ShxApp(viewModel: HomeViewModel = viewModel())
|
||||
) { tab ->
|
||||
when (tab)
|
||||
{
|
||||
ShxTab.HOME -> HomeScreen(
|
||||
state = state,
|
||||
trafficFlow = viewModel.traffic,
|
||||
viewModel = viewModel,
|
||||
ShxTab.HOME -> HomeScreen(
|
||||
state = state,
|
||||
trafficFlow = viewModel.traffic,
|
||||
viewModel = viewModel,
|
||||
listState = homeListState,
|
||||
onAddProfile = addProfile,
|
||||
onEditProfile = editProfile,
|
||||
onShowLog = {
|
||||
context.startActivity(Intent(context, LogActivity::class.java))
|
||||
},
|
||||
)
|
||||
onEditProfile = editProfile,
|
||||
onShareProfile = shareProfile,
|
||||
onShowLog = {
|
||||
context.startActivity(Intent(context, LogActivity::class.java))
|
||||
},
|
||||
)
|
||||
ShxTab.PROFILES -> ProfilesScreen(
|
||||
state = state,
|
||||
onConnect = { profile -> viewModel.connect(context, profile) },
|
||||
onDisconnect = { viewModel.disconnect() },
|
||||
onEdit = editProfile,
|
||||
onDelete = viewModel::delete,
|
||||
onRestore = viewModel::restore,
|
||||
onShare = shareProfile,
|
||||
onAdd = addProfile,
|
||||
)
|
||||
ShxTab.ROUTING -> RoutingScreen()
|
||||
ShxTab.SETTINGS -> SettingsScreen(profiles = state.profiles)
|
||||
}
|
||||
}
|
||||
pendingShare?.let { profile ->
|
||||
ShareProfileDialog(
|
||||
profile = profile,
|
||||
onDismiss = { pendingShare = null },
|
||||
onConfirm = { includePassword ->
|
||||
pendingShare = null
|
||||
viewModel.shareProfile(context, profile, includePassword)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ShareProfileDialog(profile: VpnProfile, onDismiss: () -> Unit, onConfirm: (Boolean) -> Unit)
|
||||
{
|
||||
var includePassword by remember { mutableStateOf(false) }
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(stringResource(R.string.share_profile)) },
|
||||
text = {
|
||||
Column {
|
||||
Text(stringResource(R.string.share_profile_password_warning), style = MaterialTheme.typography.bodyMedium)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.clickable { includePassword = !includePassword },
|
||||
) {
|
||||
Checkbox(checked = includePassword, onCheckedChange = { includePassword = it })
|
||||
Spacer(Modifier.width(4.dp))
|
||||
Text(stringResource(R.string.share_profile_include_password))
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = { onConfirm(includePassword) }) {
|
||||
Text(stringResource(R.string.share_profile))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text(stringResource(android.R.string.cancel))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ShxBottomBar(selected: ShxTab, onSelect: (ShxTab) -> Unit)
|
||||
{
|
||||
@@ -169,6 +239,7 @@ private fun ShxTab.icon(selected: Boolean): ImageVector = when (this)
|
||||
{
|
||||
ShxTab.HOME -> if (selected) Icons.Filled.Home else Icons.Outlined.Home
|
||||
ShxTab.PROFILES -> if (selected) Icons.Filled.Storage else Icons.Outlined.Storage
|
||||
ShxTab.ROUTING -> if (selected) Icons.Filled.AltRoute else Icons.Outlined.AltRoute
|
||||
ShxTab.SETTINGS -> if (selected) Icons.Filled.Settings else Icons.Outlined.Settings
|
||||
}
|
||||
|
||||
@@ -176,5 +247,6 @@ private fun ShxTab.label(): Int = when (this)
|
||||
{
|
||||
ShxTab.HOME -> R.string.nav_connect
|
||||
ShxTab.PROFILES -> R.string.nav_profiles
|
||||
ShxTab.ROUTING -> R.string.nav_routing
|
||||
ShxTab.SETTINGS -> R.string.nav_settings
|
||||
}
|
||||
+11
-136
@@ -34,20 +34,17 @@ import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowRight
|
||||
import androidx.compose.material.icons.rounded.Add
|
||||
import androidx.compose.material.icons.rounded.Delete
|
||||
import androidx.compose.material.icons.rounded.Language
|
||||
import androidx.compose.material.icons.rounded.Wifi
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.RadioButton
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -58,7 +55,6 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -67,6 +63,10 @@ import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import org.strongswan.android.R
|
||||
import org.strongswan.android.ui.compose.components.ShxSection
|
||||
import org.strongswan.android.ui.compose.components.ShxSectionDivider
|
||||
import org.strongswan.android.ui.compose.components.ShxSettingRow
|
||||
import org.strongswan.android.ui.compose.components.ShxSettingToggleRow
|
||||
import org.strongswan.android.data.VpnProfile
|
||||
import org.strongswan.android.logic.autoconnect.AutoConnectManager
|
||||
import org.strongswan.android.ui.compose.theme.ScreenPadding
|
||||
@@ -162,9 +162,9 @@ fun TrustedNetworksScreen(
|
||||
.padding(bottom = 24.dp),
|
||||
)
|
||||
{
|
||||
TrustedSection(title = stringResource(R.string.settings_section_general))
|
||||
ShxSection(title = stringResource(R.string.settings_section_general))
|
||||
{
|
||||
TrustedToggleRow(
|
||||
ShxSettingToggleRow(
|
||||
icon = Icons.Rounded.Wifi,
|
||||
title = stringResource(R.string.auto_connect_title),
|
||||
subtitle = stringResource(R.string.auto_connect_summary),
|
||||
@@ -177,8 +177,8 @@ fun TrustedNetworksScreen(
|
||||
update { it.copy(enabled = checked) }
|
||||
},
|
||||
)
|
||||
TrustedDivider()
|
||||
TrustedRow(
|
||||
ShxSectionDivider()
|
||||
ShxSettingRow(
|
||||
icon = Icons.Rounded.Language,
|
||||
title = stringResource(R.string.auto_connect_profile),
|
||||
subtitle = selectedProfile?.name
|
||||
@@ -218,7 +218,7 @@ fun TrustedNetworksScreen(
|
||||
}
|
||||
}
|
||||
|
||||
TrustedSection(title = stringResource(R.string.auto_connect_trusted_title))
|
||||
ShxSection(title = stringResource(R.string.auto_connect_trusted_title))
|
||||
{
|
||||
Text(
|
||||
text = stringResource(R.string.auto_connect_trusted_hint),
|
||||
@@ -245,7 +245,7 @@ fun TrustedNetworksScreen(
|
||||
sortedSsids.forEachIndexed { index, ssid ->
|
||||
if (index > 0)
|
||||
{
|
||||
TrustedDivider()
|
||||
ShxSectionDivider()
|
||||
}
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
@@ -277,7 +277,7 @@ fun TrustedNetworksScreen(
|
||||
}
|
||||
}
|
||||
}
|
||||
TrustedDivider()
|
||||
ShxSectionDivider()
|
||||
TrustedAddNetworkRow(
|
||||
currentSsid = currentSsid,
|
||||
onAddCurrent = { ssid -> update { it.copy(trustedSsids = it.trustedSsids + ssid) } },
|
||||
@@ -377,131 +377,6 @@ fun TrustedNetworksScreen(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TrustedSection(title: String, content: @Composable () -> Unit)
|
||||
{
|
||||
Text(
|
||||
text = title.uppercase(),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = ScreenPadding + 16.dp, top = 20.dp, bottom = 8.dp),
|
||||
)
|
||||
Surface(
|
||||
shape = MaterialTheme.shapes.large,
|
||||
color = MaterialTheme.colorScheme.surfaceContainerLow,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = ScreenPadding),
|
||||
)
|
||||
{
|
||||
Column { content() }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TrustedDivider()
|
||||
{
|
||||
HorizontalDivider(
|
||||
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.6f),
|
||||
modifier = Modifier.padding(start = 52.dp),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TrustedIconWrapper(icon: ImageVector)
|
||||
{
|
||||
Surface(
|
||||
shape = MaterialTheme.shapes.small,
|
||||
color = MaterialTheme.colorScheme.primaryContainer,
|
||||
modifier = Modifier.size(36.dp),
|
||||
)
|
||||
{
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier
|
||||
.padding(8.dp)
|
||||
.size(20.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TrustedToggleRow(
|
||||
icon: ImageVector,
|
||||
title: String,
|
||||
subtitle: String,
|
||||
checked: Boolean,
|
||||
onCheckedChange: (Boolean) -> Unit,
|
||||
)
|
||||
{
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 14.dp),
|
||||
)
|
||||
{
|
||||
TrustedIconWrapper(icon = icon)
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(horizontal = 16.dp),
|
||||
)
|
||||
{
|
||||
Text(title, style = MaterialTheme.typography.bodyLarge)
|
||||
Text(
|
||||
subtitle,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
Switch(checked = checked, onCheckedChange = onCheckedChange)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TrustedRow(
|
||||
icon: ImageVector,
|
||||
title: String,
|
||||
subtitle: String,
|
||||
enabled: Boolean,
|
||||
onClick: () -> Unit,
|
||||
)
|
||||
{
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(enabled = enabled, onClick = onClick)
|
||||
.padding(horizontal = 16.dp, vertical = 14.dp),
|
||||
)
|
||||
{
|
||||
TrustedIconWrapper(icon = icon)
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(horizontal = 16.dp),
|
||||
)
|
||||
{
|
||||
Text(title, style = MaterialTheme.typography.bodyLarge)
|
||||
Text(
|
||||
subtitle,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
Icon(
|
||||
Icons.AutoMirrored.Rounded.KeyboardArrowRight,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TrustedAddNetworkRow(
|
||||
currentSsid: String?,
|
||||
|
||||
+294
@@ -0,0 +1,294 @@
|
||||
/*
|
||||
* Copyright (C) 2026 SHX VPN
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or modify it
|
||||
* under the terms of the GNU General Public License as published by the
|
||||
* Free Software Foundation; either version 2 of the License, or (at your
|
||||
* option) any later version.
|
||||
*/
|
||||
|
||||
package org.strongswan.android.ui.compose
|
||||
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.rounded.Visibility
|
||||
import androidx.compose.material.icons.rounded.VisibilityOff
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.text.input.VisualTransformation
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import org.strongswan.android.R
|
||||
import org.strongswan.android.ui.compose.theme.ShxTheme
|
||||
|
||||
/**
|
||||
* Dialog shown on top of the transparent VpnProfileControlActivity during the
|
||||
* connect/disconnect flow. Carries just enough data to render; all actions are
|
||||
* delegated back to the activity through [VpnDialogListener].
|
||||
*/
|
||||
sealed class VpnDialog
|
||||
{
|
||||
data class Confirmation(
|
||||
val profileInfo: Bundle,
|
||||
val title: String,
|
||||
@StringRes val message: Int,
|
||||
@StringRes val confirmLabel: Int,
|
||||
val reconnect: Boolean,
|
||||
val disconnect: Boolean,
|
||||
) : VpnDialog()
|
||||
|
||||
data class Login(val profileInfo: Bundle, val username: String) : VpnDialog()
|
||||
|
||||
data class CertificateRequired(val profileInfo: Bundle, val uuid: String?) : VpnDialog()
|
||||
|
||||
data object PowerWhitelist : VpnDialog()
|
||||
|
||||
data class NotSupported(@StringRes val message: Int) : VpnDialog()
|
||||
}
|
||||
|
||||
/** Implemented by VpnProfileControlActivity; called from the dialog buttons. */
|
||||
interface VpnDialogListener
|
||||
{
|
||||
fun onVpnDialogConfirm(profileInfo: Bundle)
|
||||
fun onVpnDialogDisconnect()
|
||||
fun onVpnDialogLogin(profileInfo: Bundle, password: String)
|
||||
fun onVpnDialogEditProfile(uuid: String?)
|
||||
fun onVpnDialogPowerWhitelist()
|
||||
fun onVpnDialogCancel()
|
||||
}
|
||||
|
||||
/**
|
||||
* Holds the currently shown dialog so it survives activity recreations and can
|
||||
* be replaced at any point of the connect flow (the successor of the old
|
||||
* AppCompatDialogFragment stack).
|
||||
*/
|
||||
object VpnDialogBus
|
||||
{
|
||||
var listener: VpnDialogListener? = null
|
||||
|
||||
private val _dialog = MutableStateFlow<VpnDialog?>(null)
|
||||
val dialog: StateFlow<VpnDialog?> = _dialog.asStateFlow()
|
||||
|
||||
fun show(dialog: VpnDialog)
|
||||
{
|
||||
_dialog.value = dialog
|
||||
}
|
||||
|
||||
fun dismiss()
|
||||
{
|
||||
_dialog.value = null
|
||||
}
|
||||
}
|
||||
|
||||
/** Renders the connect-flow dialogs in the app theme inside the transparent activity. */
|
||||
fun ComponentActivity.installVpnDialogHost()
|
||||
{
|
||||
setContent {
|
||||
ShxTheme {
|
||||
VpnDialogHost()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun VpnDialogHost()
|
||||
{
|
||||
val dialog by VpnDialogBus.dialog.collectAsStateWithLifecycle()
|
||||
when (val d = dialog)
|
||||
{
|
||||
is VpnDialog.Confirmation -> ConfirmationUi(d)
|
||||
is VpnDialog.Login -> LoginUi(d)
|
||||
is VpnDialog.CertificateRequired -> CertificateRequiredUi(d)
|
||||
is VpnDialog.PowerWhitelist -> PowerWhitelistUi()
|
||||
is VpnDialog.NotSupported -> NotSupportedUi(d)
|
||||
null -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
/** Dismiss the dialog and finish the hosting activity, mirroring onCancel() of the old fragments. */
|
||||
private fun cancelDialog()
|
||||
{
|
||||
VpnDialogBus.dismiss()
|
||||
VpnDialogBus.listener?.onVpnDialogCancel()
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ConfirmationUi(dialog: VpnDialog.Confirmation)
|
||||
{
|
||||
AlertDialog(
|
||||
onDismissRequest = ::cancelDialog,
|
||||
title = { Text(dialog.title) },
|
||||
text = { Text(stringResource(dialog.message)) },
|
||||
confirmButton = {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
if (dialog.reconnect)
|
||||
{
|
||||
TextButton(onClick = {
|
||||
VpnDialogBus.dismiss()
|
||||
VpnDialogBus.listener?.onVpnDialogDisconnect()
|
||||
}) {
|
||||
Text(
|
||||
stringResource(R.string.disconnect),
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
}
|
||||
TextButton(onClick = {
|
||||
VpnDialogBus.dismiss()
|
||||
val listener = VpnDialogBus.listener
|
||||
if (dialog.disconnect)
|
||||
{
|
||||
listener?.onVpnDialogDisconnect()
|
||||
}
|
||||
else
|
||||
{
|
||||
listener?.onVpnDialogConfirm(dialog.profileInfo)
|
||||
}
|
||||
}) { Text(stringResource(dialog.confirmLabel)) }
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = ::cancelDialog) {
|
||||
Text(stringResource(android.R.string.cancel))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LoginUi(dialog: VpnDialog.Login)
|
||||
{
|
||||
var username by rememberSaveable { mutableStateOf(dialog.username ?: "") }
|
||||
var password by rememberSaveable { mutableStateOf("") }
|
||||
var showPassword by rememberSaveable { mutableStateOf(false) }
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = ::cancelDialog,
|
||||
title = { Text(stringResource(R.string.login_title)) },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
OutlinedTextField(
|
||||
value = username,
|
||||
onValueChange = { username = it },
|
||||
label = { Text(stringResource(R.string.login_username)) },
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
|
||||
enabled = false,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = password,
|
||||
onValueChange = { password = it },
|
||||
label = { Text(stringResource(R.string.login_password)) },
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.Password,
|
||||
imeAction = ImeAction.Done,
|
||||
),
|
||||
visualTransformation = if (showPassword) VisualTransformation.None else PasswordVisualTransformation(),
|
||||
trailingIcon = {
|
||||
IconButton(onClick = { showPassword = !showPassword }) {
|
||||
Icon(
|
||||
if (showPassword) Icons.Rounded.Visibility else Icons.Rounded.VisibilityOff,
|
||||
contentDescription = stringResource(
|
||||
if (showPassword) R.string.login_hide_password else R.string.login_show_password
|
||||
),
|
||||
)
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
VpnDialogBus.dismiss()
|
||||
VpnDialogBus.listener?.onVpnDialogLogin(dialog.profileInfo, password)
|
||||
}) { Text(stringResource(R.string.login_confirm)) }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = ::cancelDialog) {
|
||||
Text(stringResource(android.R.string.cancel))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CertificateRequiredUi(dialog: VpnDialog.CertificateRequired)
|
||||
{
|
||||
AlertDialog(
|
||||
onDismissRequest = ::cancelDialog,
|
||||
title = { Text(stringResource(R.string.certificate_required_title)) },
|
||||
text = { Text(stringResource(R.string.certificate_required_text)) },
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
VpnDialogBus.dismiss()
|
||||
VpnDialogBus.listener?.onVpnDialogEditProfile(dialog.uuid)
|
||||
}) { Text(stringResource(R.string.edit_profile)) }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = ::cancelDialog) {
|
||||
Text(stringResource(android.R.string.cancel))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PowerWhitelistUi()
|
||||
{
|
||||
AlertDialog(
|
||||
onDismissRequest = ::cancelDialog,
|
||||
title = { Text(stringResource(R.string.power_whitelist_title)) },
|
||||
text = { Text(stringResource(R.string.power_whitelist_text)) },
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
VpnDialogBus.dismiss()
|
||||
VpnDialogBus.listener?.onVpnDialogPowerWhitelist()
|
||||
}) { Text(stringResource(android.R.string.ok)) }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NotSupportedUi(dialog: VpnDialog.NotSupported)
|
||||
{
|
||||
AlertDialog(
|
||||
onDismissRequest = ::cancelDialog,
|
||||
title = { Text(stringResource(R.string.vpn_not_supported_title)) },
|
||||
text = { Text(stringResource(dialog.message)) },
|
||||
confirmButton = {
|
||||
TextButton(onClick = ::cancelDialog) {
|
||||
Text(stringResource(android.R.string.ok))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
+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.
|
||||
*/
|
||||
|
||||
package org.strongswan.android.ui.compose.components
|
||||
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
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.RowScope
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
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.automirrored.rounded.KeyboardArrowRight
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Switch
|
||||
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.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.strongswan.android.ui.compose.theme.ScreenPadding
|
||||
|
||||
/** Uppercase section header + grouped card, the standard grouped-list building block. */
|
||||
@Composable
|
||||
fun ShxSection(title: String, content: @Composable () -> Unit)
|
||||
{
|
||||
Text(
|
||||
text = title.uppercase(),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = ScreenPadding + 16.dp, top = 20.dp, bottom = 8.dp),
|
||||
)
|
||||
ShxCard(modifier = Modifier.fillMaxWidth().padding(horizontal = ScreenPadding)) {
|
||||
Column { content() }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ShxSectionDivider(indent: Dp = 68.dp)
|
||||
{
|
||||
HorizontalDivider(
|
||||
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.6f),
|
||||
modifier = Modifier.padding(start = indent),
|
||||
)
|
||||
}
|
||||
|
||||
/** Rounded-square icon badge shared by all setting rows. */
|
||||
@Composable
|
||||
fun ShxIconBadge(icon: ImageVector, tint: Color = MaterialTheme.colorScheme.primary)
|
||||
{
|
||||
Surface(
|
||||
shape = MaterialTheme.shapes.small,
|
||||
color = MaterialTheme.colorScheme.primaryContainer,
|
||||
modifier = Modifier.size(36.dp),
|
||||
)
|
||||
{
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
tint = tint,
|
||||
modifier = Modifier
|
||||
.padding(8.dp)
|
||||
.size(20.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RowScope.RowLabels(title: String, subtitle: String?, enabled: Boolean)
|
||||
{
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(horizontal = 16.dp),
|
||||
)
|
||||
{
|
||||
Text(
|
||||
title,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = if (enabled) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
if (subtitle != null)
|
||||
{
|
||||
Text(
|
||||
subtitle,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Setting row with a chevron; tap opens a target screen or dialog. */
|
||||
@Composable
|
||||
fun ShxSettingRow(
|
||||
icon: ImageVector,
|
||||
title: String,
|
||||
subtitle: String? = null,
|
||||
enabled: Boolean = true,
|
||||
onClick: () -> Unit,
|
||||
)
|
||||
{
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(enabled = enabled, onClick = onClick)
|
||||
.padding(horizontal = 16.dp, vertical = 14.dp),
|
||||
)
|
||||
{
|
||||
ShxIconBadge(icon = icon, tint = if (enabled) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
RowLabels(title, subtitle, enabled)
|
||||
Icon(
|
||||
Icons.AutoMirrored.Rounded.KeyboardArrowRight,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Setting row with a trailing switch; the whole row toggles. */
|
||||
@Composable
|
||||
fun ShxSettingToggleRow(
|
||||
icon: ImageVector,
|
||||
title: String,
|
||||
subtitle: String? = null,
|
||||
checked: Boolean,
|
||||
enabled: Boolean = true,
|
||||
onCheckedChange: (Boolean) -> Unit,
|
||||
)
|
||||
{
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(enabled = enabled) { onCheckedChange(!checked) }
|
||||
.padding(horizontal = 16.dp, vertical = 14.dp),
|
||||
)
|
||||
{
|
||||
ShxIconBadge(icon = icon, tint = if (enabled) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
RowLabels(title, subtitle, enabled)
|
||||
Switch(checked = checked, onCheckedChange = onCheckedChange, enabled = enabled)
|
||||
}
|
||||
}
|
||||
|
||||
/** The standard rounded card: low container, optional hairline border. */
|
||||
@Composable
|
||||
fun ShxCard(
|
||||
modifier: Modifier = Modifier,
|
||||
bordered: Boolean = false,
|
||||
content: @Composable () -> Unit,
|
||||
)
|
||||
{
|
||||
Surface(
|
||||
shape = MaterialTheme.shapes.large,
|
||||
color = MaterialTheme.colorScheme.surfaceContainerLow,
|
||||
border = if (bordered) BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant) else null,
|
||||
modifier = modifier,
|
||||
) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
||||
/** Icon + message + optional call-to-action, used for all empty states. */
|
||||
@Composable
|
||||
fun ShxEmptyState(
|
||||
icon: ImageVector,
|
||||
message: String,
|
||||
modifier: Modifier = Modifier,
|
||||
actionLabel: String? = null,
|
||||
onAction: (() -> Unit)? = null,
|
||||
)
|
||||
{
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(0.dp),
|
||||
)
|
||||
{
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(64.dp)
|
||||
.background(MaterialTheme.colorScheme.surfaceContainerHigh, CircleShape),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
icon,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(30.dp),
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(14.dp))
|
||||
Text(
|
||||
message,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
fontWeight = FontWeight.Medium,
|
||||
)
|
||||
if (actionLabel != null && onAction != null)
|
||||
{
|
||||
Spacer(Modifier.height(16.dp))
|
||||
OutlinedButton(onClick = onAction, shape = MaterialTheme.shapes.medium) { Text(actionLabel) }
|
||||
}
|
||||
}
|
||||
}
|
||||
+96
-1
@@ -9,18 +9,34 @@
|
||||
|
||||
package org.strongswan.android.ui.compose.theme
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Shapes
|
||||
import androidx.compose.material3.Typography
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.material3.dynamicDarkColorScheme
|
||||
import androidx.compose.material3.dynamicLightColorScheme
|
||||
import androidx.compose.material3.lightColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.preference.PreferenceManager
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
|
||||
val ConnectionCardRadius = 16.dp
|
||||
val ScreenPadding = 16.dp
|
||||
@@ -143,6 +159,8 @@ private val DarkStatusColors = ShxStatusColors(
|
||||
onDisconnectedContainer = Color(0xFFFFE1DE),
|
||||
)
|
||||
|
||||
/* Kept independent of dynamic color on purpose: the connected/connecting/disconnected
|
||||
* semantics must stay green/purple/red regardless of the user's wallpaper. */
|
||||
val LocalShxStatusColors = staticCompositionLocalOf { LightStatusColors }
|
||||
|
||||
private val ShxShapes = Shapes(
|
||||
@@ -153,15 +171,92 @@ private val ShxShapes = Shapes(
|
||||
extraLarge = RoundedCornerShape(22.dp),
|
||||
)
|
||||
|
||||
/**
|
||||
* Process-wide holder of the "use system colors" preference, so flipping it in
|
||||
* Settings re-themes every open Compose surface without recreating activities.
|
||||
*/
|
||||
object ShxThemeSettings
|
||||
{
|
||||
private const val KEY_DYNAMIC_COLORS = "pref_use_dynamic_colors"
|
||||
|
||||
private val _useDynamicColors = MutableStateFlow(false)
|
||||
|
||||
val useDynamicColors: StateFlow<Boolean> = _useDynamicColors.asStateFlow()
|
||||
|
||||
private var loaded = false
|
||||
|
||||
private fun prefs(context: Context) =
|
||||
PreferenceManager.getDefaultSharedPreferences(context.applicationContext)
|
||||
|
||||
/** One-shot synchronous read; cheap enough to run before the first frame. */
|
||||
fun load(context: Context)
|
||||
{
|
||||
synchronized(this) {
|
||||
if (loaded)
|
||||
{
|
||||
return
|
||||
}
|
||||
_useDynamicColors.value = prefs(context).getBoolean(KEY_DYNAMIC_COLORS, false)
|
||||
loaded = true
|
||||
}
|
||||
}
|
||||
|
||||
fun setUseDynamicColors(context: Context, enabled: Boolean)
|
||||
{
|
||||
load(context)
|
||||
prefs(context).edit().putBoolean(KEY_DYNAMIC_COLORS, enabled).apply()
|
||||
_useDynamicColors.value = enabled
|
||||
}
|
||||
}
|
||||
|
||||
/** Tabular figures so ticking numbers (traffic, RTT, duration) don't jitter. */
|
||||
private fun withTabularNumerals(style: TextStyle): TextStyle = style.copy(fontFeatureSettings = "tnum")
|
||||
|
||||
private fun buildTypography(): Typography
|
||||
{
|
||||
val base = Typography()
|
||||
return base.copy(
|
||||
displayLarge = withTabularNumerals(base.displayLarge),
|
||||
displayMedium = withTabularNumerals(base.displayMedium),
|
||||
displaySmall = withTabularNumerals(base.displaySmall),
|
||||
headlineLarge = withTabularNumerals(base.headlineLarge),
|
||||
headlineMedium = withTabularNumerals(base.headlineMedium).copy(fontWeight = FontWeight.SemiBold),
|
||||
headlineSmall = withTabularNumerals(base.headlineSmall),
|
||||
titleLarge = withTabularNumerals(base.titleLarge),
|
||||
titleMedium = withTabularNumerals(base.titleMedium).copy(fontSize = 18.sp),
|
||||
titleSmall = withTabularNumerals(base.titleSmall),
|
||||
bodyLarge = withTabularNumerals(base.bodyLarge),
|
||||
bodyMedium = withTabularNumerals(base.bodyMedium),
|
||||
bodySmall = withTabularNumerals(base.bodySmall),
|
||||
labelLarge = withTabularNumerals(base.labelLarge),
|
||||
labelMedium = withTabularNumerals(base.labelMedium),
|
||||
labelSmall = withTabularNumerals(base.labelSmall),
|
||||
)
|
||||
}
|
||||
|
||||
private val ShxTypography = buildTypography()
|
||||
|
||||
@Composable
|
||||
fun ShxTheme(darkTheme: Boolean = isSystemInDarkTheme(), content: @Composable () -> Unit)
|
||||
{
|
||||
val colors = if (darkTheme) DarkColors else LightColors
|
||||
val context = LocalContext.current
|
||||
val useDynamicColors by remember { ShxThemeSettings.load(context); ShxThemeSettings.useDynamicColors }
|
||||
.collectAsStateWithLifecycle()
|
||||
val colors = when
|
||||
{
|
||||
useDynamicColors && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S ->
|
||||
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
|
||||
darkTheme -> DarkColors
|
||||
else -> LightColors
|
||||
}
|
||||
val statusColors = if (darkTheme) DarkStatusColors else LightStatusColors
|
||||
CompositionLocalProvider(LocalShxStatusColors provides statusColors) {
|
||||
/* MaterialExpressiveTheme/MotionScheme недоступны: в material3 1.4.0 они internal,
|
||||
а 1.5.0-alpha требует compose 1.10+ (minSdk 23). Вернуться при подъеме minSdk. */
|
||||
MaterialTheme(
|
||||
colorScheme = colors,
|
||||
shapes = ShxShapes,
|
||||
typography = ShxTypography,
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
|
||||
-68
@@ -1,68 +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.widget;
|
||||
|
||||
import android.content.Context;
|
||||
import android.util.AttributeSet;
|
||||
import android.widget.Checkable;
|
||||
import android.widget.LinearLayout;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
public class CheckableLinearLayout extends LinearLayout implements Checkable
|
||||
{
|
||||
private static final int[] CHECKED_STATE_SET = {android.R.attr.state_checked};
|
||||
private boolean mChecked;
|
||||
|
||||
public CheckableLinearLayout(Context context, @Nullable AttributeSet attrs)
|
||||
{
|
||||
super(context, attrs);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setChecked(boolean checked)
|
||||
{
|
||||
if (mChecked != checked)
|
||||
{
|
||||
mChecked = checked;
|
||||
refreshDrawableState();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isChecked()
|
||||
{
|
||||
return mChecked;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void toggle()
|
||||
{
|
||||
setChecked(!mChecked);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected int[] onCreateDrawableState(int extraSpace)
|
||||
{
|
||||
final int[] drawableState = super.onCreateDrawableState(extraSpace + 1);
|
||||
if (isChecked())
|
||||
{
|
||||
mergeDrawableStates(drawableState, CHECKED_STATE_SET);
|
||||
}
|
||||
return drawableState;
|
||||
}
|
||||
}
|
||||
-186
@@ -1,186 +0,0 @@
|
||||
/*
|
||||
* Copyright (C) 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.widget;
|
||||
|
||||
import android.animation.Animator;
|
||||
import android.animation.AnimatorListenerAdapter;
|
||||
import android.content.Context;
|
||||
import android.content.res.TypedArray;
|
||||
import android.text.Editable;
|
||||
import android.text.TextWatcher;
|
||||
import android.util.AttributeSet;
|
||||
import android.util.TypedValue;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.EditText;
|
||||
import android.widget.LinearLayout;
|
||||
import android.widget.TextView;
|
||||
|
||||
import com.google.android.material.textfield.TextInputLayout;
|
||||
|
||||
import org.strongswan.android.R;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
/**
|
||||
* Layout that extends {@link TextInputLayout} with a helper text
|
||||
* displayed below the text field when it receives the focus. Also, any error message shown with
|
||||
* {@link #setError(CharSequence)} is hidden when the text field is changed (this mirrors the
|
||||
* behavior of {@link android.widget.EditText}).
|
||||
*/
|
||||
public class TextInputLayoutHelper extends TextInputLayout
|
||||
{
|
||||
private LinearLayout mHelperContainer;
|
||||
private TextView mHelperText;
|
||||
|
||||
public TextInputLayoutHelper(Context context)
|
||||
{
|
||||
this(context, null);
|
||||
}
|
||||
|
||||
public TextInputLayoutHelper(Context context, AttributeSet attrs)
|
||||
{
|
||||
this(context, attrs, 0);
|
||||
}
|
||||
|
||||
public TextInputLayoutHelper(Context context, AttributeSet attrs, int defStyleAttr)
|
||||
{
|
||||
super(context, attrs);
|
||||
TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.TextInputLayoutHelper);
|
||||
String helper = a.getString(R.styleable.TextInputLayoutHelper_helper_text);
|
||||
a.recycle();
|
||||
if (helper != null)
|
||||
{
|
||||
mHelperContainer = new LinearLayout(context);
|
||||
mHelperContainer.setOrientation(LinearLayout.HORIZONTAL);
|
||||
mHelperContainer.setVisibility(View.INVISIBLE);
|
||||
addView(mHelperContainer, LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT);
|
||||
|
||||
mHelperText = new TextView(context);
|
||||
mHelperText.setText(helper);
|
||||
mHelperText.setTextSize(TypedValue.COMPLEX_UNIT_SP, 12);
|
||||
a = context.obtainStyledAttributes(attrs, new int[]{android.R.attr.textColorSecondary});
|
||||
mHelperText.setTextColor(a.getColor(0, mHelperText.getCurrentTextColor()));
|
||||
a.recycle();
|
||||
|
||||
mHelperContainer.addView(mHelperText, LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void addView(View child, int index, ViewGroup.LayoutParams params)
|
||||
{
|
||||
super.addView(child, index, params);
|
||||
if (child instanceof EditText)
|
||||
{
|
||||
EditText text = (EditText)child;
|
||||
text.addTextChangedListener(new TextWatcher() {
|
||||
@Override
|
||||
public void beforeTextChanged(CharSequence s, int start, int count, int after) {}
|
||||
|
||||
@Override
|
||||
public void onTextChanged(CharSequence s, int start, int before, int count) {}
|
||||
|
||||
@Override
|
||||
public void afterTextChanged(Editable s)
|
||||
{
|
||||
if (getError() != null)
|
||||
{
|
||||
setError(null);
|
||||
}
|
||||
}
|
||||
});
|
||||
if (mHelperContainer != null)
|
||||
{
|
||||
text.setOnFocusChangeListener(new OnFocusChangeListener() {
|
||||
@Override
|
||||
public void onFocusChange(View v, boolean hasFocus)
|
||||
{
|
||||
showHelper(hasFocus);
|
||||
}
|
||||
});
|
||||
mHelperContainer.setPaddingRelative(text.getPaddingStart(), 0,
|
||||
text.getPaddingEnd(), text.getPaddingBottom());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setError(@Nullable CharSequence error)
|
||||
{
|
||||
super.setError(error);
|
||||
if (mHelperContainer != null)
|
||||
{
|
||||
if (error == null)
|
||||
{ /* this frees up space used by the now invisible error message */
|
||||
setErrorEnabled(false);
|
||||
}
|
||||
else
|
||||
{ /* re-add the helper as the error message should be displayed directly under the textbox */
|
||||
removeView(mHelperContainer);
|
||||
addView(mHelperContainer, LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the helper text to be displayed below the text field.
|
||||
*
|
||||
* @attr ref R.styleable#TextInputLayoutHelper_helper_text
|
||||
*/
|
||||
public void setHelperText(CharSequence text)
|
||||
{
|
||||
if (mHelperText != null)
|
||||
{
|
||||
mHelperText.setText(text);
|
||||
}
|
||||
}
|
||||
|
||||
private void showHelper(boolean show)
|
||||
{
|
||||
if (show == (mHelperContainer.getVisibility() == View.VISIBLE))
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (show)
|
||||
{
|
||||
mHelperContainer.animate()
|
||||
.alpha(1f)
|
||||
.setDuration(200)
|
||||
.setListener(new AnimatorListenerAdapter() {
|
||||
@Override
|
||||
public void onAnimationStart(Animator animation)
|
||||
{
|
||||
mHelperContainer.setVisibility(View.VISIBLE);
|
||||
}
|
||||
}).start();
|
||||
}
|
||||
else
|
||||
{
|
||||
mHelperContainer.animate()
|
||||
.alpha(0f)
|
||||
.setDuration(200)
|
||||
.setListener(new AnimatorListenerAdapter() {
|
||||
@Override
|
||||
public void onAnimationEnd(Animator animation)
|
||||
{
|
||||
mHelperContainer.setVisibility(View.INVISIBLE);
|
||||
}
|
||||
}).start();
|
||||
}
|
||||
}
|
||||
}
|
||||
+116
-1
@@ -34,8 +34,11 @@ public final class Constants
|
||||
public static final String VPN_PROFILES_MULTIPLE = "one.shx.strongswan.ext.VPN_PROFILES_MULTIPLE";
|
||||
|
||||
/**
|
||||
* Limits for MTU
|
||||
* Limits for MTU. The default matches ANDROID_DEFAULT_MTU in
|
||||
* charonservice.h, so the Java and native sides agree; larger values
|
||||
* invite fragmentation and retransmissions on constrained paths.
|
||||
*/
|
||||
public static final int MTU_DEFAULT = 1400;
|
||||
public static final int MTU_MAX = 1500;
|
||||
public static final int MTU_MIN = 1280;
|
||||
|
||||
@@ -69,4 +72,116 @@ public final class Constants
|
||||
* Preference key to store whether the user permanently dismissed our warning to add the app to the power whitelist
|
||||
*/
|
||||
public static final String PREF_IGNORE_POWER_WHITELIST = "pref_ignore_power_whitelist";
|
||||
|
||||
/**
|
||||
* Preference key to store whether automation shortcuts (launcher, Samsung Modes and Routines) are published
|
||||
*/
|
||||
public static final String PREF_PUBLISH_SHORTCUTS = "pref_publish_shortcuts";
|
||||
|
||||
/**
|
||||
* Fallback base URL of the update storage with a trailing slash.
|
||||
* Overridden at build time by S3_PUBLIC_BASE_URL from .env (BuildConfig.UPDATE_BASE_URL).
|
||||
*/
|
||||
public static final String UPDATE_BASE_URL = "https://s3.ru-7.storage.selcloud.ru/shx-app/";
|
||||
|
||||
/**
|
||||
* Name of the update manifest on the update storage
|
||||
*/
|
||||
public static final String UPDATE_MANIFEST_FILE = "latest.json";
|
||||
|
||||
/**
|
||||
* Minimum interval between automatic update checks.
|
||||
* TODO: revert to 12 * 60 * 60 * 1000L (12h) once the silent update flow
|
||||
* has been verified on a real device
|
||||
*/
|
||||
public static final long UPDATE_CHECK_INTERVAL_MS = 10 * 60 * 1000L;
|
||||
|
||||
/**
|
||||
* Preference key to store the timestamp of the last update check
|
||||
*/
|
||||
public static final String PREF_LAST_UPDATE_CHECK = "pref_last_update_check";
|
||||
|
||||
/**
|
||||
* Preference key to store whether updates are checked automatically when the app starts
|
||||
*/
|
||||
public static final String PREF_AUTO_UPDATE_CHECK = "pref_auto_update_check";
|
||||
|
||||
/**
|
||||
* Preference key to store whether updates are downloaded in the background
|
||||
* and installed without user interaction when possible (Android 12+)
|
||||
*/
|
||||
public static final String PREF_SILENT_UPDATES = "pref_silent_updates";
|
||||
|
||||
/**
|
||||
* Preference key to store whether the watchdog should restore the VPN
|
||||
* connection after the hosting process died
|
||||
*/
|
||||
public static final String PREF_WATCHDOG_ENABLED = "pref_watchdog_enabled";
|
||||
|
||||
/**
|
||||
* Preference key to store the UUID of the profile that is expected to be
|
||||
* connected. Written on connect, cleared on explicit disconnect, so the
|
||||
* watchdog can tell a crashed process from a user decision.
|
||||
*/
|
||||
public static final String PREF_WATCHDOG_EXPECTED_PROFILE = "pref_watchdog_expected_profile";
|
||||
|
||||
/**
|
||||
* Preference key to store whether a trusted-networks auto session is
|
||||
* expected to be up whenever the network allows it, even though the VPN
|
||||
* is currently parked on a trusted Wi-Fi. The watchdog uses this to
|
||||
* restore the tunnel after the process died while parked on a trusted
|
||||
* Wi-Fi and the device later moved to cellular (auto-connect, which would
|
||||
* otherwise reconnect, died with the process).
|
||||
*/
|
||||
public static final String PREF_WATCHDOG_AUTO_EXPECTED = "pref_watchdog_auto_expected";
|
||||
|
||||
/**
|
||||
* Preference key to store whether IPv6 should be blackholed when the
|
||||
* tunnel provides no IPv6 configuration, instead of letting it bypass
|
||||
* the VPN via allowFamily(AF_INET6)
|
||||
*/
|
||||
public static final String PREF_SAFE_IPV6 = "pref_safe_ipv6";
|
||||
|
||||
/**
|
||||
* RU geoip list (MetaCubeX meta-rules-dat): Russian networks that bypass the
|
||||
* tunnel when the routing beta is enabled. The mirror serves the same file
|
||||
* via jsDelivr for the cases GitHub is unreachable.
|
||||
*/
|
||||
public static final String RUSPLIT_GEOIP_URL =
|
||||
"https://raw.githubusercontent.com/MetaCubeX/meta-rules-dat/meta/geo/geoip/ru.list";
|
||||
|
||||
public static final String RUSPLIT_GEOIP_MIRROR_URL =
|
||||
"https://cdn.jsdelivr.net/gh/MetaCubeX/meta-rules-dat@meta/geo/geoip/ru.list";
|
||||
|
||||
/**
|
||||
* Minimum interval between automatic RU geoip list updates
|
||||
*/
|
||||
public static final long RUSPLIT_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000L;
|
||||
|
||||
/**
|
||||
* Hard cap on the number of complement routes installed for the RU geoip
|
||||
* list (belt and braces on top of the byte budget in
|
||||
* RuIpListManager.fitExclusionsToComplement, which normally lands at
|
||||
* 3-4 thousand routes)
|
||||
*/
|
||||
public static final int VPN_ROUTE_LIMIT = 6000;
|
||||
|
||||
/**
|
||||
* Preference key to store whether RU networks bypass the tunnel
|
||||
*/
|
||||
public static final String PREF_RUSPLIT_GEOIP_ENABLED = "pref_rusplit_geoip_enabled";
|
||||
|
||||
/**
|
||||
* How the RU split is routed when enabled:
|
||||
* "system" - the complement of the RU list is installed as OS routes
|
||||
* (coarsened to fit the binder/route budget, as shipped
|
||||
* in 2.12.12)
|
||||
* "userspace" - only the two default routes point into the TUN and the
|
||||
* direct/tunnel decision is made per packet in native code
|
||||
* (exact list, hot list updates, beta)
|
||||
*/
|
||||
public static final String PREF_RUSPLIT_MODE = "pref_rusplit_mode";
|
||||
|
||||
public static final String RUSPLIT_MODE_SYSTEM = "system";
|
||||
public static final String RUSPLIT_MODE_USERSPACE = "userspace";
|
||||
}
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
/*
|
||||
* 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.utils
|
||||
|
||||
import android.content.Context
|
||||
import androidx.preference.PreferenceManager
|
||||
|
||||
/**
|
||||
* Resolves the profile that "connect" style features (QS tile, automation
|
||||
* shortcuts, deep links) should operate on when no explicit profile is given.
|
||||
*/
|
||||
object DefaultVpnProfile
|
||||
{
|
||||
/**
|
||||
* Returns the UUID of the default VPN profile, falling back to the most
|
||||
* recently used one if the setting is unset or set to "MRU". May be null
|
||||
* if no profile has ever been used.
|
||||
*/
|
||||
@JvmStatic
|
||||
fun getDefaultProfileUuid(context: Context): String?
|
||||
{
|
||||
val pref = PreferenceManager.getDefaultSharedPreferences(context)
|
||||
var uuid = pref.getString(Constants.PREF_DEFAULT_VPN_PROFILE, null)
|
||||
if (uuid == null || uuid == Constants.PREF_DEFAULT_VPN_PROFILE_MRU)
|
||||
{
|
||||
uuid = pref.getString(Constants.PREF_MRU_VPN_PROFILE, null)
|
||||
}
|
||||
return uuid
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@ strongswan_USE_BYOD := true
|
||||
|
||||
strongswan_CHARON_PLUGINS := android-log socket-default openssl nonce \
|
||||
pkcs1 pem x509 xcbc kdf revocation \
|
||||
eap-identity eap-mschapv2 eap-md5 eap-gtc eap-tls
|
||||
eap-identity eap-mschapv2 eap-md5 eap-gtc eap-tls eap-peap
|
||||
|
||||
ifneq ($(strongswan_USE_BYOD),)
|
||||
strongswan_BYOD_PLUGINS := eap-ttls eap-tnc tnc-imc tnc-tnccs tnccs-20
|
||||
@@ -68,6 +68,7 @@ endif
|
||||
|
||||
strongswan_BUILD := \
|
||||
openssl \
|
||||
lwip \
|
||||
libandroidbridge \
|
||||
$(strongswan_DIR)/src/libipsec \
|
||||
$(strongswan_DIR)/src/libcharon \
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
APP_PLATFORM := android-21
|
||||
APP_SUPPORT_FLEXIBLE_PAGE_SIZES := true
|
||||
APP_ABI := armeabi-v7a arm64-v8a x86_64
|
||||
@@ -15,6 +15,10 @@ charonservice.c \
|
||||
kernel/android_ipsec.c \
|
||||
kernel/android_net.c \
|
||||
kernel/network_manager.c \
|
||||
routing/direct_engine.c \
|
||||
routing/direct_tcp.c \
|
||||
routing/direct_udp.c \
|
||||
routing/ru_set.c \
|
||||
vpnservice_builder.c
|
||||
|
||||
ifneq ($(strongswan_USE_BYOD),)
|
||||
@@ -28,7 +32,9 @@ endif
|
||||
LOCAL_C_INCLUDES += \
|
||||
$(strongswan_PATH)/src/libipsec \
|
||||
$(strongswan_PATH)/src/libcharon \
|
||||
$(strongswan_PATH)/src/libstrongswan
|
||||
$(strongswan_PATH)/src/libstrongswan \
|
||||
$(LOCAL_PATH)/../lwip/src/include \
|
||||
$(LOCAL_PATH)/../lwip/port
|
||||
|
||||
ifneq ($(strongswan_USE_BYOD),)
|
||||
LOCAL_C_INCLUDES += \
|
||||
@@ -58,6 +64,8 @@ LOCAL_LDLIBS := -llog
|
||||
|
||||
LOCAL_SHARED_LIBRARIES := libstrongswan libipsec libcharon
|
||||
|
||||
LOCAL_STATIC_LIBRARIES := liblwip
|
||||
|
||||
ifneq ($(strongswan_USE_BYOD),)
|
||||
LOCAL_SHARED_LIBRARIES += libimcv libtncif libtnccs libtpmtss
|
||||
endif
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
#include "android_dns_proxy.h"
|
||||
#include "../charonservice.h"
|
||||
#include "../vpnservice_builder.h"
|
||||
#include "../routing/direct_engine.h"
|
||||
|
||||
#include <daemon.h>
|
||||
#include <library.h>
|
||||
@@ -126,6 +127,15 @@ CALLBACK(receiver_esp_cb, void,
|
||||
ipsec->processor->queue_inbound(ipsec->processor, esp_packet);
|
||||
}
|
||||
|
||||
/**
|
||||
* TUN write callback for the userspace routing engine (engine thread),
|
||||
* typed to the engine's callback signature
|
||||
*/
|
||||
static void engine_write_packet(void *ctx, ip_packet_t *packet)
|
||||
{
|
||||
deliver_plain(ctx, packet);
|
||||
}
|
||||
|
||||
/**
|
||||
* Job handling outbound plaintext packets
|
||||
*/
|
||||
@@ -187,9 +197,21 @@ static job_requeue_t handle_plain(private_android_service_t *this)
|
||||
packet = ip_packet_create(raw);
|
||||
if (packet)
|
||||
{
|
||||
if (!dns_proxy || !this->dns_proxy->handle(this->dns_proxy, packet))
|
||||
if (dns_proxy && this->dns_proxy->handle(this->dns_proxy, packet))
|
||||
{
|
||||
ipsec->processor->queue_outbound(ipsec->processor, packet);
|
||||
/* handled by the DNS proxy */
|
||||
}
|
||||
else
|
||||
{
|
||||
direct_engine_t *engine = direct_engine_get();
|
||||
|
||||
if (engine && engine->handle(engine, packet))
|
||||
{ /* direct flow, handled by the userspace router */
|
||||
}
|
||||
else
|
||||
{
|
||||
ipsec->processor->queue_outbound(ipsec->processor, packet);
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
@@ -350,6 +372,11 @@ static bool setup_tun_device(private_android_service_t *this,
|
||||
|
||||
DBG1(DBG_DMN, "successfully created TUN device");
|
||||
|
||||
/* remember the binding even if the userspace routing engine does not
|
||||
* exist yet (mode B is enabled after the tunnel is up); it is applied
|
||||
* when the engine is created */
|
||||
direct_engine_set_tun(this, engine_write_packet, this->mtu);
|
||||
|
||||
if (!already_registered)
|
||||
{
|
||||
charon->receiver->add_esp_cb(charon->receiver, receiver_esp_cb, NULL);
|
||||
@@ -390,6 +417,9 @@ static bool setup_tun_device_without_dns(private_android_service_t *this)
|
||||
this->tunfd = tunfd;
|
||||
this->lock->unlock(this->lock);
|
||||
|
||||
/* the fd changed, re-bind the userspace router to the new TUN */
|
||||
direct_engine_set_tun(this, engine_write_packet, this->mtu);
|
||||
|
||||
DBG1(DBG_DMN, "successfully created TUN device without DNS");
|
||||
return TRUE;
|
||||
}
|
||||
@@ -411,6 +441,10 @@ static void close_tun_device(private_android_service_t *this)
|
||||
this->tunfd = -1;
|
||||
this->lock->unlock(this->lock);
|
||||
|
||||
/* drop the remembered binding too, so a lazily created engine does not
|
||||
* attach to the dead TUN, and flush the engine if it exists */
|
||||
direct_engine_set_tun(NULL, NULL, 0);
|
||||
|
||||
this->dns_proxy->unregister_cb(this->dns_proxy, deliver_plain);
|
||||
ipsec->processor->unregister_outbound(ipsec->processor, send_esp);
|
||||
ipsec->processor->unregister_inbound(ipsec->processor, deliver_plain);
|
||||
@@ -792,9 +826,13 @@ static job_requeue_t initiate(private_android_service_t *this)
|
||||
};
|
||||
char *type, *remote_id;
|
||||
|
||||
/* only try once and notify the GUI since Android 5+ where we have a
|
||||
* blocking TUN device */
|
||||
peer.keyingtries = 1;
|
||||
/* give a reestablishment enough time to ride out network handovers before
|
||||
* we tear the whole daemon down and let the GUI-level retry loop start
|
||||
* over: retransmits are bounded to ~9s per attempt, so six tries give
|
||||
* MOBIKE about a minute to migrate the tunnel to a new network (Wi-Fi →
|
||||
* cellular handovers can take that long to become usable); the GUI is
|
||||
* notified once the last try fails */
|
||||
peer.keyingtries = 6;
|
||||
|
||||
ike.remote = this->settings->get_str(this->settings, "connection.server",
|
||||
NULL);
|
||||
|
||||
@@ -18,9 +18,16 @@
|
||||
|
||||
#include <signal.h>
|
||||
#include <string.h>
|
||||
#include <sys/prctl.h>
|
||||
#include <sys/utsname.h>
|
||||
#include <ucontext.h>
|
||||
#include <android/log.h>
|
||||
#include <dlfcn.h>
|
||||
#include <errno.h>
|
||||
#include <fcntl.h>
|
||||
#include <pthread.h>
|
||||
#include <stdio.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#include "charonservice.h"
|
||||
#include "android_jni.h"
|
||||
@@ -32,6 +39,8 @@
|
||||
#include "backend/android_service.h"
|
||||
#include "kernel/android_ipsec.h"
|
||||
#include "kernel/android_net.h"
|
||||
#include "routing/direct_engine.h"
|
||||
#include "routing/ru_set.h"
|
||||
|
||||
#ifdef USE_BYOD
|
||||
#include "byod/imc_android.h"
|
||||
@@ -53,6 +62,11 @@
|
||||
|
||||
static volatile bool charon_running = FALSE;
|
||||
|
||||
/* guards charon_running/charon against concurrent access from the UI thread
|
||||
* (queryTraffic, polled every second) and the connection thread
|
||||
* (deinitializeCharon destroys everything while a query may be in flight) */
|
||||
static pthread_mutex_t charon_mutex = PTHREAD_MUTEX_INITIALIZER;
|
||||
|
||||
typedef struct private_charonservice_t private_charonservice_t;
|
||||
|
||||
/**
|
||||
@@ -274,6 +288,24 @@ METHOD(charonservice_t, bypass_socket, bool,
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Protect a socket of the userspace routing engine without adding it to the
|
||||
* tracked list: the engine creates and closes sockets on its own thread, and
|
||||
* the list is not synchronized against the roam re-protection walk. Flows
|
||||
* are short-lived; if the network changes they die and are re-created with
|
||||
* fresh protected sockets.
|
||||
*/
|
||||
bool charonservice_protect_socket(int fd)
|
||||
{
|
||||
private_charonservice_t *this = (private_charonservice_t*)charonservice;
|
||||
|
||||
if (!this || fd < 0)
|
||||
{
|
||||
return FALSE;
|
||||
}
|
||||
return bypass_single_socket(this, fd);
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts the given Java array of byte arrays (byte[][]) to a linked list
|
||||
* of chunk_t objects.
|
||||
@@ -626,13 +658,144 @@ static void charonservice_deinit(JNIEnv *env)
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle SIGSEGV/SIGILL signals raised by threads
|
||||
* Handle SIGSEGV/SIGILL/SIGBUS/SIGABRT signals raised by threads.
|
||||
*
|
||||
* Besides re-raising for the system tombstone, writes a one-line record to
|
||||
* diag.log via a raw write() to a pre-opened fd: the thread name (strongSwan
|
||||
* threads are named), the signal and the fault address. Native crashes are
|
||||
* otherwise invisible in the app's own logs.
|
||||
*/
|
||||
static void segv_handler(int signal)
|
||||
static int diag_crash_fd = -1;
|
||||
|
||||
/**
|
||||
* Best-effort frame capture for the crash record: Android 16 has execinfo
|
||||
* backtrace(), older builds resolve to NULL and are skipped
|
||||
*/
|
||||
static int (*crash_backtrace)(void**, int) = NULL;
|
||||
|
||||
static void segv_handler(int signal, siginfo_t *info, void *ctx)
|
||||
{
|
||||
dbg_android(DBG_DMN, 1, "thread %u received %d", thread_current_id(),
|
||||
signal);
|
||||
exit(1);
|
||||
struct sigaction action;
|
||||
char buf[1024], name[16] = {0};
|
||||
ucontext_t *uc = ctx;
|
||||
void *frames[16] = {0};
|
||||
uintptr_t pc = 0, lr = 0, sp = 0;
|
||||
Dl_info dli = {0};
|
||||
int len, n = 0, i;
|
||||
|
||||
#if defined(__aarch64__)
|
||||
pc = uc->uc_mcontext.pc;
|
||||
lr = uc->uc_mcontext.regs[30];
|
||||
sp = uc->uc_mcontext.sp;
|
||||
#elif defined(__arm__)
|
||||
pc = uc->uc_mcontext.arm_pc;
|
||||
lr = uc->uc_mcontext.arm_lr;
|
||||
sp = uc->uc_mcontext.arm_sp;
|
||||
#endif
|
||||
prctl(PR_GET_NAME, name, 0, 0, 0);
|
||||
|
||||
len = snprintf(buf, sizeof(buf),
|
||||
"native crash: signal %d at %p in thread '%s' (id %u) "
|
||||
"pc %p lr %p sp %p\n",
|
||||
signal, info ? info->si_addr : NULL,
|
||||
name[0] ? name : "?", thread_current_id(),
|
||||
(void*)pc, (void*)lr, (void*)sp);
|
||||
if (diag_crash_fd >= 0 && len > 0)
|
||||
{
|
||||
if (write(diag_crash_fd, buf, len) < 0)
|
||||
{
|
||||
/* best effort only */
|
||||
}
|
||||
}
|
||||
/* module of the faulting pc, for offline symbolication */
|
||||
if (pc && dladdr((void*)pc, &dli) && dli.dli_fname)
|
||||
{
|
||||
len = snprintf(buf, sizeof(buf), "native crash: module %s base %p "
|
||||
"pc-offset +0x%lx\n", dli.dli_fname, dli.dli_fbase,
|
||||
(unsigned long)(pc - (uintptr_t)dli.dli_fbase));
|
||||
if (diag_crash_fd >= 0 && len > 0)
|
||||
{
|
||||
if (write(diag_crash_fd, buf, len) < 0)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
/* frame list, when available on this Android version */
|
||||
if (!crash_backtrace)
|
||||
{
|
||||
crash_backtrace = dlsym(RTLD_DEFAULT, "backtrace");
|
||||
}
|
||||
if (crash_backtrace)
|
||||
{
|
||||
n = crash_backtrace(frames, 16);
|
||||
for (i = 0; i < n; i++)
|
||||
{
|
||||
void *f = frames[i];
|
||||
|
||||
if (dladdr(f, &dli) && dli.dli_fname)
|
||||
{
|
||||
len = snprintf(buf, sizeof(buf), "native crash: #%d %p %s +0x%lx\n",
|
||||
i, f, dli.dli_fname,
|
||||
(unsigned long)((uintptr_t)f - (uintptr_t)dli.dli_fbase));
|
||||
}
|
||||
else
|
||||
{
|
||||
len = snprintf(buf, sizeof(buf), "native crash: #%d %p\n", i, f);
|
||||
}
|
||||
if (diag_crash_fd >= 0 && len > 0)
|
||||
{
|
||||
if (write(diag_crash_fd, buf, len) < 0)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
/* restore the default handler and re-raise: exit() here would make the
|
||||
* crash invisible to the system (no tombstone, no crash record), which
|
||||
* makes native crashes impossible to diagnose */
|
||||
memset(&action, 0, sizeof(action));
|
||||
action.sa_handler = SIG_DFL;
|
||||
sigemptyset(&action.sa_mask);
|
||||
sigaction(signal, &action, NULL);
|
||||
raise(signal);
|
||||
}
|
||||
|
||||
/**
|
||||
* Open diag.log for the crash handler (signal-safe raw write). Called once
|
||||
* the data directory is known, before the handlers are installed.
|
||||
*/
|
||||
static void open_crash_log(const char *appdir)
|
||||
{
|
||||
char path[256];
|
||||
|
||||
if (snprintf(path, sizeof(path), "%s/diag.log", appdir) < (int)sizeof(path))
|
||||
{
|
||||
diag_crash_fd = open(path, O_WRONLY | O_CREAT | O_APPEND, 0600);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* lwIP assertion hook (see port/arch/cc.h): record the failed invariant in
|
||||
* diag.log before the abort, so heap corruption inside the stack is
|
||||
* diagnosable from the app's own log. Exposed for the vendored lwIP build.
|
||||
*/
|
||||
void lwip_assert_diag(const char *msg)
|
||||
{
|
||||
char buf[256];
|
||||
int len;
|
||||
|
||||
if (diag_crash_fd >= 0 && msg)
|
||||
{
|
||||
len = snprintf(buf, sizeof(buf), "lwip assert: %s\n", msg);
|
||||
if (len > 0)
|
||||
{
|
||||
if (write(diag_crash_fd, buf, len) < 0)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
abort();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -712,6 +875,7 @@ JNI_METHOD(CharonVpnService, initializeCharon, jboolean,
|
||||
|
||||
appdir = androidjni_convert_jstring(env, jappdir);
|
||||
charonservice_init(env, this, builder, appdir, byod);
|
||||
open_crash_log(appdir);
|
||||
free(appdir);
|
||||
|
||||
if (uname(&utsname) != 0)
|
||||
@@ -747,18 +911,22 @@ JNI_METHOD(CharonVpnService, initializeCharon, jboolean,
|
||||
lib->plugins->status(lib->plugins, LEVEL_CTRL);
|
||||
|
||||
/* add handler for SEGV and ILL etc. */
|
||||
action.sa_handler = segv_handler;
|
||||
action.sa_flags = 0;
|
||||
action.sa_sigaction = segv_handler;
|
||||
action.sa_flags = SA_SIGINFO;
|
||||
sigemptyset(&action.sa_mask);
|
||||
sigaction(SIGSEGV, &action, NULL);
|
||||
sigaction(SIGILL, &action, NULL);
|
||||
sigaction(SIGBUS, &action, NULL);
|
||||
sigaction(SIGABRT, &action, NULL);
|
||||
action.sa_handler = SIG_IGN;
|
||||
sigaction(SIGPIPE, &action, NULL);
|
||||
|
||||
/* start daemon (i.e. the threads in the thread-pool) */
|
||||
charon->start(charon);
|
||||
charon_running = TRUE;
|
||||
|
||||
/* userspace routing engine (mode B of the RU split feature) */
|
||||
direct_engine_init();
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
@@ -767,12 +935,25 @@ JNI_METHOD(CharonVpnService, initializeCharon, jboolean,
|
||||
*/
|
||||
JNI_METHOD(CharonVpnService, deinitializeCharon, void)
|
||||
{
|
||||
pthread_mutex_lock(&charon_mutex);
|
||||
charon_running = FALSE;
|
||||
/* deinitialize charon before we destroy our own objects */
|
||||
if (diag_crash_fd >= 0)
|
||||
{
|
||||
close(diag_crash_fd);
|
||||
diag_crash_fd = -1;
|
||||
}
|
||||
/* deinitialize charon before we destroy our own objects: this also
|
||||
* tears down the TUN device, so handle_plain() stops calling into the
|
||||
* userspace routing engine */
|
||||
libcharon_deinit();
|
||||
/* only once no worker can be inside engine->handle() anymore may the
|
||||
* engine be freed - freeing it earlier is a use-after-free that showed
|
||||
* up as random SEGV/heap-corruption aborts on every disconnect */
|
||||
direct_engine_deinit();
|
||||
charonservice_deinit(env);
|
||||
libipsec_deinit();
|
||||
library_deinit();
|
||||
pthread_mutex_unlock(&charon_mutex);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -804,31 +985,186 @@ JNI_METHOD(CharonVpnService, queryTraffic, jlongArray)
|
||||
uint64_t bytes;
|
||||
|
||||
result = (*env)->NewLongArray(env, 2);
|
||||
if (!charon_running || !charon || !charon->ike_sa_manager)
|
||||
pthread_mutex_lock(&charon_mutex);
|
||||
if (charon_running && charon && charon->ike_sa_manager)
|
||||
{
|
||||
(*env)->SetLongArrayRegion(env, result, 0, 2, values);
|
||||
return result;
|
||||
}
|
||||
|
||||
isas = charon->ike_sa_manager->create_enumerator(charon->ike_sa_manager, FALSE);
|
||||
while (isas->enumerate(isas, &ike_sa))
|
||||
{
|
||||
children = ike_sa->create_child_sa_enumerator(ike_sa);
|
||||
while (children->enumerate(children, &child_sa))
|
||||
isas = charon->ike_sa_manager->create_enumerator(charon->ike_sa_manager, FALSE);
|
||||
while (isas->enumerate(isas, &ike_sa))
|
||||
{
|
||||
child_sa->get_usestats(child_sa, TRUE, NULL, &bytes, NULL);
|
||||
values[0] += (jlong)bytes;
|
||||
child_sa->get_usestats(child_sa, FALSE, NULL, &bytes, NULL);
|
||||
values[1] += (jlong)bytes;
|
||||
children = ike_sa->create_child_sa_enumerator(ike_sa);
|
||||
while (children->enumerate(children, &child_sa))
|
||||
{
|
||||
child_sa->get_usestats(child_sa, TRUE, NULL, &bytes, NULL);
|
||||
values[0] += (jlong)bytes;
|
||||
child_sa->get_usestats(child_sa, FALSE, NULL, &bytes, NULL);
|
||||
values[1] += (jlong)bytes;
|
||||
}
|
||||
children->destroy(children);
|
||||
}
|
||||
children->destroy(children);
|
||||
isas->destroy(isas);
|
||||
}
|
||||
isas->destroy(isas);
|
||||
pthread_mutex_unlock(&charon_mutex);
|
||||
|
||||
(*env)->SetLongArrayRegion(env, result, 0, 2, values);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy a jlongArray of IPv4 addresses (network order in the low 32 bits)
|
||||
* into an allocated uint32_t array
|
||||
*/
|
||||
static uint32_t *longs_to_v4(JNIEnv *env, jlongArray jarr, size_t *count)
|
||||
{
|
||||
uint32_t *out = NULL;
|
||||
jlong *buf;
|
||||
jsize n, i;
|
||||
|
||||
*count = 0;
|
||||
if (!jarr)
|
||||
{
|
||||
return NULL;
|
||||
}
|
||||
n = (*env)->GetArrayLength(env, jarr);
|
||||
if (!n)
|
||||
{
|
||||
return NULL;
|
||||
}
|
||||
out = calloc(n, sizeof(uint32_t));
|
||||
buf = malloc(n * sizeof(jlong));
|
||||
if (!out || !buf)
|
||||
{
|
||||
free(out);
|
||||
free(buf);
|
||||
return NULL;
|
||||
}
|
||||
(*env)->GetLongArrayRegion(env, jarr, 0, n, buf);
|
||||
for (i = 0; i < n; i++)
|
||||
{
|
||||
out[i] = (uint32_t)buf[i];
|
||||
}
|
||||
free(buf);
|
||||
*count = n;
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy a jbyteArray of concatenated 16-byte IPv6 addresses into an
|
||||
* allocated array; entry count is returned in *count
|
||||
*/
|
||||
static uint8_t *bytes_to_v6(JNIEnv *env, jbyteArray jarr, size_t *count)
|
||||
{
|
||||
uint8_t *out;
|
||||
jsize n;
|
||||
|
||||
*count = 0;
|
||||
if (!jarr || (n = (*env)->GetArrayLength(env, jarr)) == 0)
|
||||
{
|
||||
return NULL;
|
||||
}
|
||||
n -= n % 16;
|
||||
if (!n)
|
||||
{
|
||||
return NULL;
|
||||
}
|
||||
out = malloc(n);
|
||||
if (!out)
|
||||
{
|
||||
return NULL;
|
||||
}
|
||||
(*env)->GetByteArrayRegion(env, jarr, 0, n, out);
|
||||
*count = n / 16;
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Push the userspace routing classification data (mode B of the RU split)
|
||||
* to the engine: the merged sorted RU ranges and the force-tunnel list.
|
||||
* The set is swapped atomically, no reconnect is required.
|
||||
*/
|
||||
JNI_METHOD(CharonVpnService, setUserspaceRoutingData, void,
|
||||
jboolean enabled, jlongArray jv4from, jlongArray jv4to,
|
||||
jbyteArray jv6from, jbyteArray jv6to, jlongArray jft4, jbyteArray jft6)
|
||||
{
|
||||
/* the engine exists only while the userspace routing mode is enabled;
|
||||
* in mode A none of its code runs at all */
|
||||
direct_engine_t *engine = enabled ? direct_engine_ensure() : direct_engine_get();
|
||||
ru_set_t *set;
|
||||
uint32_t *v4f = NULL, *v4t = NULL, *ft4 = NULL;
|
||||
uint8_t *v6f = NULL, *v6t = NULL, *ft6 = NULL;
|
||||
size_t n4 = 0, n6 = 0, nf4 = 0, nf6 = 0, n4b = 0;
|
||||
|
||||
if (!engine)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
v4f = longs_to_v4(env, jv4from, &n4);
|
||||
v4t = longs_to_v4(env, jv4to, &n4b);
|
||||
if (n4 != n4b)
|
||||
{ /* malformed input, keep the previous set */
|
||||
free(v4f);
|
||||
free(v4t);
|
||||
return;
|
||||
}
|
||||
v6f = bytes_to_v6(env, jv6from, &n6);
|
||||
n4b = 0;
|
||||
v6t = bytes_to_v6(env, jv6to, &n4b);
|
||||
if (n6 != n4b)
|
||||
{
|
||||
free(v4f);
|
||||
free(v4t);
|
||||
free(v6f);
|
||||
free(v6t);
|
||||
return;
|
||||
}
|
||||
ft4 = longs_to_v4(env, jft4, &nf4);
|
||||
ft6 = bytes_to_v6(env, jft6, &nf6);
|
||||
|
||||
set = ru_set_create(v4f, v4t, n4, v6f, v6t, n6, ft4, nf4, ft6, nf6);
|
||||
engine->update_set(engine, set);
|
||||
engine->set_enabled(engine, enabled);
|
||||
}
|
||||
|
||||
/**
|
||||
* Snapshot of the userspace routing engine counters (mode B diagnostics).
|
||||
*/
|
||||
JNI_METHOD(CharonVpnService, getRoutingStats, jlongArray)
|
||||
{
|
||||
direct_engine_t *engine = direct_engine_get();
|
||||
direct_stats_t s = {};
|
||||
jlong v[19];
|
||||
jlongArray result;
|
||||
|
||||
memset(&s, 0, sizeof(s));
|
||||
if (engine)
|
||||
{
|
||||
engine->get_stats(engine, &s);
|
||||
}
|
||||
v[0] = (jlong)s.tunnel_pkts;
|
||||
v[1] = (jlong)s.tunnel_bytes;
|
||||
v[2] = (jlong)s.direct_pkts;
|
||||
v[3] = (jlong)s.direct_bytes;
|
||||
v[4] = (jlong)s.udp_pkts;
|
||||
v[5] = (jlong)s.udp_bytes;
|
||||
v[6] = (jlong)s.tcp_pkts;
|
||||
v[7] = (jlong)s.tcp_bytes;
|
||||
v[8] = (jlong)s.drop_icmp;
|
||||
v[9] = (jlong)s.drop_frag;
|
||||
v[10] = (jlong)s.drop_malformed;
|
||||
v[11] = (jlong)s.relay_errors;
|
||||
v[12] = (jlong)s.udp_flows;
|
||||
v[13] = (jlong)s.tcp_flows;
|
||||
v[14] = (jlong)s.tcp_accepts;
|
||||
v[15] = (jlong)s.set_v4;
|
||||
v[16] = (jlong)s.set_v6;
|
||||
v[17] = (jlong)s.set_ft_v4;
|
||||
v[18] = (jlong)(s.enabled ? 1 : 0);
|
||||
|
||||
result = (*env)->NewLongArray(env, 19);
|
||||
(*env)->SetLongArrayRegion(env, result, 0, 19, v);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility function to verify proposal strings (static, so `this` is the class)
|
||||
*/
|
||||
|
||||
@@ -167,4 +167,10 @@ struct charonservice_t {
|
||||
*/
|
||||
extern charonservice_t *charonservice;
|
||||
|
||||
/**
|
||||
* Protect a socket of the userspace routing engine (VpnService.protect)
|
||||
* without adding it to the roam-tracked list; see charonservice.c.
|
||||
*/
|
||||
bool charonservice_protect_socket(int fd);
|
||||
|
||||
#endif /** CHARONSERVICE_H_ @}*/
|
||||
@@ -21,6 +21,7 @@
|
||||
|
||||
#include "../android_jni.h"
|
||||
#include "../charonservice.h"
|
||||
#include "../routing/direct_engine.h"
|
||||
#include <daemon.h>
|
||||
#include <processing/jobs/callback_job.h>
|
||||
#include <threading/mutex.h>
|
||||
@@ -71,6 +72,9 @@ static job_requeue_t roam_event()
|
||||
{
|
||||
/* this will fail if no connection is up */
|
||||
charonservice->bypass_socket(charonservice, -1, FALSE);
|
||||
/* the userspace router's direct flows hold untracked sockets - they
|
||||
* must be re-protected on the new network, or they black-hole */
|
||||
direct_engine_request_reprotect();
|
||||
charon->kernel->roam(charon->kernel, TRUE);
|
||||
return JOB_REQUEUE_NONE;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,764 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
#include "direct_engine.h"
|
||||
#include "direct_tcp.h"
|
||||
#include "direct_udp.h"
|
||||
|
||||
#include "../charonservice.h"
|
||||
/* lwIP assertion hook lives in charonservice.c (needs the diag fd) */
|
||||
extern void lwip_assert_diag(const char *msg);
|
||||
|
||||
#include <threading/mutex.h>
|
||||
#include <threading/rwlock.h>
|
||||
#include <threading/thread.h>
|
||||
#include <utils/debug.h>
|
||||
|
||||
#include <arpa/inet.h>
|
||||
#include <errno.h>
|
||||
#include <fcntl.h>
|
||||
#include <poll.h>
|
||||
#include <string.h>
|
||||
#include <sys/socket.h>
|
||||
#include <unistd.h>
|
||||
|
||||
/**
|
||||
* Poll set capacity; beyond that flows are simply not polled for one round
|
||||
* (the wakeup pipe keeps the loop spinning at 250ms worst case)
|
||||
*/
|
||||
#define MAX_POLL_FDS 1024
|
||||
|
||||
/**
|
||||
* Poll timeout: upper bound for lwIP timer latency
|
||||
*/
|
||||
#define ENGINE_POLL_MS 250
|
||||
|
||||
/**
|
||||
* Idle-fragment of the IPv4 fragment flags word (MF flag or offset)
|
||||
*/
|
||||
#define IP4_FRAG_MASK 0x3fff
|
||||
|
||||
typedef struct private_direct_engine_t private_direct_engine_t;
|
||||
|
||||
/**
|
||||
* Packet queue capacity (see the ring buffer below)
|
||||
*/
|
||||
#define QUEUE_SLOTS 2048
|
||||
|
||||
struct private_direct_engine_t {
|
||||
|
||||
/**
|
||||
* Public interface
|
||||
*/
|
||||
direct_engine_t public;
|
||||
|
||||
/**
|
||||
* Shared counters
|
||||
*/
|
||||
direct_stats_t stats;
|
||||
|
||||
/**
|
||||
* Classification state, guarded by set_lock
|
||||
*/
|
||||
rwlock_t *set_lock;
|
||||
ru_set_t *set;
|
||||
bool enabled;
|
||||
|
||||
/**
|
||||
* TUN binding, guarded by bind_lock
|
||||
*/
|
||||
mutex_t *bind_lock;
|
||||
void *tun_ctx;
|
||||
void (*emit)(void *ctx, ip_packet_t *packet);
|
||||
uint16_t mtu;
|
||||
|
||||
/**
|
||||
* Packet queue from the classifier (processor thread) to the engine:
|
||||
* a preallocated ring buffer. No heap traffic crosses the thread
|
||||
* boundary - the scudo heap-corruption aborts we chased for several
|
||||
* builds all fired in the malloc of a linked_list element on one of
|
||||
* the threads touching the old queue, so this path is allocation-free
|
||||
* (guarded by q_lock).
|
||||
*/
|
||||
mutex_t *q_lock;
|
||||
ip_packet_t *queue_slots[QUEUE_SLOTS];
|
||||
size_t queue_head, queue_tail;
|
||||
bool queue_full;
|
||||
|
||||
/**
|
||||
* Wakeup pipe for the engine thread
|
||||
*/
|
||||
int wake[2];
|
||||
|
||||
/**
|
||||
* Engine thread and its sub-modules
|
||||
*/
|
||||
bool running;
|
||||
thread_t *thread;
|
||||
direct_udp_t *udp;
|
||||
direct_tcp_t *tcp;
|
||||
|
||||
/**
|
||||
* Set after the first TCP flow was dispatched; keeps lwIP dormant
|
||||
* (no timers, no poll) until it is actually used
|
||||
*/
|
||||
bool tcp_active;
|
||||
|
||||
/**
|
||||
* Set after the flows were flushed for an unbound TUN, so the loop
|
||||
* does not tear down and rebuild empty lists every iteration
|
||||
*/
|
||||
bool unbound_flushed;
|
||||
};
|
||||
|
||||
/**
|
||||
* Process-wide singleton (see direct_engine_get())
|
||||
*/
|
||||
static direct_engine_t *engine_singleton;
|
||||
static mutex_t *engine_singleton_lock;
|
||||
|
||||
/**
|
||||
* Pending TUN binding: setup_tun_device() may run before the lazily created
|
||||
* engine exists (mode B is switched on only after the tunnel is up), so the
|
||||
* binding is remembered here and applied when the engine is created. The
|
||||
* plain fields keep mode A free of any engine machinery.
|
||||
*/
|
||||
static void *pending_tun_ctx;
|
||||
static void (*pending_tun_write)(void *ctx, ip_packet_t *packet);
|
||||
static uint16_t pending_tun_mtu;
|
||||
|
||||
/**
|
||||
* Roam request: set from the kernel-net roam event (processor thread),
|
||||
* serviced by the engine thread, which owns the flow sockets
|
||||
*/
|
||||
static bool reprotect_pending;
|
||||
|
||||
static int parse_protocol(chunk_t enc);
|
||||
|
||||
void direct_engine_request_reprotect(void)
|
||||
{
|
||||
__atomic_store_n(&reprotect_pending, TRUE, __ATOMIC_RELEASE);
|
||||
{
|
||||
direct_engine_t *engine = direct_engine_get();
|
||||
|
||||
if (engine)
|
||||
{
|
||||
engine->wake_engine(engine);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
METHOD(direct_engine_t, handle, bool,
|
||||
private_direct_engine_t *this, ip_packet_t *packet)
|
||||
{
|
||||
host_t *dst = packet->get_destination(packet);
|
||||
chunk_t addr = dst->get_address(dst);
|
||||
chunk_t enc = packet->get_encoding(packet);
|
||||
bool direct = FALSE;
|
||||
|
||||
this->set_lock->read_lock(this->set_lock);
|
||||
if (this->enabled && this->set)
|
||||
{
|
||||
if (addr.len == 4)
|
||||
{ /* network order (big-endian) bytes as one numeric value - the
|
||||
* same representation the Java side sorts its ranges by; a plain
|
||||
* memcpy would reverse the byte order on little-endian ARM and
|
||||
* break the binary search completely */
|
||||
uint32_t a = ((uint32_t)addr.ptr[0] << 24) |
|
||||
((uint32_t)addr.ptr[1] << 16) |
|
||||
((uint32_t)addr.ptr[2] << 8) |
|
||||
(uint32_t)addr.ptr[3];
|
||||
|
||||
if (!this->set->force_tunnel_v4(this->set, a) &&
|
||||
this->set->contains_v4(this->set, a))
|
||||
{
|
||||
direct = TRUE;
|
||||
}
|
||||
}
|
||||
else if (addr.len == 16)
|
||||
{ /* memcmp order == network order for v6, no conversion needed */
|
||||
const uint8_t *a = (uint8_t*)addr.ptr;
|
||||
|
||||
if (!this->set->force_tunnel_v6(this->set, a) &&
|
||||
this->set->contains_v6(this->set, a))
|
||||
{
|
||||
direct = TRUE;
|
||||
}
|
||||
}
|
||||
}
|
||||
this->set_lock->unlock(this->set_lock);
|
||||
|
||||
/* TCP and UDP leave directly through the engine; only ICMP etc. cannot
|
||||
* (no raw sockets for apps) and stay in the tunnel. The engine loop
|
||||
* wakes lwIP (tcp_active) on the first TCP packet, before that the
|
||||
* stack stays completely dormant. */
|
||||
if (direct)
|
||||
{
|
||||
int proto = parse_protocol(enc);
|
||||
|
||||
if (proto != IPPROTO_UDP && proto != IPPROTO_TCP)
|
||||
{
|
||||
direct = FALSE;
|
||||
}
|
||||
}
|
||||
|
||||
if (!direct)
|
||||
{
|
||||
DIRECT_STATS_ADD(this->stats.tunnel_pkts, 1);
|
||||
DIRECT_STATS_ADD(this->stats.tunnel_bytes, enc.len);
|
||||
return FALSE;
|
||||
}
|
||||
DIRECT_STATS_ADD(this->stats.direct_pkts, 1);
|
||||
DIRECT_STATS_ADD(this->stats.direct_bytes, enc.len);
|
||||
|
||||
this->q_lock->lock(this->q_lock);
|
||||
if (this->queue_full)
|
||||
{ /* overflow: drop rather than block the tunnel reader */
|
||||
DIRECT_STATS_ADD(this->stats.drop_malformed, 1);
|
||||
this->q_lock->unlock(this->q_lock);
|
||||
packet->destroy(packet);
|
||||
return TRUE;
|
||||
}
|
||||
this->queue_slots[this->queue_tail] = packet;
|
||||
this->queue_tail = (this->queue_tail + 1) % QUEUE_SLOTS;
|
||||
this->queue_full = this->queue_tail == this->queue_head;
|
||||
this->q_lock->unlock(this->q_lock);
|
||||
|
||||
if (write(this->wake[1], "", 1) < 0 && errno != EAGAIN)
|
||||
{ /* the pipe is full - the thread is already awake */
|
||||
}
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remember the current TUN binding and apply it to the engine if it exists
|
||||
* (see the pending-TUN note above; setup_tun_device calls this on every TUN).
|
||||
*/
|
||||
void direct_engine_set_tun(void *ctx,
|
||||
void (*write_packet)(void *ctx, ip_packet_t *packet),
|
||||
uint16_t mtu)
|
||||
{
|
||||
direct_engine_t *engine;
|
||||
|
||||
pending_tun_ctx = ctx;
|
||||
pending_tun_write = write_packet;
|
||||
pending_tun_mtu = mtu;
|
||||
|
||||
engine = direct_engine_get();
|
||||
if (engine && ctx)
|
||||
{
|
||||
engine->bind_tun(engine, ctx, write_packet, mtu);
|
||||
}
|
||||
else if (engine)
|
||||
{
|
||||
engine->unbind_tun(engine);
|
||||
}
|
||||
}
|
||||
|
||||
METHOD(direct_engine_t, bind_tun, void,
|
||||
private_direct_engine_t *this, void *ctx,
|
||||
void (*write_packet)(void *ctx, ip_packet_t *packet), uint16_t mtu)
|
||||
{
|
||||
this->bind_lock->lock(this->bind_lock);
|
||||
this->tun_ctx = ctx;
|
||||
this->emit = write_packet;
|
||||
this->mtu = mtu;
|
||||
this->bind_lock->unlock(this->bind_lock);
|
||||
if (this->tcp_active)
|
||||
{
|
||||
this->tcp->set_mtu(this->tcp, mtu);
|
||||
}
|
||||
this->unbound_flushed = FALSE;
|
||||
if (write(this->wake[1], "", 1) < 0 && errno != EAGAIN)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
METHOD(direct_engine_t, unbind_tun, void,
|
||||
private_direct_engine_t *this)
|
||||
{
|
||||
this->bind_lock->lock(this->bind_lock);
|
||||
this->tun_ctx = NULL;
|
||||
this->emit = NULL;
|
||||
this->bind_lock->unlock(this->bind_lock);
|
||||
/* wake the engine so it flushes queues and flows for the dead TUN.
|
||||
* enabled is deliberately NOT cleared: the flag belongs to the push
|
||||
* (set_enabled), and a MOBIKE re-bind must not silently stop
|
||||
* classification */
|
||||
if (write(this->wake[1], "", 1) < 0 && errno != EAGAIN)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
METHOD(direct_engine_t, wake_engine, void,
|
||||
private_direct_engine_t *this)
|
||||
{
|
||||
if (write(this->wake[1], "", 1) < 0 && errno != EAGAIN)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deliver a packet to the TUN device; called by the sub-modules on the
|
||||
* engine thread. Drops the packet if the TUN is currently unbound.
|
||||
*/
|
||||
void direct_engine_emit(direct_engine_t *pub, ip_packet_t *packet)
|
||||
{
|
||||
private_direct_engine_t *this = (private_direct_engine_t*)pub;
|
||||
void (*emit)(void *ctx, ip_packet_t *packet) = NULL;
|
||||
void *ctx = NULL;
|
||||
|
||||
this->bind_lock->lock(this->bind_lock);
|
||||
emit = this->emit;
|
||||
ctx = this->tun_ctx;
|
||||
this->bind_lock->unlock(this->bind_lock);
|
||||
|
||||
if (emit)
|
||||
{
|
||||
emit(ctx, packet);
|
||||
}
|
||||
else
|
||||
{
|
||||
packet->destroy(packet);
|
||||
}
|
||||
}
|
||||
|
||||
METHOD(direct_engine_t, update_set, void,
|
||||
private_direct_engine_t *this, ru_set_t *set)
|
||||
{
|
||||
ru_set_t *old;
|
||||
|
||||
this->set_lock->write_lock(this->set_lock);
|
||||
old = this->set;
|
||||
this->set = set;
|
||||
DIRECT_STATS_SET(this->stats.set_v4, set ? set->v4_count : 0);
|
||||
DIRECT_STATS_SET(this->stats.set_v6, set ? set->v6_count : 0);
|
||||
DIRECT_STATS_SET(this->stats.set_ft_v4, set ? set->ft_v4_count : 0);
|
||||
DIRECT_STATS_SET(this->stats.set_ft_v6, set ? set->ft_v6_count : 0);
|
||||
this->set_lock->unlock(this->set_lock);
|
||||
DESTROY_IF(old);
|
||||
}
|
||||
|
||||
METHOD(direct_engine_t, set_enabled, void,
|
||||
private_direct_engine_t *this, bool enabled)
|
||||
{
|
||||
this->set_lock->write_lock(this->set_lock);
|
||||
this->enabled = enabled;
|
||||
DIRECT_STATS_SET(this->stats.enabled, enabled);
|
||||
this->set_lock->unlock(this->set_lock);
|
||||
}
|
||||
|
||||
METHOD(direct_engine_t, get_stats, void,
|
||||
private_direct_engine_t *this, direct_stats_t *stats)
|
||||
{
|
||||
*stats = (direct_stats_t){
|
||||
.tunnel_pkts = __atomic_load_n(&this->stats.tunnel_pkts, __ATOMIC_RELAXED),
|
||||
.tunnel_bytes = __atomic_load_n(&this->stats.tunnel_bytes, __ATOMIC_RELAXED),
|
||||
.direct_pkts = __atomic_load_n(&this->stats.direct_pkts, __ATOMIC_RELAXED),
|
||||
.direct_bytes = __atomic_load_n(&this->stats.direct_bytes, __ATOMIC_RELAXED),
|
||||
.udp_pkts = __atomic_load_n(&this->stats.udp_pkts, __ATOMIC_RELAXED),
|
||||
.udp_bytes = __atomic_load_n(&this->stats.udp_bytes, __ATOMIC_RELAXED),
|
||||
.tcp_pkts = __atomic_load_n(&this->stats.tcp_pkts, __ATOMIC_RELAXED),
|
||||
.tcp_bytes = __atomic_load_n(&this->stats.tcp_bytes, __ATOMIC_RELAXED),
|
||||
.drop_icmp = __atomic_load_n(&this->stats.drop_icmp, __ATOMIC_RELAXED),
|
||||
.drop_frag = __atomic_load_n(&this->stats.drop_frag, __ATOMIC_RELAXED),
|
||||
.drop_malformed = __atomic_load_n(&this->stats.drop_malformed, __ATOMIC_RELAXED),
|
||||
.relay_errors = __atomic_load_n(&this->stats.relay_errors, __ATOMIC_RELAXED),
|
||||
.udp_flows = __atomic_load_n(&this->stats.udp_flows, __ATOMIC_RELAXED),
|
||||
.tcp_flows = __atomic_load_n(&this->stats.tcp_flows, __ATOMIC_RELAXED),
|
||||
.tcp_accepts = __atomic_load_n(&this->stats.tcp_accepts, __ATOMIC_RELAXED),
|
||||
};
|
||||
this->set_lock->read_lock(this->set_lock);
|
||||
stats->set_v4 = this->stats.set_v4;
|
||||
stats->set_v6 = this->stats.set_v6;
|
||||
stats->set_ft_v4 = this->stats.set_ft_v4;
|
||||
stats->set_ft_v6 = this->stats.set_ft_v6;
|
||||
stats->enabled = this->enabled;
|
||||
this->set_lock->unlock(this->set_lock);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the transport protocol of a packet, rejecting fragments
|
||||
*/
|
||||
static int parse_protocol(chunk_t enc)
|
||||
{
|
||||
uint8_t nh;
|
||||
size_t off;
|
||||
|
||||
if (enc.len < 20)
|
||||
{
|
||||
return -1;
|
||||
}
|
||||
switch (enc.ptr[0] >> 4)
|
||||
{
|
||||
case 4:
|
||||
{
|
||||
uint16_t frag;
|
||||
|
||||
memcpy(&frag, enc.ptr + 6, sizeof(frag));
|
||||
if (ntohs(frag) & IP4_FRAG_MASK)
|
||||
{ /* MF set or non-zero offset */
|
||||
return -2;
|
||||
}
|
||||
return enc.ptr[9];
|
||||
}
|
||||
case 6:
|
||||
nh = enc.ptr[6];
|
||||
off = 40;
|
||||
while (nh != IPPROTO_TCP && nh != IPPROTO_UDP)
|
||||
{
|
||||
if (nh == 44)
|
||||
{ /* fragment header */
|
||||
return -2;
|
||||
}
|
||||
if (nh == 0 || nh == 43 || nh == 60)
|
||||
{ /* hop-by-hop, routing, destination options */
|
||||
size_t len;
|
||||
|
||||
if (off + 2 > enc.len)
|
||||
{
|
||||
return -1;
|
||||
}
|
||||
nh = enc.ptr[off];
|
||||
len = ((size_t)enc.ptr[off + 1] + 1) * 8;
|
||||
if (off + len > enc.len)
|
||||
{
|
||||
return -1;
|
||||
}
|
||||
off += len;
|
||||
continue;
|
||||
}
|
||||
/* AH or unknown next header */
|
||||
return -1;
|
||||
}
|
||||
return nh;
|
||||
default:
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Route one queued packet to its sub-module (engine thread). TCP wakes
|
||||
* lwIP (tcp_active) on the first packet; the stack is dormant before that.
|
||||
*/
|
||||
static void dispatch_packet(private_direct_engine_t *this, ip_packet_t *packet)
|
||||
{
|
||||
int proto = parse_protocol(packet->get_encoding(packet));
|
||||
|
||||
if (proto == IPPROTO_TCP && !this->tcp_active)
|
||||
{
|
||||
this->tcp->set_mtu(this->tcp, this->mtu);
|
||||
this->tcp_active = TRUE;
|
||||
}
|
||||
switch (proto)
|
||||
{
|
||||
case IPPROTO_UDP:
|
||||
this->udp->handle_packet(this->udp, packet);
|
||||
break;
|
||||
case IPPROTO_TCP:
|
||||
this->tcp->handle_packet(this->tcp, packet);
|
||||
break;
|
||||
case -2:
|
||||
DIRECT_STATS_ADD(this->stats.drop_frag, 1);
|
||||
packet->destroy(packet);
|
||||
break;
|
||||
case -1:
|
||||
DIRECT_STATS_ADD(this->stats.drop_malformed, 1);
|
||||
packet->destroy(packet);
|
||||
break;
|
||||
default:
|
||||
/* unreachable while handle() tunnels non-TCP/UDP; kept for safety */
|
||||
DIRECT_STATS_ADD(this->stats.drop_icmp, 1);
|
||||
packet->destroy(packet);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Drain the wakeup pipe
|
||||
*/
|
||||
static void drain_wake(private_direct_engine_t *this)
|
||||
{
|
||||
char buf[32];
|
||||
|
||||
while (read(this->wake[0], buf, sizeof(buf)) > 0)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Discard everything still queued (TUN gone)
|
||||
*/
|
||||
static void drain_queue_nobind(private_direct_engine_t *this)
|
||||
{
|
||||
while (TRUE)
|
||||
{
|
||||
ip_packet_t *packet;
|
||||
|
||||
this->q_lock->lock(this->q_lock);
|
||||
if (this->queue_head == this->queue_tail && !this->queue_full)
|
||||
{
|
||||
this->q_lock->unlock(this->q_lock);
|
||||
return;
|
||||
}
|
||||
packet = this->queue_slots[this->queue_head];
|
||||
this->queue_head = (this->queue_head + 1) % QUEUE_SLOTS;
|
||||
this->queue_full = FALSE;
|
||||
this->q_lock->unlock(this->q_lock);
|
||||
packet->destroy(packet);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Engine thread: drives all direct flows. The queue is drained straight
|
||||
* into dispatch - no intermediate list, no allocations in this loop.
|
||||
*/
|
||||
static void *engine_thread(private_direct_engine_t *this)
|
||||
{
|
||||
struct pollfd fds[MAX_POLL_FDS];
|
||||
uint32_t sweeps = 0;
|
||||
|
||||
while (this->running)
|
||||
{
|
||||
size_t n = 1, udp_n, tcp_n;
|
||||
bool bound;
|
||||
|
||||
fds[0].fd = this->wake[0];
|
||||
fds[0].events = POLLIN;
|
||||
fds[0].revents = 0;
|
||||
udp_n = this->udp->fill_poll(this->udp, fds + n, MAX_POLL_FDS - n);
|
||||
n += udp_n;
|
||||
tcp_n = 0;
|
||||
if (this->tcp_active)
|
||||
{ /* dormant until a TCP flow exists (currently never: handle()
|
||||
* tunnels TCP; see the lwIP note in dispatch_packet) */
|
||||
tcp_n = this->tcp->fill_poll(this->tcp, fds + n, MAX_POLL_FDS - n);
|
||||
n += tcp_n;
|
||||
}
|
||||
|
||||
poll(fds, n, ENGINE_POLL_MS);
|
||||
|
||||
if (fds[0].revents & POLLIN)
|
||||
{
|
||||
drain_wake(this);
|
||||
}
|
||||
|
||||
/* roam: re-protect the flow sockets on the new network (the flag
|
||||
* is set by direct_engine_request_reprotect from the kernel-net
|
||||
* roam event; the flow lists belong to this thread) */
|
||||
if (__atomic_exchange_n(&reprotect_pending, FALSE, __ATOMIC_ACQ_REL))
|
||||
{
|
||||
this->udp->reprotect(this->udp);
|
||||
if (this->tcp_active)
|
||||
{
|
||||
this->tcp->reprotect(this->tcp);
|
||||
}
|
||||
}
|
||||
|
||||
this->bind_lock->lock(this->bind_lock);
|
||||
bound = this->emit != NULL;
|
||||
this->bind_lock->unlock(this->bind_lock);
|
||||
|
||||
if (!bound && this->unbound_flushed)
|
||||
{ /* TUN still gone and already flushed */
|
||||
drain_queue_nobind(this);
|
||||
continue;
|
||||
}
|
||||
|
||||
/* drain the ring buffer and dispatch every packet inline */
|
||||
while (TRUE)
|
||||
{
|
||||
ip_packet_t *packet;
|
||||
|
||||
this->q_lock->lock(this->q_lock);
|
||||
if (this->queue_head == this->queue_tail && !this->queue_full)
|
||||
{
|
||||
this->q_lock->unlock(this->q_lock);
|
||||
break;
|
||||
}
|
||||
packet = this->queue_slots[this->queue_head];
|
||||
this->queue_head = (this->queue_head + 1) % QUEUE_SLOTS;
|
||||
this->queue_full = FALSE;
|
||||
this->q_lock->unlock(this->q_lock);
|
||||
|
||||
if (bound)
|
||||
{
|
||||
dispatch_packet(this, packet);
|
||||
}
|
||||
else
|
||||
{
|
||||
packet->destroy(packet);
|
||||
}
|
||||
}
|
||||
|
||||
if (!bound)
|
||||
{ /* first unbound round: also drop all flows */
|
||||
this->udp->reset(this->udp);
|
||||
if (this->tcp_active)
|
||||
{
|
||||
this->tcp->reset(this->tcp);
|
||||
}
|
||||
this->unbound_flushed = TRUE;
|
||||
continue;
|
||||
}
|
||||
this->unbound_flushed = FALSE;
|
||||
|
||||
this->udp->process_poll(this->udp, fds + 1, udp_n);
|
||||
if (this->tcp_active)
|
||||
{
|
||||
this->tcp->process_poll(this->tcp, fds + 1 + udp_n, tcp_n);
|
||||
this->tcp->check_timers(this->tcp);
|
||||
}
|
||||
|
||||
if (++sweeps % 16 == 0)
|
||||
{ /* roughly every four seconds */
|
||||
this->udp->sweep(this->udp);
|
||||
}
|
||||
}
|
||||
return NULL;
|
||||
}
|
||||
|
||||
METHOD(direct_engine_t, destroy, void,
|
||||
private_direct_engine_t *this)
|
||||
{
|
||||
this->running = FALSE;
|
||||
if (write(this->wake[1], "", 1) < 0 && errno != EAGAIN)
|
||||
{
|
||||
}
|
||||
if (this->thread)
|
||||
{
|
||||
this->thread->join(this->thread);
|
||||
}
|
||||
close(this->wake[0]);
|
||||
close(this->wake[1]);
|
||||
DESTROY_IF(this->udp);
|
||||
DESTROY_IF(this->tcp);
|
||||
DESTROY_IF(this->set);
|
||||
drain_queue_nobind(this);
|
||||
this->q_lock->destroy(this->q_lock);
|
||||
this->bind_lock->destroy(this->bind_lock);
|
||||
this->set_lock->destroy(this->set_lock);
|
||||
free(this);
|
||||
}
|
||||
|
||||
direct_engine_t *direct_engine_create(void)
|
||||
{
|
||||
private_direct_engine_t *this;
|
||||
|
||||
INIT(this,
|
||||
.public = {
|
||||
.handle = _handle,
|
||||
.bind_tun = _bind_tun,
|
||||
.unbind_tun = _unbind_tun,
|
||||
.wake_engine = _wake_engine,
|
||||
.update_set = _update_set,
|
||||
.set_enabled = _set_enabled,
|
||||
.get_stats = _get_stats,
|
||||
.destroy = _destroy,
|
||||
},
|
||||
.set_lock = rwlock_create(RWLOCK_TYPE_DEFAULT),
|
||||
.bind_lock = mutex_create(MUTEX_TYPE_DEFAULT),
|
||||
.q_lock = mutex_create(MUTEX_TYPE_DEFAULT),
|
||||
.running = TRUE,
|
||||
);
|
||||
|
||||
if (pipe(this->wake) != 0)
|
||||
{
|
||||
DBG1(DBG_DMN, "direct engine: pipe failed: %s", strerror(errno));
|
||||
destroy(this);
|
||||
return NULL;
|
||||
}
|
||||
fcntl(this->wake[0], F_SETFL, O_NONBLOCK);
|
||||
fcntl(this->wake[1], F_SETFL, O_NONBLOCK);
|
||||
|
||||
this->udp = direct_udp_create(this, &this->stats);
|
||||
this->tcp = direct_tcp_create(ANDROID_DEFAULT_MTU, this, &this->stats);
|
||||
this->thread = thread_create((thread_main_t)engine_thread, this);
|
||||
|
||||
return &this->public;
|
||||
}
|
||||
|
||||
void direct_engine_destroy(direct_engine_t *engine)
|
||||
{
|
||||
DESTROY_IF(engine);
|
||||
}
|
||||
|
||||
direct_engine_t *direct_engine_get(void)
|
||||
{
|
||||
/* lock-free: called for every packet read from the TUN device */
|
||||
return __atomic_load_n(&engine_singleton, __ATOMIC_ACQUIRE);
|
||||
}
|
||||
|
||||
/**
|
||||
* (De)Initialize the singleton together with the charon libraries.
|
||||
*
|
||||
* The engine itself is created lazily by direct_engine_ensure(), only when
|
||||
* the userspace routing mode is actually enabled - in mode A none of the
|
||||
* engine code may run at all.
|
||||
*/
|
||||
void direct_engine_init(void)
|
||||
{
|
||||
if (!engine_singleton_lock)
|
||||
{
|
||||
engine_singleton_lock = mutex_create(MUTEX_TYPE_DEFAULT);
|
||||
}
|
||||
}
|
||||
|
||||
void direct_engine_deinit(void)
|
||||
{
|
||||
direct_engine_t *engine;
|
||||
|
||||
/* the pending binding belongs to the TUN of this charon instance */
|
||||
pending_tun_ctx = NULL;
|
||||
pending_tun_write = NULL;
|
||||
|
||||
if (!engine_singleton_lock)
|
||||
{
|
||||
return;
|
||||
}
|
||||
engine_singleton_lock->lock(engine_singleton_lock);
|
||||
engine = __atomic_exchange_n(&engine_singleton, (direct_engine_t*)NULL,
|
||||
__ATOMIC_ACQ_REL);
|
||||
engine_singleton_lock->unlock(engine_singleton_lock);
|
||||
DESTROY_IF(engine);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the engine, creating it on first use. Called from the JNI push when
|
||||
* the userspace routing mode is enabled; a TUN that came up before the
|
||||
* engine existed is bound here (see direct_engine_set_tun).
|
||||
*/
|
||||
direct_engine_t *direct_engine_ensure(void)
|
||||
{
|
||||
direct_engine_t *engine;
|
||||
|
||||
direct_engine_init();
|
||||
engine_singleton_lock->lock(engine_singleton_lock);
|
||||
engine = __atomic_load_n(&engine_singleton, __ATOMIC_RELAXED);
|
||||
if (!engine)
|
||||
{
|
||||
engine = direct_engine_create();
|
||||
if (engine)
|
||||
{
|
||||
if (pending_tun_ctx && pending_tun_write)
|
||||
{
|
||||
engine->bind_tun(engine, pending_tun_ctx, pending_tun_write,
|
||||
pending_tun_mtu);
|
||||
}
|
||||
__atomic_store_n(&engine_singleton, engine, __ATOMIC_RELEASE);
|
||||
}
|
||||
}
|
||||
engine_singleton_lock->unlock(engine_singleton_lock);
|
||||
return engine;
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @defgroup direct_engine direct_engine
|
||||
* @{ @ingroup routing
|
||||
*
|
||||
* Userspace routing engine ("mode B" of the RU split feature): with only the
|
||||
* two default routes pointing into the TUN device, every plaintext packet
|
||||
* read by handle_plain() ends up here. The destination is classified
|
||||
* against the RU address ranges (ru_set): RU traffic leaves directly through
|
||||
* protected sockets (UDP via a NAT, TCP via an embedded lwIP stack), while
|
||||
* everything else is handed to the regular libipsec path by returning FALSE.
|
||||
*
|
||||
* The engine owns one thread that drives all direct flows (poll loop); the
|
||||
* classifier itself runs on the caller's (processor) thread and only queues
|
||||
* packets.
|
||||
*/
|
||||
|
||||
#ifndef DIRECT_ENGINE_H_
|
||||
#define DIRECT_ENGINE_H_
|
||||
|
||||
#include <ip_packet.h>
|
||||
|
||||
#include "ru_set.h"
|
||||
|
||||
typedef struct direct_engine_t direct_engine_t;
|
||||
|
||||
/**
|
||||
* Counters snapshot for the "route diag:" block (all fields relaxed
|
||||
* atomics, updated from the processor and engine threads).
|
||||
*/
|
||||
typedef struct direct_stats_t {
|
||||
uint64_t tunnel_pkts, tunnel_bytes;
|
||||
uint64_t direct_pkts, direct_bytes;
|
||||
uint64_t udp_pkts, udp_bytes;
|
||||
uint64_t tcp_pkts, tcp_bytes;
|
||||
uint64_t drop_icmp;
|
||||
uint64_t drop_frag;
|
||||
uint64_t drop_malformed;
|
||||
uint64_t relay_errors;
|
||||
uint64_t udp_flows, tcp_flows;
|
||||
uint64_t tcp_accepts;
|
||||
size_t set_v4, set_v6, set_ft_v4, set_ft_v6;
|
||||
bool enabled;
|
||||
} direct_stats_t;
|
||||
|
||||
/**
|
||||
* Counter updates use relaxed atomics: two threads write (the processor
|
||||
* thread classifies, the engine thread relays), the JNI thread reads.
|
||||
*/
|
||||
#define DIRECT_STATS_ADD(field, val) \
|
||||
__atomic_fetch_add(&(field), (val), __ATOMIC_RELAXED)
|
||||
#define DIRECT_STATS_SET(field, val) \
|
||||
__atomic_store_n(&(field), (val), __ATOMIC_RELAXED)
|
||||
|
||||
struct direct_engine_t {
|
||||
|
||||
/**
|
||||
* Classify a plaintext packet read from the TUN device. Takes ownership
|
||||
* of the packet if it returns TRUE (direct flow); otherwise the packet
|
||||
* stays with the caller for the IPsec path. Called on a processor
|
||||
* thread, must not block.
|
||||
*/
|
||||
bool (*handle)(direct_engine_t *this, ip_packet_t *packet);
|
||||
|
||||
/**
|
||||
* Bind the engine to a TUN device: packets generated for the kernel
|
||||
* (lwIP segments, UDP responses) are delivered through write_packet,
|
||||
* called on the engine thread. Unbind drops the binding and flushes
|
||||
* all direct flows.
|
||||
*/
|
||||
void (*bind_tun)(direct_engine_t *this, void *ctx,
|
||||
void (*write_packet)(void *ctx, ip_packet_t *packet),
|
||||
uint16_t mtu);
|
||||
void (*unbind_tun)(direct_engine_t *this);
|
||||
|
||||
/**
|
||||
* Atomically replace the classification set; the engine takes ownership.
|
||||
*/
|
||||
void (*update_set)(direct_engine_t *this, ru_set_t *set);
|
||||
|
||||
/**
|
||||
* Enable or disable the direct path (mode B on/off). While disabled
|
||||
* everything is tunneled, but counters keep running.
|
||||
*/
|
||||
void (*set_enabled)(direct_engine_t *this, bool enabled);
|
||||
|
||||
/**
|
||||
* Wake the engine thread without changing any state (roam re-protect).
|
||||
*/
|
||||
void (*wake_engine)(direct_engine_t *this);
|
||||
|
||||
/**
|
||||
* Snapshot counters and set sizes into stats.
|
||||
*/
|
||||
void (*get_stats)(direct_engine_t *this, direct_stats_t *stats);
|
||||
|
||||
void (*destroy)(direct_engine_t *this);
|
||||
};
|
||||
|
||||
/**
|
||||
* Process-wide engine instance, created with the charon libraries and
|
||||
* destroyed with them. NULL before initializeCharon() / after
|
||||
* deinitializeCharon().
|
||||
*/
|
||||
direct_engine_t *direct_engine_get(void);
|
||||
|
||||
/**
|
||||
* Create/destroy used by charonservice.c on initialize/deinitialize.
|
||||
*/
|
||||
direct_engine_t *direct_engine_create(void);
|
||||
void direct_engine_destroy(direct_engine_t *engine);
|
||||
|
||||
/**
|
||||
* (De)Initialize the process-wide singleton together with the charon
|
||||
* libraries (called from charonservice.c initialize/deinitialize). The
|
||||
* engine itself is created lazily - only by direct_engine_ensure() when
|
||||
* the userspace routing mode is enabled; in mode A none of the engine
|
||||
* code runs at all.
|
||||
*/
|
||||
void direct_engine_init(void);
|
||||
void direct_engine_deinit(void);
|
||||
|
||||
/**
|
||||
* Get the engine, creating it on first use (userspace routing enabled).
|
||||
*/
|
||||
direct_engine_t *direct_engine_ensure(void);
|
||||
|
||||
/**
|
||||
* Deliver a packet to the currently bound TUN device; called by the engine's
|
||||
* sub-modules on the engine thread. Destroys the packet if unbound.
|
||||
*/
|
||||
void direct_engine_emit(direct_engine_t *engine, ip_packet_t *packet);
|
||||
|
||||
/**
|
||||
* Remember the current TUN binding (ctx/write callback/MTU) and apply it to
|
||||
* the engine if it exists. setup_tun_device() calls this on every TUN - it
|
||||
* may run before the lazily created engine exists, in which case the binding
|
||||
* is applied when the engine is created (direct_engine_ensure). Passing
|
||||
* NULL unbinds.
|
||||
*/
|
||||
void direct_engine_set_tun(void *ctx,
|
||||
void (*write_packet)(void *ctx, ip_packet_t *packet),
|
||||
uint16_t mtu);
|
||||
|
||||
/**
|
||||
* Ask the engine to re-protect all direct flow sockets on the (new) default
|
||||
* network. Safe from any thread; the sockets belong to the engine thread,
|
||||
* so the work happens there on the next loop iteration.
|
||||
*/
|
||||
void direct_engine_request_reprotect(void);
|
||||
|
||||
#endif /** DIRECT_ENGINE_H_ @} */
|
||||
@@ -0,0 +1,812 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* TCP termination for direct flows via lwIP (NO_SYS=1, single engine thread).
|
||||
*
|
||||
* The kernel believes it talks to the original RU destination; in fact it
|
||||
* talks to the lwIP stack, which plays the server side of the connection
|
||||
* (a listening pcb bound to the wildcard accepts every destination). Each
|
||||
* accepted pcb is relayed to a protected, non-blocking socket connected to
|
||||
* the real address. Inbound segments bypass ip4_input()/ip6_input() (the
|
||||
* stack would reject packets not addressed to the netif) and are dispatched
|
||||
* straight into tcp_input() with ip_data prepared the way the IP layer
|
||||
* would have.
|
||||
*
|
||||
* Relay removal only happens through the engine loop's enumerators
|
||||
* (remove_at); lwIP callbacks merely mark flows dead.
|
||||
*/
|
||||
|
||||
#include "direct_tcp.h"
|
||||
|
||||
#include "../charonservice.h"
|
||||
|
||||
#include <collections/linked_list.h>
|
||||
#include <utils/debug.h>
|
||||
|
||||
#include <lwip/init.h>
|
||||
#include <lwip/ip.h>
|
||||
#include <lwip/ip6_addr.h>
|
||||
#include <lwip/ip_addr.h>
|
||||
#include <lwip/netif.h>
|
||||
#include <lwip/pbuf.h>
|
||||
#include <lwip/tcp.h>
|
||||
#include <lwip/timeouts.h>
|
||||
#include <lwip/priv/tcp_priv.h>
|
||||
|
||||
#include <arpa/inet.h>
|
||||
#include <errno.h>
|
||||
#include <netinet/in.h>
|
||||
#include <string.h>
|
||||
#include <sys/socket.h>
|
||||
#include <unistd.h>
|
||||
|
||||
/**
|
||||
* Per-flow buffer limits before the flow is aborted
|
||||
*/
|
||||
#define MAX_BUFFERED (256 * 1024)
|
||||
|
||||
typedef struct private_direct_tcp_t private_direct_tcp_t;
|
||||
|
||||
static lwip_err_t on_accept(void *arg, struct tcp_pcb *newpcb, lwip_err_t err);
|
||||
static lwip_err_t on_recv(void *arg, struct tcp_pcb *pcb, struct pbuf *p, lwip_err_t err);
|
||||
static void on_err(void *arg, lwip_err_t err);
|
||||
static lwip_err_t on_poll(void *arg, struct tcp_pcb *pcb);
|
||||
|
||||
/**
|
||||
* One relayed connection
|
||||
*/
|
||||
typedef struct {
|
||||
private_direct_tcp_t *m;
|
||||
struct tcp_pcb *pcb;
|
||||
int fd;
|
||||
bool connected;
|
||||
bool dead;
|
||||
bool remote_eof;
|
||||
uint8_t *to_sock;
|
||||
size_t to_sock_len, to_sock_cap;
|
||||
uint8_t *to_pcb;
|
||||
size_t to_pcb_len, to_pcb_cap;
|
||||
} tcp_relay_t;
|
||||
|
||||
struct private_direct_tcp_t {
|
||||
|
||||
/**
|
||||
* Public interface
|
||||
*/
|
||||
direct_tcp_t public;
|
||||
|
||||
/**
|
||||
* Shared counters
|
||||
*/
|
||||
direct_stats_t *stats;
|
||||
|
||||
/**
|
||||
* Engine pointer for TUN emission
|
||||
*/
|
||||
direct_engine_t *engine;
|
||||
|
||||
/**
|
||||
* The stack and its wildcard listener
|
||||
*/
|
||||
struct netif netif;
|
||||
struct tcp_pcb *lpcb;
|
||||
uint16_t mtu;
|
||||
|
||||
/**
|
||||
* Live relays; only touched on the engine thread
|
||||
*/
|
||||
linked_list_t *relays;
|
||||
};
|
||||
|
||||
/**
|
||||
* Append data to a growable buffer (data may be NULL to only reserve),
|
||||
* returns FALSE if the flow must be aborted
|
||||
*/
|
||||
static bool buf_append(tcp_relay_t *relay, bool to_sock,
|
||||
const void *data, size_t len)
|
||||
{
|
||||
uint8_t **buf = to_sock ? &relay->to_sock : &relay->to_pcb;
|
||||
size_t *clen = to_sock ? &relay->to_sock_len : &relay->to_pcb_len;
|
||||
size_t *cap = to_sock ? &relay->to_sock_cap : &relay->to_pcb_cap;
|
||||
|
||||
if (*clen + len > MAX_BUFFERED)
|
||||
{
|
||||
return FALSE;
|
||||
}
|
||||
if (*clen + len > *cap)
|
||||
{
|
||||
size_t ncap = *cap ? *cap : 8192;
|
||||
uint8_t *nbuf;
|
||||
|
||||
while (ncap < *clen + len)
|
||||
{
|
||||
ncap *= 2;
|
||||
}
|
||||
nbuf = realloc(*buf, ncap);
|
||||
if (!nbuf)
|
||||
{
|
||||
return FALSE;
|
||||
}
|
||||
*buf = nbuf;
|
||||
*cap = ncap;
|
||||
}
|
||||
if (data)
|
||||
{
|
||||
memcpy(*buf + *clen, data, len);
|
||||
}
|
||||
*clen += len;
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
static void buf_consume(uint8_t *buf, size_t *clen, size_t len)
|
||||
{
|
||||
memmove(buf, buf + len, *clen - len);
|
||||
*clen -= len;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detach a pcb we are about to close, or that lwIP already destroyed
|
||||
*/
|
||||
static void pcb_detach(tcp_relay_t *relay)
|
||||
{
|
||||
if (relay->pcb)
|
||||
{
|
||||
tcp_arg(relay->pcb, NULL);
|
||||
tcp_recv(relay->pcb, NULL);
|
||||
tcp_err(relay->pcb, NULL);
|
||||
tcp_sent(relay->pcb, NULL);
|
||||
tcp_poll(relay->pcb, NULL, 0);
|
||||
relay->pcb = NULL;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroy a relay outside of any lwIP callback context. The pcb is closed
|
||||
* with a FIN if possible, aborted otherwise; pcb==NULL means lwIP already
|
||||
* destroyed it or we detached it before.
|
||||
*/
|
||||
static void relay_destroy(private_direct_tcp_t *this, tcp_relay_t *relay,
|
||||
bool abort_pcb)
|
||||
{
|
||||
if (relay->pcb)
|
||||
{
|
||||
if (abort_pcb)
|
||||
{
|
||||
tcp_abort(relay->pcb);
|
||||
relay->pcb = NULL;
|
||||
}
|
||||
else
|
||||
{
|
||||
struct tcp_pcb *pcb = relay->pcb;
|
||||
|
||||
pcb_detach(relay);
|
||||
if (tcp_close(pcb) != ERR_OK)
|
||||
{
|
||||
tcp_abort(pcb);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (relay->fd >= 0)
|
||||
{
|
||||
close(relay->fd);
|
||||
}
|
||||
free(relay->to_sock);
|
||||
free(relay->to_pcb);
|
||||
free(relay);
|
||||
DIRECT_STATS_ADD(this->stats->tcp_flows, -1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Push buffered data into the pcb (socket -> kernel direction)
|
||||
*/
|
||||
static void relay_flush_to_pcb(tcp_relay_t *relay)
|
||||
{
|
||||
while (relay->to_pcb_len && relay->pcb)
|
||||
{
|
||||
u16_t snd = tcp_sndbuf(relay->pcb);
|
||||
size_t chunk;
|
||||
|
||||
if (!snd)
|
||||
{
|
||||
break;
|
||||
}
|
||||
chunk = min(relay->to_pcb_len, snd);
|
||||
chunk = min(chunk, 0xffff);
|
||||
if (tcp_write(relay->pcb, relay->to_pcb, chunk,
|
||||
TCP_WRITE_FLAG_COPY) != ERR_OK)
|
||||
{
|
||||
break;
|
||||
}
|
||||
buf_consume(relay->to_pcb, &relay->to_pcb_len, chunk);
|
||||
}
|
||||
if (relay->pcb)
|
||||
{
|
||||
tcp_output(relay->pcb);
|
||||
}
|
||||
if (relay->remote_eof && relay->to_pcb_len == 0 && relay->pcb && !relay->dead)
|
||||
{ /* everything delivered and the remote is done: FIN the kernel */
|
||||
struct tcp_pcb *pcb = relay->pcb;
|
||||
|
||||
pcb_detach(relay);
|
||||
if (tcp_close(pcb) != ERR_OK)
|
||||
{
|
||||
tcp_abort(pcb);
|
||||
}
|
||||
relay->dead = TRUE;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Push buffered data into the socket (kernel -> socket direction)
|
||||
*/
|
||||
static void relay_flush_to_sock(tcp_relay_t *relay)
|
||||
{
|
||||
while (relay->to_sock_len)
|
||||
{
|
||||
ssize_t n = write(relay->fd, relay->to_sock, relay->to_sock_len);
|
||||
|
||||
if (n < 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
buf_consume(relay->to_sock, &relay->to_sock_len, n);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* lwIP accept callback: a connection arrived for some destination
|
||||
*/
|
||||
static lwip_err_t on_accept(void *arg, struct tcp_pcb *newpcb, lwip_err_t err)
|
||||
{
|
||||
private_direct_tcp_t *this = arg;
|
||||
tcp_relay_t *relay;
|
||||
struct sockaddr_storage ss;
|
||||
struct sockaddr_in *sin = (struct sockaddr_in*)&ss;
|
||||
struct sockaddr_in6 *sin6 = (struct sockaddr_in6*)&ss;
|
||||
int fd;
|
||||
|
||||
DIRECT_STATS_ADD(this->stats->tcp_accepts, 1);
|
||||
|
||||
memset(&ss, 0, sizeof(ss));
|
||||
if (IP_IS_V6_VAL(newpcb->local_ip))
|
||||
{
|
||||
sin6->sin6_family = AF_INET6;
|
||||
memcpy(&sin6->sin6_addr, &ip_2_ip6(&newpcb->local_ip)->addr, 16);
|
||||
sin6->sin6_port = htons(newpcb->local_port);
|
||||
}
|
||||
else
|
||||
{
|
||||
sin->sin_family = AF_INET;
|
||||
memcpy(&sin->sin_addr, &ip_2_ip4(&newpcb->local_ip)->addr, 4);
|
||||
sin->sin_port = htons(newpcb->local_port);
|
||||
}
|
||||
|
||||
fd = socket(ss.ss_family, SOCK_STREAM | SOCK_NONBLOCK, IPPROTO_TCP);
|
||||
if (fd < 0)
|
||||
{
|
||||
DIRECT_STATS_ADD(this->stats->relay_errors, 1);
|
||||
DBG1(DBG_NET, "direct tcp: accept socket failed: %s", strerror(errno));
|
||||
tcp_abort(newpcb);
|
||||
return ERR_ABRT;
|
||||
}
|
||||
if (!charonservice_protect_socket(fd))
|
||||
{
|
||||
DIRECT_STATS_ADD(this->stats->relay_errors, 1);
|
||||
DBG1(DBG_NET, "direct tcp: accept protect failed fd=%d", fd);
|
||||
close(fd);
|
||||
tcp_abort(newpcb);
|
||||
return ERR_ABRT;
|
||||
}
|
||||
if (connect(fd, (struct sockaddr*)&ss, sizeof(ss)) < 0 && errno != EINPROGRESS)
|
||||
{
|
||||
DIRECT_STATS_ADD(this->stats->relay_errors, 1);
|
||||
DBG1(DBG_NET, "direct tcp: accept connect failed: %s", strerror(errno));
|
||||
close(fd);
|
||||
tcp_abort(newpcb);
|
||||
return ERR_ABRT;
|
||||
}
|
||||
|
||||
INIT(relay,
|
||||
.m = this,
|
||||
.pcb = newpcb,
|
||||
.fd = fd,
|
||||
);
|
||||
/* keep our own segments within the TUN MTU regardless of TCP_MSS */
|
||||
if (newpcb->mss > this->mtu - 72)
|
||||
{
|
||||
newpcb->mss = this->mtu - 72;
|
||||
}
|
||||
tcp_arg(newpcb, relay);
|
||||
tcp_recv(newpcb, on_recv);
|
||||
tcp_err(newpcb, on_err);
|
||||
tcp_poll(newpcb, on_poll, 4);
|
||||
this->relays->insert_last(this->relays, relay);
|
||||
DIRECT_STATS_ADD(this->stats->tcp_flows, 1);
|
||||
return ERR_OK;
|
||||
}
|
||||
|
||||
/**
|
||||
* lwIP receive callback: data from the kernel side
|
||||
*/
|
||||
static lwip_err_t on_recv(void *arg, struct tcp_pcb *pcb, struct pbuf *p, lwip_err_t err)
|
||||
{
|
||||
tcp_relay_t *relay = arg;
|
||||
|
||||
if (!p)
|
||||
{ /* the kernel closed the connection */
|
||||
struct tcp_pcb *close = relay->pcb;
|
||||
|
||||
if (close)
|
||||
{
|
||||
pcb_detach(relay);
|
||||
if (tcp_close(close) != ERR_OK)
|
||||
{
|
||||
tcp_abort(close);
|
||||
}
|
||||
}
|
||||
relay->dead = TRUE;
|
||||
return ERR_OK;
|
||||
}
|
||||
if (!buf_append(relay, TRUE, NULL, p->tot_len) ||
|
||||
pbuf_copy_partial(p, relay->to_sock + relay->to_sock_len - p->tot_len,
|
||||
p->tot_len, 0) != p->tot_len)
|
||||
{
|
||||
pbuf_free(p);
|
||||
pcb_detach(relay);
|
||||
tcp_abort(pcb);
|
||||
relay->dead = TRUE;
|
||||
return ERR_ABRT;
|
||||
}
|
||||
tcp_recved(pcb, p->tot_len);
|
||||
pbuf_free(p);
|
||||
relay_flush_to_sock(relay);
|
||||
return ERR_OK;
|
||||
}
|
||||
|
||||
/**
|
||||
* lwIP error callback: the pcb is already gone
|
||||
*/
|
||||
static void on_err(void *arg, lwip_err_t err)
|
||||
{
|
||||
tcp_relay_t *relay = arg;
|
||||
|
||||
if (relay)
|
||||
{
|
||||
relay->pcb = NULL;
|
||||
relay->dead = TRUE;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* lwIP poll callback: retry pumps after EAGAIN
|
||||
*/
|
||||
static lwip_err_t on_poll(void *arg, struct tcp_pcb *pcb)
|
||||
{
|
||||
tcp_relay_t *relay = arg;
|
||||
|
||||
if (relay->pcb && !relay->dead)
|
||||
{
|
||||
relay_flush_to_sock(relay);
|
||||
relay_flush_to_pcb(relay);
|
||||
}
|
||||
return ERR_OK;
|
||||
}
|
||||
|
||||
/**
|
||||
* netif output: a finished IP packet for the kernel. lwIP keeps ownership
|
||||
* of the pbuf (unacked segments reference it for retransmission and free
|
||||
* it themselves on ACK) - copying the bytes out is all we may do here,
|
||||
* freeing p here was the double-free behind "lwip assert: pbuf_free:
|
||||
* p->ref > 0" and the SIGABRT crashes on RU traffic.
|
||||
*/
|
||||
static lwip_err_t netif_output(struct netif *netif, struct pbuf *p)
|
||||
{
|
||||
private_direct_tcp_t *this = netif->state;
|
||||
uint8_t buf[65535];
|
||||
u16_t len;
|
||||
|
||||
len = pbuf_copy_partial(p, buf, p->tot_len, 0);
|
||||
if (len != p->tot_len)
|
||||
{
|
||||
return ERR_BUF;
|
||||
}
|
||||
ip_packet_t *packet = ip_packet_create(chunk_clone(chunk_create(buf, len)));
|
||||
if (packet)
|
||||
{
|
||||
direct_engine_emit(this->engine, packet);
|
||||
}
|
||||
return ERR_OK;
|
||||
}
|
||||
|
||||
static lwip_err_t netif_output_v4(struct netif *netif, struct pbuf *p,
|
||||
const ip4_addr_t *dest)
|
||||
{
|
||||
return netif_output(netif, p);
|
||||
}
|
||||
|
||||
static lwip_err_t netif_output_v6(struct netif *netif, struct pbuf *p,
|
||||
const ip6_addr_t *dest)
|
||||
{
|
||||
return netif_output(netif, p);
|
||||
}
|
||||
|
||||
static lwip_err_t netif_init_cb(struct netif *netif)
|
||||
{
|
||||
netif->name[0] = 'd';
|
||||
netif->name[1] = 'r';
|
||||
netif->output = netif_output_v4;
|
||||
netif->output_ip6 = netif_output_v6;
|
||||
netif->mtu = ((private_direct_tcp_t*)netif->state)->mtu;
|
||||
netif->mtu6 = netif->mtu;
|
||||
netif->flags = NETIF_FLAG_LINK_UP;
|
||||
return ERR_OK;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear ip_data the way ip4_input()/ip6_input() do on exit
|
||||
*/
|
||||
static void ip_data_reset(void)
|
||||
{
|
||||
ip_data.current_netif = NULL;
|
||||
ip_data.current_input_netif = NULL;
|
||||
ip_data.current_ip4_header = NULL;
|
||||
#if LWIP_IPV6
|
||||
ip_data.current_ip6_header = NULL;
|
||||
#endif
|
||||
ip_data.current_ip_header_tot_len = 0;
|
||||
ip_addr_set_any(0, &ip_data.current_iphdr_src);
|
||||
ip_addr_set_any(0, &ip_data.current_iphdr_dest);
|
||||
}
|
||||
|
||||
METHOD(direct_tcp_t, handle_packet, void,
|
||||
private_direct_tcp_t *this, ip_packet_t *packet)
|
||||
{
|
||||
chunk_t enc = packet->get_encoding(packet);
|
||||
struct pbuf *p;
|
||||
size_t hlen;
|
||||
|
||||
if (enc.len < 20)
|
||||
{
|
||||
DIRECT_STATS_ADD(this->stats->drop_malformed, 1);
|
||||
packet->destroy(packet);
|
||||
return;
|
||||
}
|
||||
switch (enc.ptr[0] >> 4)
|
||||
{
|
||||
case 4:
|
||||
hlen = (size_t)(enc.ptr[0] & 0x0f) * 4;
|
||||
break;
|
||||
case 6:
|
||||
hlen = 40;
|
||||
break;
|
||||
default:
|
||||
hlen = 0;
|
||||
break;
|
||||
}
|
||||
if (hlen < 20 || hlen > 60 || enc.len <= hlen)
|
||||
{
|
||||
DIRECT_STATS_ADD(this->stats->drop_malformed, 1);
|
||||
packet->destroy(packet);
|
||||
return;
|
||||
}
|
||||
|
||||
p = pbuf_alloc(PBUF_RAW, (u16_t)(enc.len - hlen), PBUF_RAM);
|
||||
if (!p)
|
||||
{
|
||||
DIRECT_STATS_ADD(this->stats->drop_malformed, 1);
|
||||
packet->destroy(packet);
|
||||
return;
|
||||
}
|
||||
pbuf_take(p, enc.ptr + hlen, enc.len - hlen);
|
||||
|
||||
DIRECT_STATS_ADD(this->stats->tcp_pkts, 1);
|
||||
DIRECT_STATS_ADD(this->stats->tcp_bytes, enc.len);
|
||||
|
||||
if ((enc.ptr[0] >> 4) == 4)
|
||||
{
|
||||
struct ip_hdr *iph = (struct ip_hdr*)enc.ptr;
|
||||
|
||||
ip_addr_copy_from_ip4(ip_data.current_iphdr_src, iph->src);
|
||||
ip_addr_copy_from_ip4(ip_data.current_iphdr_dest, iph->dest);
|
||||
ip_data.current_netif = &this->netif;
|
||||
ip_data.current_input_netif = &this->netif;
|
||||
ip_data.current_ip4_header = iph;
|
||||
ip_data.current_ip_header_tot_len = IPH_HL_BYTES(iph);
|
||||
}
|
||||
else
|
||||
{
|
||||
struct ip6_hdr *iph = (struct ip6_hdr*)enc.ptr;
|
||||
|
||||
ip_addr_copy_from_ip6_packed(ip_data.current_iphdr_src, iph->src);
|
||||
ip_addr_copy_from_ip6_packed(ip_data.current_iphdr_dest, iph->dest);
|
||||
ip_data.current_netif = &this->netif;
|
||||
ip_data.current_input_netif = &this->netif;
|
||||
#if LWIP_IPV6
|
||||
ip_data.current_ip6_header = iph;
|
||||
#endif
|
||||
ip_data.current_ip_header_tot_len = 40;
|
||||
}
|
||||
tcp_input(p, &this->netif);
|
||||
ip_data_reset();
|
||||
packet->destroy(packet);
|
||||
}
|
||||
|
||||
METHOD(direct_tcp_t, check_timers, void,
|
||||
private_direct_tcp_t *this)
|
||||
{
|
||||
tcp_relay_t *relay;
|
||||
enumerator_t *enumerator;
|
||||
|
||||
sys_check_timeouts();
|
||||
|
||||
/* remove dead relays */
|
||||
enumerator = this->relays->create_enumerator(this->relays);
|
||||
while (enumerator->enumerate(enumerator, &relay))
|
||||
{
|
||||
if (relay->dead)
|
||||
{
|
||||
this->relays->remove_at(this->relays, enumerator);
|
||||
relay_destroy(this, relay, TRUE);
|
||||
}
|
||||
}
|
||||
enumerator->destroy(enumerator);
|
||||
}
|
||||
|
||||
METHOD(direct_tcp_t, set_mtu, void,
|
||||
private_direct_tcp_t *this, uint16_t mtu)
|
||||
{
|
||||
this->mtu = mtu;
|
||||
this->netif.mtu = mtu;
|
||||
this->netif.mtu6 = mtu;
|
||||
}
|
||||
|
||||
METHOD(direct_tcp_t, reprotect, void,
|
||||
private_direct_tcp_t *this)
|
||||
{
|
||||
tcp_relay_t *relay;
|
||||
enumerator_t *enumerator;
|
||||
|
||||
enumerator = this->relays->create_enumerator(this->relays);
|
||||
while (enumerator->enumerate(enumerator, &relay))
|
||||
{
|
||||
if (relay->fd >= 0 && !charonservice_protect_socket(relay->fd))
|
||||
{
|
||||
DBG1(DBG_NET, "direct tcp: re-protect failed fd=%d", relay->fd);
|
||||
}
|
||||
}
|
||||
enumerator->destroy(enumerator);
|
||||
}
|
||||
|
||||
METHOD(direct_tcp_t, fill_poll, size_t,
|
||||
private_direct_tcp_t *this, struct pollfd *fds, size_t cap)
|
||||
{
|
||||
tcp_relay_t *relay;
|
||||
enumerator_t *enumerator;
|
||||
size_t n = 0;
|
||||
|
||||
enumerator = this->relays->create_enumerator(this->relays);
|
||||
while (n < cap && enumerator->enumerate(enumerator, &relay))
|
||||
{
|
||||
fds[n].fd = relay->fd;
|
||||
fds[n].events = POLLIN | (relay->connected ? 0 : POLLOUT);
|
||||
fds[n].revents = 0;
|
||||
n++;
|
||||
}
|
||||
enumerator->destroy(enumerator);
|
||||
return n;
|
||||
}
|
||||
|
||||
METHOD(direct_tcp_t, process_poll, void,
|
||||
private_direct_tcp_t *this, struct pollfd *fds, size_t count)
|
||||
{
|
||||
tcp_relay_t *relay;
|
||||
enumerator_t *enumerator;
|
||||
|
||||
enumerator = this->relays->create_enumerator(this->relays);
|
||||
while (enumerator->enumerate(enumerator, &relay))
|
||||
{
|
||||
short re = 0;
|
||||
size_t j;
|
||||
|
||||
/* relays may have been created or removed between fill_poll() and
|
||||
* here (dispatching new segments accepts connections), so match
|
||||
* by fd */
|
||||
for (j = 0; j < count; j++)
|
||||
{
|
||||
if (fds[j].fd == relay->fd)
|
||||
{
|
||||
re = fds[j].revents;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (re & POLLNVAL)
|
||||
{
|
||||
this->relays->remove_at(this->relays, enumerator);
|
||||
relay_destroy(this, relay, TRUE);
|
||||
continue;
|
||||
}
|
||||
if (!relay->connected && (re & (POLLOUT | POLLERR | POLLHUP)))
|
||||
{
|
||||
int err = 0;
|
||||
socklen_t len = sizeof(err);
|
||||
|
||||
getsockopt(relay->fd, SOL_SOCKET, SO_ERROR, &err, &len);
|
||||
if (err == 0)
|
||||
{
|
||||
relay->connected = TRUE;
|
||||
}
|
||||
else
|
||||
{
|
||||
DIRECT_STATS_ADD(this->stats->relay_errors, 1);
|
||||
DBG1(DBG_NET, "direct tcp: connect failed async: %s", strerror(err));
|
||||
this->relays->remove_at(this->relays, enumerator);
|
||||
relay_destroy(this, relay, TRUE);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (re & (POLLIN | POLLHUP))
|
||||
{
|
||||
uint8_t buf[16384];
|
||||
ssize_t n;
|
||||
bool failed = FALSE;
|
||||
|
||||
while ((n = read(relay->fd, buf, sizeof(buf))) > 0)
|
||||
{
|
||||
if (!buf_append(relay, FALSE, buf, n))
|
||||
{
|
||||
failed = TRUE;
|
||||
break;
|
||||
}
|
||||
relay_flush_to_pcb(relay);
|
||||
}
|
||||
if (failed || n == 0)
|
||||
{ /* remote closed or flow buffer overrun */
|
||||
if (failed)
|
||||
{
|
||||
DIRECT_STATS_ADD(this->stats->relay_errors, 1);
|
||||
this->relays->remove_at(this->relays, enumerator);
|
||||
relay_destroy(this, relay, TRUE);
|
||||
continue;
|
||||
}
|
||||
shutdown(relay->fd, SHUT_RD);
|
||||
relay->remote_eof = TRUE;
|
||||
relay_flush_to_pcb(relay);
|
||||
}
|
||||
else if (n < 0 && errno != EAGAIN && errno != EWOULDBLOCK)
|
||||
{
|
||||
this->relays->remove_at(this->relays, enumerator);
|
||||
relay_destroy(this, relay, TRUE);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (re & POLLOUT && relay->connected && relay->fd >= 0)
|
||||
{
|
||||
relay_flush_to_sock(relay);
|
||||
}
|
||||
}
|
||||
enumerator->destroy(enumerator);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sweep callback for list destruction
|
||||
*/
|
||||
static void relay_free(void *relay)
|
||||
{
|
||||
tcp_relay_t *r = relay;
|
||||
|
||||
relay_destroy(r->m, r, TRUE);
|
||||
}
|
||||
|
||||
METHOD(direct_tcp_t, reset, void,
|
||||
private_direct_tcp_t *this)
|
||||
{
|
||||
DIRECT_STATS_SET(this->stats->tcp_flows, 0);
|
||||
this->relays->destroy_function(this->relays, relay_free);
|
||||
this->relays = linked_list_create();
|
||||
}
|
||||
|
||||
METHOD(direct_tcp_t, destroy, void,
|
||||
private_direct_tcp_t *this)
|
||||
{
|
||||
if (this->lpcb)
|
||||
{
|
||||
tcp_close(this->lpcb);
|
||||
}
|
||||
reset(this);
|
||||
netif_remove(&this->netif);
|
||||
this->relays->destroy(this->relays);
|
||||
free(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* lwIP port: monotonic milliseconds (NO_SYS=1 requires the port to
|
||||
* provide this for the stack's timers)
|
||||
*/
|
||||
u32_t sys_now(void)
|
||||
{
|
||||
return (u32_t)(time_monotonic(NULL) * 1000);
|
||||
}
|
||||
|
||||
direct_tcp_t *direct_tcp_create(uint16_t mtu, direct_engine_t *engine,
|
||||
direct_stats_t *stats)
|
||||
{
|
||||
private_direct_tcp_t *this;
|
||||
ip4_addr_t addr, mask, gw;
|
||||
ip6_addr_t v6;
|
||||
struct tcp_pcb *pcb;
|
||||
s8_t idx;
|
||||
|
||||
INIT(this,
|
||||
.public = {
|
||||
.handle_packet = _handle_packet,
|
||||
.check_timers = _check_timers,
|
||||
.set_mtu = _set_mtu,
|
||||
.reprotect = _reprotect,
|
||||
.fill_poll = _fill_poll,
|
||||
.process_poll = _process_poll,
|
||||
.reset = _reset,
|
||||
.destroy = _destroy,
|
||||
},
|
||||
.engine = engine,
|
||||
.stats = stats,
|
||||
.mtu = mtu,
|
||||
.relays = linked_list_create(),
|
||||
);
|
||||
|
||||
lwip_init();
|
||||
|
||||
/* a dummy point-to-point netif: the addresses never appear in packets,
|
||||
* routing falls back to the (single) default netif */
|
||||
IP4_ADDR(&addr, 198, 18, 0, 1);
|
||||
IP4_ADDR(&mask, 255, 255, 255, 255);
|
||||
IP4_ADDR(&gw, 0, 0, 0, 0);
|
||||
netif_add(&this->netif, &addr, &mask, &gw, this, netif_init_cb, ip4_input);
|
||||
IP6_ADDR(&v6, PP_HTONL(LWIP_MAKEU32(0xfd, 0x00, 0x01, 0x98)),
|
||||
PP_HTONL(LWIP_MAKEU32(0x00, 0x18, 0x00, 0x00)),
|
||||
PP_HTONL(LWIP_MAKEU32(0x00, 0x00, 0x00, 0x00)),
|
||||
PP_HTONL(LWIP_MAKEU32(0x00, 0x00, 0x00, 0x01)));
|
||||
netif_add_ip6_address(&this->netif, &v6, &idx);
|
||||
netif_ip6_addr_set_state(&this->netif, idx, IP6_ADDR_PREFERRED);
|
||||
netif_set_default(&this->netif);
|
||||
netif_set_up(&this->netif);
|
||||
netif_set_link_up(&this->netif);
|
||||
|
||||
/* wildcard listener: accepts connections to any address and port (the
|
||||
* stack plays the server side for arbitrary destinations; see the
|
||||
* local_port == 0 match in tcp_in.c). tcp_bind() is bypassed because
|
||||
* it would replace port 0 with a random port. */
|
||||
pcb = tcp_new_ip_type(IPADDR_TYPE_ANY);
|
||||
if (!pcb)
|
||||
{
|
||||
DBG1(DBG_NET, "direct tcp: pcb allocation failed");
|
||||
}
|
||||
else
|
||||
{
|
||||
ip_addr_copy(pcb->local_ip, *IP_ANY_TYPE);
|
||||
pcb->local_port = 0;
|
||||
this->lpcb = tcp_listen_with_backlog(pcb, TCP_DEFAULT_LISTEN_BACKLOG);
|
||||
if (this->lpcb)
|
||||
{
|
||||
tcp_arg(this->lpcb, this);
|
||||
tcp_accept(this->lpcb, on_accept);
|
||||
}
|
||||
else
|
||||
{
|
||||
DBG1(DBG_NET, "direct tcp: listen failed");
|
||||
}
|
||||
}
|
||||
|
||||
return &this->public;
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
#ifndef DIRECT_TCP_H_
|
||||
#define DIRECT_TCP_H_
|
||||
|
||||
#include <ip_packet.h>
|
||||
|
||||
#include "direct_engine.h"
|
||||
|
||||
#include <poll.h>
|
||||
#include <stddef.h>
|
||||
|
||||
typedef struct direct_tcp_t direct_tcp_t;
|
||||
|
||||
/**
|
||||
* TCP termination for direct flows via the embedded lwIP stack (tun2socks
|
||||
* model): the kernel side of each connection talks to lwIP, which plays the
|
||||
* role of the original destination; every flow is relayed to a protected
|
||||
* socket connected to the real address.
|
||||
*/
|
||||
struct direct_tcp_t {
|
||||
|
||||
/**
|
||||
* Feed an outbound TCP packet (IP header included) into the stack
|
||||
* (consumes it). Runs on the engine thread.
|
||||
*/
|
||||
void (*handle_packet)(direct_tcp_t *this, ip_packet_t *packet);
|
||||
|
||||
/**
|
||||
* Drive lwIP timers; call every loop iteration (<= 250ms apart).
|
||||
*/
|
||||
void (*check_timers)(direct_tcp_t *this);
|
||||
|
||||
/**
|
||||
* Update the TUN MTU (affects the advertised MSS of new flows).
|
||||
*/
|
||||
void (*set_mtu)(direct_tcp_t *this, uint16_t mtu);
|
||||
|
||||
/**
|
||||
* Re-protect all relay sockets on the current default network (roam).
|
||||
*/
|
||||
void (*reprotect)(direct_tcp_t *this);
|
||||
|
||||
/**
|
||||
* Abort all flows (TUN went away).
|
||||
*/
|
||||
void (*reset)(direct_tcp_t *this);
|
||||
|
||||
/**
|
||||
* Number of live flows (diagnostics).
|
||||
*/
|
||||
size_t (*flow_count)(direct_tcp_t *this);
|
||||
|
||||
/**
|
||||
* Add this module's relay sockets to the poll set, returns how many.
|
||||
*/
|
||||
size_t (*fill_poll)(direct_tcp_t *this, struct pollfd *fds, size_t cap);
|
||||
|
||||
/**
|
||||
* Check revents of the previously added sockets (same count/order).
|
||||
*/
|
||||
void (*process_poll)(direct_tcp_t *this, struct pollfd *fds, size_t count);
|
||||
|
||||
void (*destroy)(direct_tcp_t *this);
|
||||
};
|
||||
|
||||
direct_tcp_t *direct_tcp_create(uint16_t mtu, direct_engine_t *engine,
|
||||
direct_stats_t *stats);
|
||||
|
||||
#endif /** DIRECT_TCP_H_ */
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
#include "direct_udp.h"
|
||||
|
||||
#include "../charonservice.h"
|
||||
|
||||
#include <utils/debug.h>
|
||||
|
||||
#include <errno.h>
|
||||
#include <string.h>
|
||||
#include <sys/socket.h>
|
||||
#include <unistd.h>
|
||||
|
||||
/**
|
||||
* Idle timeout for flows in seconds
|
||||
*/
|
||||
#define FLOW_TIMEOUT 60
|
||||
|
||||
/**
|
||||
* Preallocated flow table - no heap traffic on the packet path
|
||||
*/
|
||||
#define FLOW_SLOTS 256
|
||||
|
||||
typedef struct private_direct_udp_t private_direct_udp_t;
|
||||
|
||||
/**
|
||||
* One NATed flow: kernel source <==> remote destination over one socket.
|
||||
* A slot is in use when fd >= 0.
|
||||
*/
|
||||
typedef struct {
|
||||
int fd;
|
||||
host_t *origin;
|
||||
host_t *remote;
|
||||
time_t last_use;
|
||||
} udp_flow_t;
|
||||
|
||||
struct private_direct_udp_t {
|
||||
|
||||
/**
|
||||
* Public interface
|
||||
*/
|
||||
direct_udp_t public;
|
||||
|
||||
/**
|
||||
* Engine pointer for TUN emission
|
||||
*/
|
||||
direct_engine_t *engine;
|
||||
|
||||
/**
|
||||
* Shared counters
|
||||
*/
|
||||
direct_stats_t *stats;
|
||||
|
||||
/**
|
||||
* Fixed flow table; only touched on the engine thread
|
||||
*/
|
||||
udp_flow_t flows[FLOW_SLOTS];
|
||||
};
|
||||
|
||||
/**
|
||||
* Close a flow slot (engine thread); no-op for unused slots
|
||||
*/
|
||||
static void flow_close(private_direct_udp_t *this, int slot)
|
||||
{
|
||||
udp_flow_t *flow = &this->flows[slot];
|
||||
|
||||
if (flow->fd < 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
close(flow->fd);
|
||||
flow->fd = -1;
|
||||
DESTROY_IF(flow->origin);
|
||||
flow->origin = NULL;
|
||||
DESTROY_IF(flow->remote);
|
||||
flow->remote = NULL;
|
||||
DIRECT_STATS_ADD(this->stats->udp_flows, -1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a protected datagram socket connected to the remote end
|
||||
*/
|
||||
static int open_flow_socket(host_t *remote)
|
||||
{
|
||||
int fd;
|
||||
|
||||
fd = socket(remote->get_family(remote), SOCK_DGRAM | SOCK_NONBLOCK, IPPROTO_UDP);
|
||||
if (fd < 0)
|
||||
{
|
||||
DBG1(DBG_NET, "direct udp: socket failed: %s", strerror(errno));
|
||||
return -1;
|
||||
}
|
||||
if (!charonservice_protect_socket(fd))
|
||||
{
|
||||
DBG1(DBG_NET, "direct udp: protecting socket failed");
|
||||
close(fd);
|
||||
return -1;
|
||||
}
|
||||
if (connect(fd, remote->get_sockaddr(remote), *remote->get_sockaddr_len(remote)) < 0)
|
||||
{
|
||||
DBG1(DBG_NET, "direct udp: connect failed: %s", strerror(errno));
|
||||
close(fd);
|
||||
return -1;
|
||||
}
|
||||
return fd;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read pending responses of one flow and emit them to the TUN
|
||||
*/
|
||||
static void flow_process(private_direct_udp_t *this, int slot)
|
||||
{
|
||||
static uint8_t buf[65535];
|
||||
udp_flow_t *flow = &this->flows[slot];
|
||||
ssize_t len;
|
||||
|
||||
while (TRUE)
|
||||
{
|
||||
len = recv(flow->fd, buf, sizeof(buf), MSG_DONTWAIT);
|
||||
if (len < 0)
|
||||
{
|
||||
if (errno != EWOULDBLOCK && errno != EAGAIN)
|
||||
{
|
||||
DBG1(DBG_NET, "direct udp: recv failed: %s", strerror(errno));
|
||||
}
|
||||
return;
|
||||
}
|
||||
flow->last_use = time_monotonic(NULL);
|
||||
/* address the response from the remote the kernel talked to */
|
||||
ip_packet_t *packet = ip_packet_create_udp_from_data(
|
||||
flow->remote, flow->origin,
|
||||
chunk_create(buf, len));
|
||||
if (!packet)
|
||||
{
|
||||
DIRECT_STATS_ADD(this->stats->drop_malformed, 1);
|
||||
continue;
|
||||
}
|
||||
DIRECT_STATS_ADD(this->stats->udp_pkts, 1);
|
||||
DIRECT_STATS_ADD(this->stats->udp_bytes, len);
|
||||
direct_engine_emit(this->engine, packet);
|
||||
}
|
||||
}
|
||||
|
||||
METHOD(direct_udp_t, handle_packet, void,
|
||||
private_direct_udp_t *this, ip_packet_t *packet)
|
||||
{
|
||||
host_t *src, *dst;
|
||||
chunk_t data;
|
||||
int slot, free_slot = -1;
|
||||
|
||||
src = packet->get_source(packet);
|
||||
dst = packet->get_destination(packet);
|
||||
data = packet->get_payload(packet);
|
||||
if (data.len < 8)
|
||||
{
|
||||
DIRECT_STATS_ADD(this->stats->drop_malformed, 1);
|
||||
packet->destroy(packet);
|
||||
return;
|
||||
}
|
||||
/* strip the UDP header */
|
||||
data = chunk_skip(data, 8);
|
||||
|
||||
for (slot = 0; slot < FLOW_SLOTS; slot++)
|
||||
{
|
||||
udp_flow_t *flow = &this->flows[slot];
|
||||
|
||||
if (flow->fd < 0)
|
||||
{
|
||||
if (free_slot == -1)
|
||||
{
|
||||
free_slot = slot;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (flow->origin->equals(flow->origin, src) &&
|
||||
flow->remote->equals(flow->remote, dst))
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (slot == FLOW_SLOTS)
|
||||
{ /* no flow yet - open one */
|
||||
if (free_slot == -1)
|
||||
{ /* table full: drop */
|
||||
DIRECT_STATS_ADD(this->stats->relay_errors, 1);
|
||||
packet->destroy(packet);
|
||||
return;
|
||||
}
|
||||
slot = free_slot;
|
||||
this->flows[slot].fd = open_flow_socket(dst);
|
||||
if (this->flows[slot].fd < 0)
|
||||
{
|
||||
DIRECT_STATS_ADD(this->stats->relay_errors, 1);
|
||||
packet->destroy(packet);
|
||||
return;
|
||||
}
|
||||
this->flows[slot].origin = src->clone(src);
|
||||
this->flows[slot].remote = dst->clone(dst);
|
||||
DIRECT_STATS_ADD(this->stats->udp_flows, 1);
|
||||
}
|
||||
this->flows[slot].last_use = time_monotonic(NULL);
|
||||
if (send(this->flows[slot].fd, data.ptr, data.len, MSG_DONTWAIT) != data.len)
|
||||
{
|
||||
DIRECT_STATS_ADD(this->stats->relay_errors, 1);
|
||||
}
|
||||
packet->destroy(packet);
|
||||
}
|
||||
|
||||
METHOD(direct_udp_t, fill_poll, size_t,
|
||||
private_direct_udp_t *this, struct pollfd *fds, size_t cap)
|
||||
{
|
||||
int slot;
|
||||
size_t n = 0;
|
||||
|
||||
for (slot = 0; slot < FLOW_SLOTS && n < cap; slot++)
|
||||
{
|
||||
if (this->flows[slot].fd >= 0)
|
||||
{
|
||||
fds[n].fd = this->flows[slot].fd;
|
||||
fds[n].events = POLLIN;
|
||||
fds[n].revents = 0;
|
||||
n++;
|
||||
}
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
METHOD(direct_udp_t, process_poll, void,
|
||||
private_direct_udp_t *this, struct pollfd *fds, size_t count)
|
||||
{
|
||||
size_t i;
|
||||
|
||||
/* flows may have been opened or closed between fill_poll() and here,
|
||||
* so match by fd value, not by position */
|
||||
for (i = 0; i < count; i++)
|
||||
{
|
||||
int slot;
|
||||
|
||||
if (!(fds[i].revents & (POLLIN | POLLERR | POLLHUP | POLLNVAL)))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
for (slot = 0; slot < FLOW_SLOTS; slot++)
|
||||
{
|
||||
if (this->flows[slot].fd == fds[i].fd)
|
||||
{
|
||||
if (fds[i].revents & (POLLIN | POLLHUP))
|
||||
{
|
||||
flow_process(this, slot);
|
||||
}
|
||||
if (fds[i].revents & (POLLERR | POLLNVAL))
|
||||
{ /* socket is dead - the next packet reopens the flow */
|
||||
flow_close(this, slot);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
METHOD(direct_udp_t, sweep, void,
|
||||
private_direct_udp_t *this)
|
||||
{
|
||||
time_t now = time_monotonic(NULL);
|
||||
int slot;
|
||||
|
||||
for (slot = 0; slot < FLOW_SLOTS; slot++)
|
||||
{
|
||||
if (this->flows[slot].fd >= 0 &&
|
||||
now - this->flows[slot].last_use >= FLOW_TIMEOUT)
|
||||
{
|
||||
flow_close(this, slot);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
METHOD(direct_udp_t, reprotect, void,
|
||||
private_direct_udp_t *this)
|
||||
{
|
||||
int slot;
|
||||
|
||||
for (slot = 0; slot < FLOW_SLOTS; slot++)
|
||||
{
|
||||
if (this->flows[slot].fd >= 0 &&
|
||||
!charonservice_protect_socket(this->flows[slot].fd))
|
||||
{
|
||||
DBG1(DBG_NET, "direct udp: re-protect failed on slot %d", slot);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
METHOD(direct_udp_t, reset, void,
|
||||
private_direct_udp_t *this)
|
||||
{
|
||||
int slot;
|
||||
|
||||
DIRECT_STATS_SET(this->stats->udp_flows, 0);
|
||||
for (slot = 0; slot < FLOW_SLOTS; slot++)
|
||||
{
|
||||
flow_close(this, slot);
|
||||
}
|
||||
}
|
||||
|
||||
METHOD(direct_udp_t, destroy, void,
|
||||
private_direct_udp_t *this)
|
||||
{
|
||||
reset(this);
|
||||
free(this);
|
||||
}
|
||||
|
||||
direct_udp_t *direct_udp_create(direct_engine_t *engine,
|
||||
direct_stats_t *stats)
|
||||
{
|
||||
private_direct_udp_t *this;
|
||||
int slot;
|
||||
|
||||
INIT(this,
|
||||
.public = {
|
||||
.handle_packet = _handle_packet,
|
||||
.fill_poll = _fill_poll,
|
||||
.process_poll = _process_poll,
|
||||
.sweep = _sweep,
|
||||
.reprotect = _reprotect,
|
||||
.reset = _reset,
|
||||
.destroy = _destroy,
|
||||
},
|
||||
.engine = engine,
|
||||
.stats = stats,
|
||||
);
|
||||
for (slot = 0; slot < FLOW_SLOTS; slot++)
|
||||
{
|
||||
this->flows[slot].fd = -1;
|
||||
}
|
||||
|
||||
return &this->public;
|
||||
}
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
#ifndef DIRECT_UDP_H_
|
||||
#define DIRECT_UDP_H_
|
||||
|
||||
#include <ip_packet.h>
|
||||
|
||||
#include "direct_engine.h"
|
||||
|
||||
#include <poll.h>
|
||||
#include <stddef.h>
|
||||
|
||||
typedef struct direct_udp_t direct_udp_t;
|
||||
|
||||
/**
|
||||
* UDP NAT for direct flows, modeled after android_dns_proxy: one protected
|
||||
* socket per (kernel source, remote destination) pair; responses are turned
|
||||
* back into IP packets addressed from the remote to the original source and
|
||||
* emitted through the engine's TUN writer.
|
||||
*/
|
||||
struct direct_udp_t {
|
||||
|
||||
/**
|
||||
* Handle an outbound UDP packet (consumes it). Runs on the engine
|
||||
* thread; packets arrive pre-classified as direct.
|
||||
*/
|
||||
void (*handle_packet)(direct_udp_t *this, ip_packet_t *packet);
|
||||
|
||||
/**
|
||||
* Add this module's sockets to the poll set, returns how many.
|
||||
*/
|
||||
size_t (*fill_poll)(direct_udp_t *this, struct pollfd *fds, size_t cap);
|
||||
|
||||
/**
|
||||
* Check revents of the previously added sockets (same count/order).
|
||||
*/
|
||||
void (*process_poll)(direct_udp_t *this, struct pollfd *fds, size_t count);
|
||||
|
||||
/**
|
||||
* Expire idle flows (called about once per second).
|
||||
*/
|
||||
void (*sweep)(direct_udp_t *this);
|
||||
|
||||
/**
|
||||
* Re-protect all flow sockets on the current default network (roam).
|
||||
*/
|
||||
void (*reprotect)(direct_udp_t *this);
|
||||
|
||||
/**
|
||||
* Close all flows (TUN went away).
|
||||
*/
|
||||
void (*reset)(direct_udp_t *this);
|
||||
|
||||
void (*destroy)(direct_udp_t *this);
|
||||
};
|
||||
|
||||
direct_udp_t *direct_udp_create(direct_engine_t *engine,
|
||||
direct_stats_t *stats);
|
||||
|
||||
#endif /** DIRECT_UDP_H_ */
|
||||
@@ -0,0 +1,304 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
#include "ru_set.h"
|
||||
|
||||
#include <library.h>
|
||||
#include <utils/debug.h>
|
||||
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
typedef struct private_ru_set_t private_ru_set_t;
|
||||
|
||||
struct private_ru_set_t {
|
||||
|
||||
/**
|
||||
* Public interface
|
||||
*/
|
||||
ru_set_t public;
|
||||
|
||||
/**
|
||||
* Sorted non-overlapping ranges (network byte order)
|
||||
*/
|
||||
uint32_t *v4_from, *v4_to;
|
||||
uint8_t *v6_from, *v6_to;
|
||||
|
||||
/**
|
||||
* Sorted force-tunnel addresses (network byte order)
|
||||
*/
|
||||
uint32_t *ft_v4;
|
||||
uint8_t *ft_v6;
|
||||
};
|
||||
|
||||
/**
|
||||
* Compare two 16-byte keys (network order is big-endian => memcmp is numeric)
|
||||
*/
|
||||
static int cmp_v6(const void *a, const void *b)
|
||||
{
|
||||
return memcmp(a, b, 16);
|
||||
}
|
||||
|
||||
static int cmp_v4(const void *a, const void *b)
|
||||
{
|
||||
uint32_t x = *(uint32_t*)a, y = *(uint32_t*)b;
|
||||
|
||||
return x < y ? -1 : (x > y ? 1 : 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sort parallel from/to range arrays by the from key (insertion sort on an
|
||||
* index permutation keeps the pairs together without a struct copy)
|
||||
*/
|
||||
static void qsort_ranges_v4(uint32_t *from, uint32_t *to, size_t count)
|
||||
{
|
||||
size_t *idx = malloc(count * sizeof(size_t));
|
||||
size_t i, j;
|
||||
|
||||
if (!idx)
|
||||
{
|
||||
return;
|
||||
}
|
||||
for (i = 0; i < count; i++)
|
||||
{
|
||||
idx[i] = i;
|
||||
}
|
||||
/* insertion sort by from[idx]: the arrays arrive nearly sorted from the
|
||||
* Java merge, so this is effectively linear */
|
||||
for (i = 1; i < count; i++)
|
||||
{
|
||||
size_t cur = idx[i];
|
||||
|
||||
for (j = i; j > 0 && from[idx[j - 1]] > from[cur]; j--)
|
||||
{
|
||||
idx[j] = idx[j - 1];
|
||||
}
|
||||
idx[j] = cur;
|
||||
}
|
||||
{
|
||||
uint32_t *sf = malloc(count * sizeof(uint32_t));
|
||||
uint32_t *st = malloc(count * sizeof(uint32_t));
|
||||
|
||||
if (sf && st)
|
||||
{
|
||||
for (i = 0; i < count; i++)
|
||||
{
|
||||
sf[i] = from[idx[i]];
|
||||
st[i] = to[idx[i]];
|
||||
}
|
||||
memcpy(from, sf, count * sizeof(uint32_t));
|
||||
memcpy(to, st, count * sizeof(uint32_t));
|
||||
}
|
||||
free(sf);
|
||||
free(st);
|
||||
}
|
||||
free(idx);
|
||||
}
|
||||
|
||||
static void qsort_ranges_v6(uint8_t *from, uint8_t *to, size_t count)
|
||||
{
|
||||
size_t *idx = malloc(count * sizeof(size_t));
|
||||
size_t i, j;
|
||||
|
||||
if (!idx)
|
||||
{
|
||||
return;
|
||||
}
|
||||
for (i = 0; i < count; i++)
|
||||
{
|
||||
idx[i] = i;
|
||||
}
|
||||
for (i = 1; i < count; i++)
|
||||
{
|
||||
size_t cur = idx[i];
|
||||
|
||||
for (j = i; j > 0 && memcmp(from + idx[j - 1] * 16, from + cur * 16, 16) > 0; j--)
|
||||
{
|
||||
idx[j] = idx[j - 1];
|
||||
}
|
||||
idx[j] = cur;
|
||||
}
|
||||
{
|
||||
uint8_t *sf = malloc(count * 16);
|
||||
uint8_t *st = malloc(count * 16);
|
||||
|
||||
if (sf && st)
|
||||
{
|
||||
for (i = 0; i < count; i++)
|
||||
{
|
||||
memcpy(sf + i * 16, from + idx[i] * 16, 16);
|
||||
memcpy(st + i * 16, to + idx[i] * 16, 16);
|
||||
}
|
||||
memcpy(from, sf, count * 16);
|
||||
memcpy(to, st, count * 16);
|
||||
}
|
||||
free(sf);
|
||||
free(st);
|
||||
}
|
||||
free(idx);
|
||||
}
|
||||
|
||||
/**
|
||||
* Binary search: index of the last range with from <= addr, or -1
|
||||
*/
|
||||
static ssize_t find_v4(uint32_t *from, size_t count, uint32_t addr)
|
||||
{
|
||||
ssize_t lo = 0, hi = count - 1, res = -1;
|
||||
|
||||
while (lo <= hi)
|
||||
{
|
||||
ssize_t mid = lo + (hi - lo) / 2;
|
||||
|
||||
if (from[mid] <= addr)
|
||||
{
|
||||
res = mid;
|
||||
lo = mid + 1;
|
||||
}
|
||||
else
|
||||
{
|
||||
hi = mid - 1;
|
||||
}
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
static ssize_t find_v6(uint8_t *keys, size_t count, const uint8_t addr[16])
|
||||
{
|
||||
size_t lo = 0, hi = count;
|
||||
ssize_t res = -1;
|
||||
|
||||
while (lo < hi)
|
||||
{
|
||||
size_t mid = lo + (hi - lo) / 2;
|
||||
|
||||
if (memcmp(keys + mid * 16, addr, 16) <= 0)
|
||||
{
|
||||
res = mid;
|
||||
lo = mid + 1;
|
||||
}
|
||||
else
|
||||
{
|
||||
hi = mid;
|
||||
}
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
METHOD(ru_set_t, contains_v4, bool,
|
||||
private_ru_set_t *this, uint32_t dst)
|
||||
{
|
||||
ssize_t i = find_v4(this->v4_from, this->public.v4_count, dst);
|
||||
|
||||
return i >= 0 && dst <= this->v4_to[i];
|
||||
}
|
||||
|
||||
METHOD(ru_set_t, contains_v6, bool,
|
||||
private_ru_set_t *this, const uint8_t dst[16])
|
||||
{
|
||||
ssize_t i = find_v6(this->v6_from, this->public.v6_count, dst);
|
||||
|
||||
return i >= 0 && memcmp(dst, this->v6_to + i * 16, 16) <= 0;
|
||||
}
|
||||
|
||||
METHOD(ru_set_t, force_tunnel_v4, bool,
|
||||
private_ru_set_t *this, uint32_t addr)
|
||||
{
|
||||
size_t lo = 0, hi = this->public.ft_v4_count;
|
||||
|
||||
while (lo < hi)
|
||||
{
|
||||
size_t mid = lo + (hi - lo) / 2;
|
||||
|
||||
if (this->ft_v4[mid] == addr)
|
||||
{
|
||||
return TRUE;
|
||||
}
|
||||
if (this->ft_v4[mid] < addr)
|
||||
{
|
||||
lo = mid + 1;
|
||||
}
|
||||
else
|
||||
{
|
||||
hi = mid;
|
||||
}
|
||||
}
|
||||
return FALSE;
|
||||
}
|
||||
|
||||
METHOD(ru_set_t, force_tunnel_v6, bool,
|
||||
private_ru_set_t *this, const uint8_t addr[16])
|
||||
{
|
||||
return bsearch(addr, this->ft_v6, this->public.ft_v6_count, 16, cmp_v6) != NULL;
|
||||
}
|
||||
|
||||
METHOD(ru_set_t, destroy, void,
|
||||
private_ru_set_t *this)
|
||||
{
|
||||
free(this->v4_from);
|
||||
free(this->v4_to);
|
||||
free(this->v6_from);
|
||||
free(this->v6_to);
|
||||
free(this->ft_v4);
|
||||
free(this->ft_v6);
|
||||
free(this);
|
||||
}
|
||||
|
||||
ru_set_t *ru_set_create(uint32_t *v4_from, uint32_t *v4_to, size_t v4_count,
|
||||
uint8_t *v6_from, uint8_t *v6_to, size_t v6_count,
|
||||
uint32_t *ft_v4, size_t ft_v4_count,
|
||||
uint8_t *ft_v6, size_t ft_v6_count)
|
||||
{
|
||||
private_ru_set_t *this;
|
||||
|
||||
INIT(this,
|
||||
.public = {
|
||||
.v4_count = v4_count,
|
||||
.v6_count = v6_count,
|
||||
.ft_v4_count = ft_v4_count,
|
||||
.ft_v6_count = ft_v6_count,
|
||||
.contains_v4 = _contains_v4,
|
||||
.contains_v6 = _contains_v6,
|
||||
.force_tunnel_v4 = _force_tunnel_v4,
|
||||
.force_tunnel_v6 = _force_tunnel_v6,
|
||||
.destroy = _destroy,
|
||||
},
|
||||
.v4_from = v4_from,
|
||||
.v4_to = v4_to,
|
||||
.v6_from = v6_from,
|
||||
.v6_to = v6_to,
|
||||
.ft_v4 = ft_v4,
|
||||
.ft_v6 = ft_v6,
|
||||
);
|
||||
|
||||
/* the lookups are binary searches in numeric (network) order; do not
|
||||
* rely on the caller's comparator - sort everything here. The from/to
|
||||
* range arrays are parallel and are reordered together. */
|
||||
if (v4_count > 1)
|
||||
{
|
||||
qsort_ranges_v4(this->v4_from, this->v4_to, v4_count);
|
||||
}
|
||||
if (ft_v4_count > 1)
|
||||
{
|
||||
qsort(this->ft_v4, ft_v4_count, sizeof(uint32_t), cmp_v4);
|
||||
}
|
||||
if (v6_count > 1)
|
||||
{
|
||||
qsort_ranges_v6(this->v6_from, this->v6_to, v6_count);
|
||||
}
|
||||
if (ft_v6_count > 1)
|
||||
{
|
||||
qsort(this->ft_v6, ft_v6_count, 16, cmp_v6);
|
||||
}
|
||||
return &this->public;
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @defgroup ru_set ru_set
|
||||
* @{ @ingroup routing
|
||||
*
|
||||
* Classification sets for the userspace routing engine: the merged, sorted
|
||||
* and non-overlapping RU address ranges (as produced by RuIpListManager on
|
||||
* the Java side) plus the small force-tunnel list (DoT and tunnel DNS
|
||||
* servers that must never go direct).
|
||||
*
|
||||
* Addresses are stored in network byte order; IPv4 ranges as 32-bit values,
|
||||
* IPv6 ranges as 16-byte keys. Lookups are binary searches, so a set with
|
||||
* ~15k ranges answers in a few comparisons without any per-packet locking
|
||||
* (sets are replaced atomically by the engine).
|
||||
*/
|
||||
|
||||
#ifndef RU_SET_H_
|
||||
#define RU_SET_H_
|
||||
|
||||
#include <stdbool.h>
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
|
||||
typedef struct ru_set_t ru_set_t;
|
||||
|
||||
struct ru_set_t {
|
||||
|
||||
/**
|
||||
* Number of IPv4/IPv6 ranges and force-tunnel entries (diagnostics).
|
||||
*/
|
||||
size_t v4_count, v6_count, ft_v4_count, ft_v6_count;
|
||||
|
||||
/**
|
||||
* Whether the destination (network byte order) is inside the RU ranges.
|
||||
*/
|
||||
bool (*contains_v4)(ru_set_t *set, uint32_t dst);
|
||||
bool (*contains_v6)(ru_set_t *set, const uint8_t dst[16]);
|
||||
|
||||
/**
|
||||
* Whether the address is in the force-tunnel set (always tunneled).
|
||||
*/
|
||||
bool (*force_tunnel_v4)(ru_set_t *set, uint32_t addr);
|
||||
bool (*force_tunnel_v6)(ru_set_t *set, const uint8_t addr[16]);
|
||||
|
||||
/**
|
||||
* Destroy the set (frees all arrays).
|
||||
*/
|
||||
void (*destroy)(ru_set_t *set);
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a set, taking ownership of the given arrays (network byte order).
|
||||
* The force-tunnel arrays are sorted by the constructor.
|
||||
*/
|
||||
ru_set_t *ru_set_create(uint32_t *v4_from, uint32_t *v4_to, size_t v4_count,
|
||||
uint8_t *v6_from, uint8_t *v6_to, size_t v6_count,
|
||||
uint32_t *ft_v4, size_t ft_v4_count,
|
||||
uint8_t *ft_v6, size_t ft_v6_count);
|
||||
|
||||
#endif /** RU_SET_H_ @} */
|
||||
@@ -0,0 +1,39 @@
|
||||
LOCAL_PATH := $(call my-dir)
|
||||
include $(CLEAR_VARS)
|
||||
|
||||
# lwIP 2.2.0, trimmed to what the direct-routing engine needs: the TCP core
|
||||
# with IPv4/IPv6 (NO_SYS=1, single-threaded raw API, see port/lwipopts.h).
|
||||
# API files (sockets/netconn) and network drivers are not built.
|
||||
LOCAL_SRC_FILES := \
|
||||
src/core/init.c \
|
||||
src/core/def.c \
|
||||
src/core/inet_chksum.c \
|
||||
src/core/ip.c \
|
||||
src/core/mem.c \
|
||||
src/core/memp.c \
|
||||
src/core/netif.c \
|
||||
src/core/pbuf.c \
|
||||
src/core/stats.c \
|
||||
src/core/sys.c \
|
||||
src/core/tcp.c \
|
||||
src/core/tcp_in.c \
|
||||
src/core/tcp_out.c \
|
||||
src/core/timeouts.c \
|
||||
src/core/ipv4/ip4.c \
|
||||
src/core/ipv4/ip4_addr.c \
|
||||
src/core/ipv6/icmp6.c \
|
||||
src/core/ipv6/inet6.c \
|
||||
src/core/ipv6/ip6.c \
|
||||
src/core/ipv6/ip6_addr.c \
|
||||
src/core/ipv6/nd6.c
|
||||
|
||||
LOCAL_C_INCLUDES := \
|
||||
$(LOCAL_PATH)/src/include \
|
||||
$(LOCAL_PATH)/port
|
||||
|
||||
# third-party code: keep the build log readable
|
||||
LOCAL_CFLAGS := -w
|
||||
|
||||
LOCAL_MODULE := liblwip
|
||||
|
||||
include $(BUILD_STATIC_LIBRARY)
|
||||
@@ -0,0 +1,27 @@
|
||||
/*
|
||||
* Minimal lwIP port for the Android direct-routing engine.
|
||||
*
|
||||
* The stack runs single-threaded (NO_SYS=1) on the engine's own thread; no
|
||||
* sys_arch is needed. Diagnostics are compiled out - the engine keeps its
|
||||
* own counters for diag.log.
|
||||
*/
|
||||
#ifndef LWIP_ARCH_CC_H
|
||||
#define LWIP_ARCH_CC_H
|
||||
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
void lwip_assert_diag(const char *msg);
|
||||
|
||||
#define PACK_STRUCT_BEGIN
|
||||
#define PACK_STRUCT_END
|
||||
#define PACK_STRUCT_STRUCT __attribute__((packed))
|
||||
#define PACK_STRUCT_FIELD(x) x
|
||||
|
||||
/* lwIP debug output goes to diag.log (message + abort marker); the abort
|
||||
* itself happens right after via abort() so the crash record with the
|
||||
* backtrace follows in the same file */
|
||||
#define LWIP_PLATFORM_DIAG(x) do { } while (0)
|
||||
#define LWIP_PLATFORM_ASSERT(x) lwip_assert_diag(x)
|
||||
|
||||
#endif /* LWIP_ARCH_CC_H */
|
||||
@@ -0,0 +1,7 @@
|
||||
#ifndef LWIP_ARCH_PERF_H
|
||||
#define LWIP_ARCH_PERF_H
|
||||
|
||||
#define PERF_START
|
||||
#define PERF_STOP(x)
|
||||
|
||||
#endif /* LWIP_ARCH_PERF_H */
|
||||
@@ -0,0 +1,91 @@
|
||||
/*
|
||||
* lwIP options for the Android direct-routing engine.
|
||||
*
|
||||
* The stack terminates TCP flows on behalf of the kernel side of the TUN
|
||||
* device (tun2socks model): inbound segments are dispatched to tcp_input()
|
||||
* by the engine, outbound segments leave through a netif whose output
|
||||
* callback writes raw IP packets back into the TUN. Only the TCP core is
|
||||
* enabled; UDP is handled by a separate NAT in the engine (ip_packet
|
||||
* crafting already exists there), so lwIP carries TCP only.
|
||||
*/
|
||||
#ifndef LWIPOPTS_H
|
||||
#define LWIPOPTS_H
|
||||
|
||||
/* platform: no OS integration, single thread, no sockets/netconn API */
|
||||
#define NO_SYS 1
|
||||
#define SYS_LIGHTWEIGHT_PROT 0
|
||||
#define LWIP_TIMERS 1
|
||||
#define LWIP_SOCKET 0
|
||||
#define LWIP_NETCONN 0
|
||||
#define LWIP_NETIF_API 0
|
||||
#define LWIP_IPV4 1
|
||||
#define LWIP_IPV6 1
|
||||
#define LWIP_SINGLE_NETIF 1
|
||||
#define LWIP_NETIF_STATUS_CALLBACK 0
|
||||
#define LWIP_NETIF_LINK_CALLBACK 0
|
||||
#define LWIP_NETIF_EXT_STATUS_CALLBACK 0
|
||||
|
||||
/* memory: libc malloc via strongSwan's allocator is set up separately */
|
||||
#define MEM_LIBC_MALLOC 1
|
||||
#define MEMP_MEM_MALLOC 1
|
||||
|
||||
/* protocols: TCP only */
|
||||
#define LWIP_TCP 1
|
||||
#define LWIP_UDP 0
|
||||
#define LWIP_RAW 0
|
||||
#define LWIP_DNS 0
|
||||
#define LWIP_ARP 0
|
||||
#define LWIP_ICMP 0
|
||||
#define LWIP_ICMP6 1
|
||||
#define LWIP_IPV6_SEND_NS 0
|
||||
#define LWIP_IPV6_SEND_NA 0
|
||||
#define LWIP_IPV6_MLD 0
|
||||
#define LWIP_IGMP 0
|
||||
#define LWIP_DHCP 0
|
||||
#define LWIP_AUTOIP 0
|
||||
#define LWIP_IPV6_DHCP6 0
|
||||
#define LWIP_IPV6_AUTOCONFIG 0
|
||||
#define LWIP_IPV6_SEND_ROUTER_SOLICIT 0
|
||||
#define LWIP_IPV6_FRAG 0
|
||||
#define LWIP_IPV4_FRAG 0
|
||||
#define LWIP_IPV6_REASS 0
|
||||
#define LWIP_IPV4_REASS 0
|
||||
#define IP_REASSEMBLY 0
|
||||
#define IP_FRAG 0
|
||||
#define LWIP_MLD 0
|
||||
#define LWIP_ALTCP 0
|
||||
#define LWIP_ALTCP_TLS 0
|
||||
|
||||
/* checksums: generate for outgoing segments, skip verification of
|
||||
* kernel-generated inbound segments */
|
||||
#define CHECKSUM_GEN_IP 1
|
||||
#define CHECKSUM_GEN_TCP 1
|
||||
#define CHECKSUM_CHECK_IP 0
|
||||
#define CHECKSUM_CHECK_TCP 0
|
||||
#define CHECKSUM_CHECK_UDP 0
|
||||
|
||||
/* TCP tuning: the TUN MTU is 1400 (v4 1360 / v6 1340 max payload), 1340
|
||||
* is safe for both families; segment reassembly and re-segmentation on
|
||||
* both directions is handled by the stack */
|
||||
#define TCP_MSS 1340
|
||||
#define TCP_WND (8 * TCP_MSS)
|
||||
#define TCP_SND_BUF (8 * TCP_MSS)
|
||||
#define TCP_SND_QUEUELEN 32
|
||||
#define TCP_QUEUE_OOSEQ 1
|
||||
#define TCP_OOSEQ_MAX_PBUFS 64
|
||||
#define TCP_LISTEN_BACKLOG 1
|
||||
#define TCP_DEFAULT_LISTEN_BACKLOG 16
|
||||
#define TCP_MSL 5000
|
||||
#define LWIP_TCP_KEEPALIVE 1
|
||||
#define LWIP_WND_SCALE 1
|
||||
#define TCP_RCV_SCALE 1
|
||||
#define LWIP_TCP_SACK_OUT 1
|
||||
|
||||
/* silence stats/debug */
|
||||
#define LWIP_STATS 0
|
||||
#define LWIP_DEBUG 0
|
||||
|
||||
/* libstrongswan already has an err_t typedef, and it is a different type */
|
||||
#define LWIP_ERR_T int8_t
|
||||
|
||||
#endif /* LWIPOPTS_H */
|
||||
@@ -0,0 +1,15 @@
|
||||
api/ - The code for the high-level wrapper API. Not needed if
|
||||
you use the lowel-level call-back/raw API.
|
||||
|
||||
apps/ - Higher layer applications that are specifically programmed
|
||||
with the lwIP low-level raw API.
|
||||
|
||||
core/ - The core of the TPC/IP stack; protocol implementations,
|
||||
memory and buffer management, and the low-level raw API.
|
||||
|
||||
include/ - lwIP include files.
|
||||
|
||||
netif/ - Generic network interface device drivers are kept here.
|
||||
|
||||
For more information on the various subdirectories, check the FILES
|
||||
file in each directory.
|
||||
@@ -0,0 +1,293 @@
|
||||
# This file is indended to be included in end-user CMakeLists.txt
|
||||
# include(/path/to/Filelists.cmake)
|
||||
# It assumes the variable LWIP_DIR is defined pointing to the
|
||||
# root path of lwIP sources.
|
||||
#
|
||||
# This file is NOT designed (on purpose) to be used as cmake
|
||||
# subdir via add_subdirectory()
|
||||
# The intention is to provide greater flexibility to users to
|
||||
# create their own targets using the *_SRCS variables.
|
||||
|
||||
if(NOT ${CMAKE_VERSION} VERSION_LESS "3.10.0")
|
||||
include_guard(GLOBAL)
|
||||
endif()
|
||||
|
||||
set(LWIP_VERSION_MAJOR "2")
|
||||
set(LWIP_VERSION_MINOR "2")
|
||||
set(LWIP_VERSION_REVISION "0")
|
||||
# LWIP_VERSION_RC is set to LWIP_RC_RELEASE for official releases
|
||||
# LWIP_VERSION_RC is set to LWIP_RC_DEVELOPMENT for Git versions
|
||||
# Numbers 1..31 are reserved for release candidates
|
||||
set(LWIP_VERSION_RC "LWIP_RC_RELEASE")
|
||||
|
||||
if ("${LWIP_VERSION_RC}" STREQUAL "LWIP_RC_RELEASE")
|
||||
set(LWIP_VERSION_STRING
|
||||
"${LWIP_VERSION_MAJOR}.${LWIP_VERSION_MINOR}.${LWIP_VERSION_REVISION}"
|
||||
)
|
||||
elseif ("${LWIP_VERSION_RC}" STREQUAL "LWIP_RC_DEVELOPMENT")
|
||||
set(LWIP_VERSION_STRING
|
||||
"${LWIP_VERSION_MAJOR}.${LWIP_VERSION_MINOR}.${LWIP_VERSION_REVISION}.dev"
|
||||
)
|
||||
else()
|
||||
set(LWIP_VERSION_STRING
|
||||
"${LWIP_VERSION_MAJOR}.${LWIP_VERSION_MINOR}.${LWIP_VERSION_REVISION}.rc${LWIP_VERSION_RC}"
|
||||
)
|
||||
endif()
|
||||
|
||||
# The minimum set of files needed for lwIP.
|
||||
set(lwipcore_SRCS
|
||||
${LWIP_DIR}/src/core/init.c
|
||||
${LWIP_DIR}/src/core/def.c
|
||||
${LWIP_DIR}/src/core/dns.c
|
||||
${LWIP_DIR}/src/core/inet_chksum.c
|
||||
${LWIP_DIR}/src/core/ip.c
|
||||
${LWIP_DIR}/src/core/mem.c
|
||||
${LWIP_DIR}/src/core/memp.c
|
||||
${LWIP_DIR}/src/core/netif.c
|
||||
${LWIP_DIR}/src/core/pbuf.c
|
||||
${LWIP_DIR}/src/core/raw.c
|
||||
${LWIP_DIR}/src/core/stats.c
|
||||
${LWIP_DIR}/src/core/sys.c
|
||||
${LWIP_DIR}/src/core/altcp.c
|
||||
${LWIP_DIR}/src/core/altcp_alloc.c
|
||||
${LWIP_DIR}/src/core/altcp_tcp.c
|
||||
${LWIP_DIR}/src/core/tcp.c
|
||||
${LWIP_DIR}/src/core/tcp_in.c
|
||||
${LWIP_DIR}/src/core/tcp_out.c
|
||||
${LWIP_DIR}/src/core/timeouts.c
|
||||
${LWIP_DIR}/src/core/udp.c
|
||||
)
|
||||
set(lwipcore4_SRCS
|
||||
${LWIP_DIR}/src/core/ipv4/acd.c
|
||||
${LWIP_DIR}/src/core/ipv4/autoip.c
|
||||
${LWIP_DIR}/src/core/ipv4/dhcp.c
|
||||
${LWIP_DIR}/src/core/ipv4/etharp.c
|
||||
${LWIP_DIR}/src/core/ipv4/icmp.c
|
||||
${LWIP_DIR}/src/core/ipv4/igmp.c
|
||||
${LWIP_DIR}/src/core/ipv4/ip4_frag.c
|
||||
${LWIP_DIR}/src/core/ipv4/ip4.c
|
||||
${LWIP_DIR}/src/core/ipv4/ip4_addr.c
|
||||
)
|
||||
set(lwipcore6_SRCS
|
||||
${LWIP_DIR}/src/core/ipv6/dhcp6.c
|
||||
${LWIP_DIR}/src/core/ipv6/ethip6.c
|
||||
${LWIP_DIR}/src/core/ipv6/icmp6.c
|
||||
${LWIP_DIR}/src/core/ipv6/inet6.c
|
||||
${LWIP_DIR}/src/core/ipv6/ip6.c
|
||||
${LWIP_DIR}/src/core/ipv6/ip6_addr.c
|
||||
${LWIP_DIR}/src/core/ipv6/ip6_frag.c
|
||||
${LWIP_DIR}/src/core/ipv6/mld6.c
|
||||
${LWIP_DIR}/src/core/ipv6/nd6.c
|
||||
)
|
||||
|
||||
# APIFILES: The files which implement the sequential and socket APIs.
|
||||
set(lwipapi_SRCS
|
||||
${LWIP_DIR}/src/api/api_lib.c
|
||||
${LWIP_DIR}/src/api/api_msg.c
|
||||
${LWIP_DIR}/src/api/err.c
|
||||
${LWIP_DIR}/src/api/if_api.c
|
||||
${LWIP_DIR}/src/api/netbuf.c
|
||||
${LWIP_DIR}/src/api/netdb.c
|
||||
${LWIP_DIR}/src/api/netifapi.c
|
||||
${LWIP_DIR}/src/api/sockets.c
|
||||
${LWIP_DIR}/src/api/tcpip.c
|
||||
)
|
||||
|
||||
# Files implementing various generic network interface functions
|
||||
set(lwipnetif_SRCS
|
||||
${LWIP_DIR}/src/netif/ethernet.c
|
||||
${LWIP_DIR}/src/netif/bridgeif.c
|
||||
${LWIP_DIR}/src/netif/bridgeif_fdb.c
|
||||
)
|
||||
|
||||
if (NOT ${LWIP_EXCLUDE_SLIPIF})
|
||||
list(APPEND lwipnetif_SRCS ${LWIP_DIR}/src/netif/slipif.c)
|
||||
endif()
|
||||
|
||||
# 6LoWPAN
|
||||
set(lwipsixlowpan_SRCS
|
||||
${LWIP_DIR}/src/netif/lowpan6_common.c
|
||||
${LWIP_DIR}/src/netif/lowpan6.c
|
||||
${LWIP_DIR}/src/netif/lowpan6_ble.c
|
||||
${LWIP_DIR}/src/netif/zepif.c
|
||||
)
|
||||
|
||||
# PPP
|
||||
set(lwipppp_SRCS
|
||||
${LWIP_DIR}/src/netif/ppp/auth.c
|
||||
${LWIP_DIR}/src/netif/ppp/ccp.c
|
||||
${LWIP_DIR}/src/netif/ppp/chap-md5.c
|
||||
${LWIP_DIR}/src/netif/ppp/chap_ms.c
|
||||
${LWIP_DIR}/src/netif/ppp/chap-new.c
|
||||
${LWIP_DIR}/src/netif/ppp/demand.c
|
||||
${LWIP_DIR}/src/netif/ppp/eap.c
|
||||
${LWIP_DIR}/src/netif/ppp/ecp.c
|
||||
${LWIP_DIR}/src/netif/ppp/eui64.c
|
||||
${LWIP_DIR}/src/netif/ppp/fsm.c
|
||||
${LWIP_DIR}/src/netif/ppp/ipcp.c
|
||||
${LWIP_DIR}/src/netif/ppp/ipv6cp.c
|
||||
${LWIP_DIR}/src/netif/ppp/lcp.c
|
||||
${LWIP_DIR}/src/netif/ppp/magic.c
|
||||
${LWIP_DIR}/src/netif/ppp/mppe.c
|
||||
${LWIP_DIR}/src/netif/ppp/multilink.c
|
||||
${LWIP_DIR}/src/netif/ppp/ppp.c
|
||||
${LWIP_DIR}/src/netif/ppp/pppapi.c
|
||||
${LWIP_DIR}/src/netif/ppp/pppcrypt.c
|
||||
${LWIP_DIR}/src/netif/ppp/pppoe.c
|
||||
${LWIP_DIR}/src/netif/ppp/pppol2tp.c
|
||||
${LWIP_DIR}/src/netif/ppp/pppos.c
|
||||
${LWIP_DIR}/src/netif/ppp/upap.c
|
||||
${LWIP_DIR}/src/netif/ppp/utils.c
|
||||
${LWIP_DIR}/src/netif/ppp/vj.c
|
||||
${LWIP_DIR}/src/netif/ppp/polarssl/arc4.c
|
||||
${LWIP_DIR}/src/netif/ppp/polarssl/des.c
|
||||
${LWIP_DIR}/src/netif/ppp/polarssl/md4.c
|
||||
${LWIP_DIR}/src/netif/ppp/polarssl/md5.c
|
||||
${LWIP_DIR}/src/netif/ppp/polarssl/sha1.c
|
||||
)
|
||||
|
||||
# SNMPv3 agent
|
||||
set(lwipsnmp_SRCS
|
||||
${LWIP_DIR}/src/apps/snmp/snmp_asn1.c
|
||||
${LWIP_DIR}/src/apps/snmp/snmp_core.c
|
||||
${LWIP_DIR}/src/apps/snmp/snmp_mib2.c
|
||||
${LWIP_DIR}/src/apps/snmp/snmp_mib2_icmp.c
|
||||
${LWIP_DIR}/src/apps/snmp/snmp_mib2_interfaces.c
|
||||
${LWIP_DIR}/src/apps/snmp/snmp_mib2_ip.c
|
||||
${LWIP_DIR}/src/apps/snmp/snmp_mib2_snmp.c
|
||||
${LWIP_DIR}/src/apps/snmp/snmp_mib2_system.c
|
||||
${LWIP_DIR}/src/apps/snmp/snmp_mib2_tcp.c
|
||||
${LWIP_DIR}/src/apps/snmp/snmp_mib2_udp.c
|
||||
${LWIP_DIR}/src/apps/snmp/snmp_snmpv2_framework.c
|
||||
${LWIP_DIR}/src/apps/snmp/snmp_snmpv2_usm.c
|
||||
${LWIP_DIR}/src/apps/snmp/snmp_msg.c
|
||||
${LWIP_DIR}/src/apps/snmp/snmpv3.c
|
||||
${LWIP_DIR}/src/apps/snmp/snmp_netconn.c
|
||||
${LWIP_DIR}/src/apps/snmp/snmp_pbuf_stream.c
|
||||
${LWIP_DIR}/src/apps/snmp/snmp_raw.c
|
||||
${LWIP_DIR}/src/apps/snmp/snmp_scalar.c
|
||||
${LWIP_DIR}/src/apps/snmp/snmp_table.c
|
||||
${LWIP_DIR}/src/apps/snmp/snmp_threadsync.c
|
||||
${LWIP_DIR}/src/apps/snmp/snmp_traps.c
|
||||
)
|
||||
|
||||
# HTTP server + client
|
||||
set(lwiphttp_SRCS
|
||||
${LWIP_DIR}/src/apps/http/altcp_proxyconnect.c
|
||||
${LWIP_DIR}/src/apps/http/fs.c
|
||||
${LWIP_DIR}/src/apps/http/http_client.c
|
||||
${LWIP_DIR}/src/apps/http/httpd.c
|
||||
)
|
||||
|
||||
# MAKEFSDATA HTTP server host utility
|
||||
set(lwipmakefsdata_SRCS
|
||||
${LWIP_DIR}/src/apps/http/makefsdata/makefsdata.c
|
||||
)
|
||||
|
||||
# IPERF server
|
||||
set(lwipiperf_SRCS
|
||||
${LWIP_DIR}/src/apps/lwiperf/lwiperf.c
|
||||
)
|
||||
|
||||
# SMTP client
|
||||
set(lwipsmtp_SRCS
|
||||
${LWIP_DIR}/src/apps/smtp/smtp.c
|
||||
)
|
||||
|
||||
# SNTP client
|
||||
set(lwipsntp_SRCS
|
||||
${LWIP_DIR}/src/apps/sntp/sntp.c
|
||||
)
|
||||
|
||||
# MDNS responder
|
||||
set(lwipmdns_SRCS
|
||||
${LWIP_DIR}/src/apps/mdns/mdns.c
|
||||
${LWIP_DIR}/src/apps/mdns/mdns_out.c
|
||||
${LWIP_DIR}/src/apps/mdns/mdns_domain.c
|
||||
)
|
||||
|
||||
# NetBIOS name server
|
||||
set(lwipnetbios_SRCS
|
||||
${LWIP_DIR}/src/apps/netbiosns/netbiosns.c
|
||||
)
|
||||
|
||||
# TFTP server files
|
||||
set(lwiptftp_SRCS
|
||||
${LWIP_DIR}/src/apps/tftp/tftp.c
|
||||
)
|
||||
|
||||
# MQTT client files
|
||||
set(lwipmqtt_SRCS
|
||||
${LWIP_DIR}/src/apps/mqtt/mqtt.c
|
||||
)
|
||||
|
||||
# ARM MBEDTLS related files of lwIP rep
|
||||
set(lwipmbedtls_SRCS
|
||||
${LWIP_DIR}/src/apps/altcp_tls/altcp_tls_mbedtls.c
|
||||
${LWIP_DIR}/src/apps/altcp_tls/altcp_tls_mbedtls_mem.c
|
||||
${LWIP_DIR}/src/apps/snmp/snmpv3_mbedtls.c
|
||||
)
|
||||
|
||||
# All LWIP files without apps
|
||||
set(lwipnoapps_SRCS
|
||||
${lwipcore_SRCS}
|
||||
${lwipcore4_SRCS}
|
||||
${lwipcore6_SRCS}
|
||||
${lwipapi_SRCS}
|
||||
${lwipnetif_SRCS}
|
||||
${lwipsixlowpan_SRCS}
|
||||
${lwipppp_SRCS}
|
||||
)
|
||||
|
||||
# LWIPAPPFILES: All LWIP APPs
|
||||
set(lwipallapps_SRCS
|
||||
${lwipsnmp_SRCS}
|
||||
${lwiphttp_SRCS}
|
||||
${lwipiperf_SRCS}
|
||||
${lwipsmtp_SRCS}
|
||||
${lwipsntp_SRCS}
|
||||
${lwipmdns_SRCS}
|
||||
${lwipnetbios_SRCS}
|
||||
${lwiptftp_SRCS}
|
||||
${lwipmqtt_SRCS}
|
||||
)
|
||||
|
||||
# Generate lwip/init.h (version info)
|
||||
configure_file(${LWIP_DIR}/src/include/lwip/init.h.cmake.in ${LWIP_DIR}/src/include/lwip/init.h)
|
||||
|
||||
# Documentation
|
||||
set(DOXYGEN_DIR ${LWIP_DIR}/doc/doxygen)
|
||||
set(DOXYGEN_OUTPUT_DIR output)
|
||||
set(DOXYGEN_IN ${LWIP_DIR}/doc/doxygen/lwip.Doxyfile.cmake.in)
|
||||
set(DOXYGEN_OUT ${LWIP_DIR}/doc/doxygen/lwip.Doxyfile)
|
||||
configure_file(${DOXYGEN_IN} ${DOXYGEN_OUT})
|
||||
|
||||
find_package(Doxygen)
|
||||
if (DOXYGEN_FOUND)
|
||||
message(STATUS "Doxygen build started")
|
||||
|
||||
add_custom_target(lwipdocs
|
||||
COMMAND ${CMAKE_COMMAND} -E remove_directory ${DOXYGEN_DIR}/${DOXYGEN_OUTPUT_DIR}/html
|
||||
COMMAND ${DOXYGEN_EXECUTABLE} ${DOXYGEN_OUT}
|
||||
WORKING_DIRECTORY ${DOXYGEN_DIR}
|
||||
COMMENT "Generating API documentation with Doxygen"
|
||||
VERBATIM)
|
||||
else (DOXYGEN_FOUND)
|
||||
message(STATUS "Doxygen needs to be installed to generate the doxygen documentation")
|
||||
endif (DOXYGEN_FOUND)
|
||||
|
||||
# lwIP libraries
|
||||
add_library(lwipcore EXCLUDE_FROM_ALL ${lwipnoapps_SRCS})
|
||||
target_compile_options(lwipcore PRIVATE ${LWIP_COMPILER_FLAGS})
|
||||
target_compile_definitions(lwipcore PRIVATE ${LWIP_DEFINITIONS} ${LWIP_MBEDTLS_DEFINITIONS})
|
||||
target_include_directories(lwipcore PRIVATE ${LWIP_INCLUDE_DIRS} ${LWIP_MBEDTLS_INCLUDE_DIRS})
|
||||
|
||||
add_library(lwipallapps EXCLUDE_FROM_ALL ${lwipallapps_SRCS})
|
||||
target_compile_options(lwipallapps PRIVATE ${LWIP_COMPILER_FLAGS})
|
||||
target_compile_definitions(lwipallapps PRIVATE ${LWIP_DEFINITIONS} ${LWIP_MBEDTLS_DEFINITIONS})
|
||||
target_include_directories(lwipallapps PRIVATE ${LWIP_INCLUDE_DIRS} ${LWIP_MBEDTLS_INCLUDE_DIRS})
|
||||
|
||||
add_library(lwipmbedtls EXCLUDE_FROM_ALL ${lwipmbedtls_SRCS})
|
||||
target_compile_options(lwipmbedtls PRIVATE ${LWIP_COMPILER_FLAGS})
|
||||
target_compile_definitions(lwipmbedtls PRIVATE ${LWIP_DEFINITIONS} ${LWIP_MBEDTLS_DEFINITIONS})
|
||||
target_include_directories(lwipmbedtls PRIVATE ${LWIP_INCLUDE_DIRS} ${LWIP_MBEDTLS_INCLUDE_DIRS})
|
||||
Loaded 100 of 494 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user