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

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:
Denozordec committed 2026-09-30 16:50:03 +07:00
1 parent 9dcc56c821
commit d12fb96dc2
494 files changed
+156372 -5520

No files matched your search

+16
View File
@@ -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 и подпись.
+40
View File
@@ -0,0 +1,40 @@
<!--
Параметры сборки и публикации обновлений.
Скопируйте этот файл в .env и заполните значения:
cp .env.example .env
.env не попадает в git (добавлен в .gitignore).
Строки в формате KEY=value; комментарии - строки, начинающиеся с #.
-->
# --- S3 (Selectel Object Storage) ---
S3_ENDPOINT=https://s3.ru-7.storage.selcloud.ru
S3_REGION=ru-7
S3_BUCKET=shx-app
# Пусто = файлы кладутся в корень бакета (latest.json и shx-vpn.apk)
S3_PREFIX=
S3_ACCESS_KEY=
S3_SECRET_KEY=
# Если AWS CLI ругается на SSL (self-signed certificate in certificate chain),
# укажите CA-бандл, содержащий корень сертификата эндпоинта
# (для Selectel - GlobalSign Root CA - R6; например бандл из Git for Windows):
# AWS_CA_BUNDLE=C:\Program Files\Git\mingw64\etc\ssl\certs\ca-bundle.crt
# Публичная статичная ссылка на бакет (без завершающего слеша).
# Требуется публичный доступ на чтение бакета в панели Selectel.
# Зашивается в APK как адрес проверки обновлений.
S3_PUBLIC_BASE_URL=https://s3.ru-7.storage.selcloud.ru/shx-app
# --- Подпись APK (опционально) ---
# Если KEYSTORE_FILE не задан, release APK подписывается debug-ключом
# (~/.android/debug.keystore) - это нужно, чтобы обновление встало поверх
# сборок, установленных из Android Studio.
# Путь - относительно каталога android-проекта или абсолютный.
# KEYSTORE_FILE=release.keystore
# KEYSTORE_PASSWORD=
# KEY_ALIAS=
# KEY_PASSWORD=
# --- Текст "что нового" в latest.json (опционально, одна строка без кавычек) ---
# NOTES_EN=
# NOTES_RU=
+7
View File
@@ -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
+174
View File
@@ -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'
}
@@ -0,0 +1,204 @@
/*
* Copyright (C) 2026 SHX VPN
*
* This program is free software; you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the
* Free Software Foundation; either version 2 of the License, or (at your
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
*
* This program is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
* for more details.
*/
package org.strongswan.android.logic
import android.os.Bundle
import android.util.Base64
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.assertj.core.api.Assertions.assertThat
import org.json.JSONObject
import org.junit.Test
import org.junit.runner.RunWith
import org.strongswan.android.data.ManagedVpnProfile
import org.strongswan.android.data.VpnProfile
import org.strongswan.android.data.VpnProfile.SelectedAppsHandling
import org.strongswan.android.data.VpnProfileDataSource
import org.strongswan.android.data.VpnType
import java.io.ByteArrayInputStream
import java.security.KeyStore
import java.util.UUID
@RunWith(AndroidJUnit4::class)
class VpnProfileExporterTest
{
private fun sampleProfile(): VpnProfile
{
val profile = VpnProfile()
profile.setUUID(UUID.fromString("123e4567-e89b-12d3-a456-426614174000"))
profile.setName("Shared Profile")
profile.setVpnType(VpnType.IKEV2_EAP)
profile.setGateway("vpn.example.com")
profile.setPort(4500)
profile.setRemoteId(" CN=vpn.example.com")
profile.setLocalId("alice@example.com")
profile.setUsername("alice")
profile.setPassword("secret")
profile.setIkeProposal("aes256-sha2_384-prfsha384-modp2048")
profile.setEspProposal("aes256gcm16-prfsha384-modp2048")
profile.setDnsServers("10.0.0.1 10.0.0.2")
profile.setMTU(1400)
profile.setNATKeepAlive(20)
profile.setIncludedSubnets("10.1.0.0/16")
profile.setExcludedSubnets("10.1.2.0/24")
profile.setSplitTunneling(VpnProfile.SPLIT_TUNNELING_BLOCK_IPV4)
profile.setProxyHost("proxy.example.com")
profile.setProxyPort(8080)
profile.setProxyExclusions("example.org example.net")
profile.setSelectedApps("com.example.app1 com.example.app2")
profile.setSelectedAppsHandling(SelectedAppsHandling.SELECTED_APPS_ONLY)
profile.setFlags(VpnProfile.FLAGS_SUPPRESS_CERT_REQS or VpnProfile.FLAGS_DISABLE_CRL or
VpnProfile.FLAGS_STRICT_REVOCATION or VpnProfile.FLAGS_IPv6_TRANSPORT)
return profile
}
@Test
fun exportRoundTripMatchesImporterFormat()
{
val json = JSONObject(VpnProfileExporter.serializeProfile(sampleProfile(), true).toString())
assertThat(json.getString("uuid")).isEqualTo("123e4567-e89b-12d3-a456-426614174000")
assertThat(json.getString("name")).isEqualTo("Shared Profile")
assertThat(json.getString("type")).isEqualTo("ikev2-eap")
val remote = json.getJSONObject("remote")
assertThat(remote.getString("addr")).isEqualTo("vpn.example.com")
assertThat(remote.getInt("port")).isEqualTo(4500)
assertThat(remote.getString("id")).isEqualTo(" CN=vpn.example.com")
assertThat(remote.getBoolean("certreq")).isFalse()
assertThat(remote.getJSONObject("revocation").getBoolean("crl")).isFalse()
assertThat(remote.getJSONObject("revocation").getBoolean("strict")).isTrue()
val local = json.getJSONObject("local")
assertThat(local.getString("id")).isEqualTo("alice@example.com")
assertThat(local.getString("eap_id")).isEqualTo("alice")
assertThat(local.getString("shared_secret")).isEqualTo("secret")
assertThat(json.getString("ike-proposal")).isEqualTo("aes256-sha2_384-prfsha384-modp2048")
assertThat(json.getString("esp-proposal")).isEqualTo("aes256gcm16-prfsha384-modp2048")
assertThat(json.getJSONArray("dns-servers").length()).isEqualTo(2)
assertThat(json.getInt("mtu")).isEqualTo(1400)
assertThat(json.getInt("nat-keepalive")).isEqualTo(20)
assertThat(json.getBoolean("ipv6-transport")).isTrue()
val proxy = json.getJSONObject("proxy")
assertThat(proxy.getString("host")).isEqualTo("proxy.example.com")
assertThat(proxy.getInt("port")).isEqualTo(8080)
assertThat(proxy.getJSONArray("exclusions").length()).isEqualTo(2)
val split = json.getJSONObject("split-tunneling")
assertThat(split.getJSONArray("subnets").getString(0)).isEqualTo("10.1.0.0/16")
assertThat(split.getJSONArray("excluded").getString(0)).isEqualTo("10.1.2.0/24")
assertThat(split.getBoolean("block-ipv4")).isTrue()
assertThat(split.has("block-ipv6")).isFalse()
assertThat(json.getJSONArray("apps").length()).isEqualTo(2)
assertThat(json.has("excluded-apps")).isFalse()
}
@Test
fun passwordOmittedWhenNotIncluded()
{
val json = JSONObject(VpnProfileExporter.serializeProfile(sampleProfile(), false).toString())
assertThat(json.getJSONObject("local").has("shared_secret")).isFalse()
assertThat(json.getJSONObject("local").getString("eap_id")).isEqualTo("alice")
}
@Test
fun defaultsAreOmitted()
{
val profile = VpnProfile()
profile.setName("Minimal")
profile.setVpnType(VpnType.IKEV2_EAP)
profile.setGateway("vpn.example.com")
val json = JSONObject(VpnProfileExporter.serializeProfile(profile, false).toString())
assertThat(json.has("local")).isFalse()
assertThat(json.has("ike-proposal")).isFalse()
assertThat(json.has("dns-servers")).isFalse()
assertThat(json.has("mtu")).isFalse()
assertThat(json.has("ipv6-transport")).isFalse()
assertThat(json.getJSONObject("remote").has("certreq")).isFalse()
assertThat(json.getJSONObject("remote").has("revocation")).isFalse()
}
@Test
fun managedUserCertificateIsRepackagedWithoutPassword()
{
val local = Bundle()
local.putString(VpnProfileDataSource.KEY_USER_CERTIFICATE, TEST_P12)
local.putString(VpnProfileDataSource.KEY_USER_CERTIFICATE_PASSWORD, "testpassword")
val bundle = Bundle()
bundle.putString(VpnProfileDataSource.KEY_NAME, "Managed")
bundle.putString(VpnProfileDataSource.KEY_VPN_TYPE, "ikev2-cert")
bundle.putBundle("local", local)
val profile = ManagedVpnProfile(bundle, UUID.randomUUID())
val exported = VpnProfileExporter.userCertificateData(profile)
assertThat(exported).isNotNull()
/* the recipient must be able to open the container without knowing a password */
val keyStore = KeyStore.getInstance("PKCS12")
keyStore.load(ByteArrayInputStream(Base64.decode(exported, Base64.DEFAULT)), charArrayOf())
val alias = keyStore.aliases().nextElement()
assertThat(keyStore.getKey(alias, charArrayOf())).isNotNull()
assertThat(keyStore.getCertificateChain(alias)).isNotEmpty()
val cert = keyStore.getCertificate(alias) as java.security.cert.X509Certificate
assertThat(cert.getSubjectX500Principal().name).contains("Test User")
}
companion object
{
/** PKCS#12 container with a private key for CN=Test User, password: testpassword */
private val TEST_P12 = """ MIIKOAIBAzCCCe4GCSqGSIb3DQEHAaCCCd8EggnbMIIJ1zCCBCoGCSqGSIb3DQEHBqCCBBswggQXAgEAMIIEEAYJKoZIhvcN
AQcBMF8GCSqGSIb3DQEFDTBSMDEGCSqGSIb3DQEFDDAkBBDba1sYbH9bqoGmf+8dPAqZAgIIADAMBggqhkiG9w0CCQUAMB0G
CWCGSAFlAwQBKgQQ6xNPeUT6JH4tibm14NRyZ4CCA6AyC5vk/wmALhgKx3M2jRlOdSXaGp6iLaIt1a6mDAlpULXik9oxmWmh
4m5Q5UTq9nfJBTJkmSv0MKhMOpP8WKijEH92UWqrI+P6VRpDDMM110tySeE3Qv4al/5+RJSVQFyJktSK+2bA4aFlMI+WxZBQ
Ki0l+mPMSEhDkUTVez/UBcx8rYPCFq9Di42VT2DbVfebcvmAfWJwjlIWMixGiWrIpS7U/m5v0RSUQyDSNYnp1b49eDePGLUF
90yEeOxiVjPEaRRPzeaEbBy569ig2BltZDBbCR0Ju+FUSWsyDD7OV/k9ZcmkRT4e2Hp7r9De2UiYS3sD+dGVPTTVVBEMewiv
ujsccRUx83PRlbhQFMQh6WscsBzhPb6K5qgeYQ0xUZLHMfM6657xws2UyhPFnrGfeokZg3r3SihB5XueZV95fVFtDHC0D8gD
tbdnc404XNx+xhB7uf33UZ5KUde7TC068VSXovUuxzgGFa/ZmbICtpumsr+na/+/I09zLttjnZXnr2islywjCwB9pbuzTG2y
N5ZhgsELiNph5C/8sjIxqLNQkzvD1M9K9u5YiaxaUU/ZHxIs9F6QJYa+JqGiafuBcUZuc9kg7oNm94vD69i6Jhg10LKc7My5
B5lU9SPJ/kFjKrmk4dhDYCiTRCI/zOuanmPERdFlLR+SMJe/ACRUFj+O0WT4Sd5eE1byHxzu7Rxtuoc5GAPdQHPzKoDOj5Bc
3h8yH5tTyZEUjy6Q2Svqrn/hxxduYx6zB0IATKiMXp2ADsuM1K35OWLK0WyyGC5C/ONcp84MM6vYMNecvcC8kPyuBsmegzbC
HiScBvVlqsWZUzFIdQ+aIw1c2IY0LHuWksd7QYIJAWDLK3eMk3oNeFO/ao0JLE4N9gUQKLTyMa+sxytc0C9OfJb7PB/kssYW
FC9KmmitkKU+JlM+i0uD9hXdHPThmyO8bPXT2EKM6zB0AWmcAPmPUMedzWREOmuldQNa1OQrIsZjyTOMDIy+l0UPQiFc6Q6e
SdhHlXmzr/BSwC3u/oDfzZRye6crC9qFlLg4SLFSisxF6NC+YzlfVnpXEftc3lTAJpf2UQrgxFPaawgbGK0xGGuesBzmtLFs
J57YD7h9vvqdD4VRCQrWn5Fy3/2rQVCHYZseLGyygWl0RR8C1TcWq/aWlycLIzNQmiGmYSrihA2u9v/2aVr1SyKCJicxBH3l
Xfz4adrs6aeAGC0I6G73zJ+skxud1Y6AMIIFpQYJKoZIhvcNAQcBoIIFlgSCBZIwggWOMIIFigYLKoZIhvcNAQwKAQKgggU5
MIIFNTBfBgkqhkiG9w0BBQ0wUjAxBgkqhkiG9w0BBQwwJAQQOc129Uu2Ph/kfFdWKDKL+gICCAAwDAYIKoZIhvcNAgkFADAd
BglghkgBZQMEASoEEOpkCTykee2dSnZ0xkWndckEggTQD5YCqwB/3T86lOpmpb+gsKBMp6s48JkxiPhg4FO15aIHfqyg8Get
KkxZCfeNzNszTbT5BApyRvbNedy9EPWluWvdnyYTDjKZvYDw5OvM21k1yX+pScXj58kBx2AMk3/XwFIeFOTx2WXt6mUy4GR7
3pA+Zxe9pWO2SB9Zzn3VrCgj6B8o70fEHRJbt8kYR5uX7S5GVFhwgQbcFc1bmn1o/cDGqKS8U4D+LDR3++m4iQUHblz4r6Vm
7hkknc2qIW116jtO3MYXQ9s+O8uiOlKbRnmQSuFjH4p0KzKl5i2uolj1rB5xHJwrJE4ajv6ubovEU/sUUjlnHScEF2S9qaJ3
+sMlYgQMcvduaGDFx79leD0DdPxONZa8WmxnY7pwYdO7xFHloPfL/bSXnsSTX3cxHVyFj2r1/+MNhYM/BQG82/UfM5/s/1q+
SmZHujO+vpDg1H9lKvIiFv992+oYGuiFpgjrTi3KGe2cMlY3r/LmFJnJkTjfTaTBRQ0UOOcC3+9/neN4dZ6Dhl51wM3ayRtS
puhl5eNG3c/05VbjLaz98I6j8WlpEVHYxMyYXqeWchHPaGMlPeKWEZTZYqly1dodLthvZyCW6oItEFVSCJZtDl0bqYPm/CWv
A2/1H8jwawzUN8KPiDIQScpK3qVR5c1pvol7uQ8P4yk6sIDqRoS27b2pS2vQwJNWIc93daCzk7MpJ/BJNTENhn2hxvlgksSZ
RI/pWJVKe/vtPqDiW1Exe5N3lJ/Pk9fTQ3cCzVUP2S25DCNhdtHmlKGR37y+j/JeAAWXNs0GH0oq48FkWIEWj7gwynxsBlME
xS3O2I1ZsDkzcaJh66tZ0Vyle87VpgZ6wA0NKqbwHdGQlAyVZ944O1jktQH9Do5qvfkkbcOM/yQE31fCTLIkT9hGXXVBG7Vy
071cp5efGkU1bBfAHBmhNP7E3VDogHUbciwRSpw4O8GXtPtP3RMbEUpoqHaX4KRvyK0XKN6ZNKl8lVaDzU8g6TL313H1Z3yJ
LPa0aedC2E4MJiQTVwbinSXhUdsmhVrFP0MfbtssdSLKkk0hNkzqsUExcaoxh51uR2pw5tb1w6x279C7dAV/+GibTeY2yngE
+ikKRIa1YH3KvSZtFskLmXf2kphk1IRZHzQZauTgIwJnY7XFJWdus3HKdqr/XaqrchLfmMXYj/ZG7bS5ssoO7g9JPMD/5ElB
Ur4Gz/1qE88NWBvqt6+guMAB5YvrsaOxxSLYZj6dyeO1bQfxMecuo3txI0lwJWpbkTHOu0rLOtde7OzgPo9qfRPur+rY5JqC
tYOyCzfFFcwILWC92eiGO+N2XUe6k75qVSWpROSwkT/lZFmz6AmeBena8obtOnV6yTWapEPXkGMKP9Cm6rXEVgvZeUh886Bd
5Am/mfAe0cT8hxoutrDXD649xRB4JNq1/jff8jik/kjgpEzk62rz6ix42JxB9gYljfdtekvi5/JfF7ODvij4xO6OzycrH0I/
742vk4swnVwKRQfhCQuaklNrTmpqWsefprLy54EArTjSNEHspZJD/BGNJe8q+ntECKMBqPzTgptuxb9JSWsTnJzYFP6kxYLf
NxQpWPVQz51SkmVzpjRgp/RE3FLmBtponAST69xIQuXz+xinc1asWYgxPjAXBgkqhkiG9w0BCRQxCh4IAHQAZQBzAHQwIwYJ
KoZIhvcNAQkVMRYEFNSUf6nnO3vpRNk70pJS3R58gg9NMEEwMTANBglghkgBZQMEAgEFAAQgL2BlHv8N4AAEaEOTRpzFamcY
l/5PNjlWjlgV252DMCoECGgcyjRqux6dAgIIAA=="""
}
}
@@ -0,0 +1,87 @@
/*
* Copyright (C) 2026 SHX VPN
*
* This program is free software; you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the
* Free Software Foundation; either version 2 of the License, or (at your
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
*
* This program is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
* for more details.
*/
package org.strongswan.android.ui.compose
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithText
import org.junit.Rule
import org.junit.Test
import org.strongswan.android.R
import org.strongswan.android.ui.compose.theme.ShxTheme
class ConnectivityCheckScreenTest
{
@get:Rule
val composeRule = createComposeRule()
private fun string(id: Int): String =
androidx.test.platform.app.InstrumentationRegistry.getInstrumentation().targetContext.getString(id)
private fun setContent()
{
composeRule.setContent {
ShxTheme {
ConnectivityCheckScreen()
}
}
composeRule.waitForIdle()
}
@Test
fun showsSectionHeaders()
{
setContent()
composeRule.onNodeWithText(
string(R.string.health_check_targets_title)
).assertIsDisplayed()
composeRule.onNodeWithText(
string(R.string.health_check_server_down_title)
).assertIsDisplayed()
composeRule.onNodeWithText(
string(R.string.health_check_tunnel_dead_title)
).assertIsDisplayed()
}
@Test
fun showsMasterToggle()
{
setContent()
composeRule.onNodeWithText(
string(R.string.health_check_title)
).assertIsDisplayed()
}
@Test
fun showsAggressiveModeToggle()
{
setContent()
composeRule.onNodeWithText(
string(R.string.health_check_aggressive_title)
).assertIsDisplayed()
}
@Test
fun showsTargetToggles()
{
setContent()
composeRule.onNodeWithText(
string(R.string.probe_target_google)
).assertIsDisplayed()
composeRule.onNodeWithText(
string(R.string.probe_target_cloudflare)
).assertIsDisplayed()
}
}
@@ -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>
@@ -0,0 +1,190 @@
/*
* Copyright (C) 2012 Tobias Brunner
*
* Copyright (C) secunet Security Networks AG
*
* This program is free software; you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the
* Free Software Foundation; either version 2 of the License, or (at your
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
*
* This program is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
* for more details.
*/
package org.strongswan.android.data;
import java.io.File;
import java.io.FileNotFoundException;
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.ConcurrentHashMap;
import org.strongswan.android.logic.CharonVpnService;
import android.content.ContentProvider;
import android.content.ContentValues;
import android.database.Cursor;
import android.database.MatrixCursor;
import android.net.Uri;
import android.os.ParcelFileDescriptor;
import android.os.SystemClock;
import android.provider.OpenableColumns;
public class LogContentProvider extends ContentProvider
{
private static final String AUTHORITY = "one.shx.strongswan.ext.content.log";
/* an Uri is valid for 30 minutes */
private static final long URI_VALIDITY = 30 * 60 * 1000;
private static ConcurrentHashMap<Uri, Long> mUris = new ConcurrentHashMap<Uri, Long>();
/* file served by each Uri (defaults to the charon log for legacy Uris) */
private static ConcurrentHashMap<Uri, File> mFiles = new ConcurrentHashMap<Uri, File>();
private File mLogFile;
public LogContentProvider()
{
}
@Override
public boolean onCreate()
{
mLogFile = new File(getContext().getFilesDir(), CharonVpnService.LOG_FILE);
return true;
}
/**
* The log file can only be accessed by Uris created with this method
* @return null if failed to create the Uri
*/
public static Uri createContentUri()
{
return createContentUri(null);
}
/**
* The log file can only be accessed by Uris created with this method
* @param file file to serve, the charon log if null
* @return null if failed to create the Uri
*/
public static Uri createContentUri(File file)
{
SecureRandom random;
try
{
random = SecureRandom.getInstance("SHA1PRNG");
}
catch (NoSuchAlgorithmException e)
{
return null;
}
Uri uri = Uri.parse("content://" + AUTHORITY + "/" + random.nextLong());
mUris.put(uri, SystemClock.uptimeMillis());
if (file != null)
{ /* ConcurrentHashMap does not accept null values; fileFor() falls
* back to the charon log for legacy Uris */
mFiles.put(uri, file);
}
return uri;
}
@Override
public String getType(Uri uri)
{
/* MIME type for our log file */
return "text/plain";
}
@Override
public Cursor query(Uri uri, String[] projection, String selection,
String[] selectionArgs, String sortOrder)
{
/* this is called by apps to find out the name and size of the file.
* since we only provide a single file per Uri this is simple to implement */
if (projection == null)
{
projection = new String[]{ OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE };
}
File file = fileFor(uri);
if (file == null)
{ /* don't check the validity as this information is not really private */
return null;
}
List<String> cols = new ArrayList<>();
List<Object> vals = new ArrayList<>();
for (String col : projection)
{
if (OpenableColumns.DISPLAY_NAME.equals(col))
{
cols.add(OpenableColumns.DISPLAY_NAME);
vals.add(file.getName());
}
else if (OpenableColumns.SIZE.equals(col))
{
cols.add(OpenableColumns.SIZE);
vals.add(file.length());
}
}
MatrixCursor cursor = new MatrixCursor(cols.toArray(new String[0]), 1);
cursor.addRow(vals.toArray());
return cursor;
}
@Override
public ParcelFileDescriptor openFile(Uri uri, String mode) throws FileNotFoundException
{
File file = fileFor(uri);
if (file != null)
{
return ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_CREATE | ParcelFileDescriptor.MODE_READ_ONLY);
}
return super.openFile(uri, mode);
}
/**
* Get the file served by the given Uri, or null if the Uri is invalid or expired.
*/
private File fileFor(Uri uri)
{
Long timestamp = mUris.get(uri);
if (timestamp == null)
{
return null;
}
long elapsed = SystemClock.uptimeMillis() - timestamp;
if (elapsed > 0 && elapsed < URI_VALIDITY)
{ /* we fail if clock wrapped, should happen rarely though */
File file = mFiles.get(uri);
return file != null ? file : mLogFile;
}
mUris.remove(uri);
mFiles.remove(uri);
return null;
}
@Override
public Uri insert(Uri uri, ContentValues values)
{
/* not supported */
return null;
}
@Override
public int delete(Uri uri, String selection, String[] selectionArgs)
{
/* not supported */
return 0;
}
@Override
public int update(Uri uri, ContentValues values, String selection,
String[] selectionArgs)
{
/* not supported */
return 0;
}
}
@@ -47,7 +47,7 @@ public class ManagedVpnProfile extends VpnProfile
private ManagedTrustedCertificate trustedCertificate;
private ManagedUserCertificate userCertificate;
ManagedVpnProfile(final Bundle bundle, final UUID uuid)
public ManagedVpnProfile(final Bundle bundle, final UUID uuid)
{
int flags = 0;
int splitFlags = 0;
@@ -0,0 +1,194 @@
/*
* Copyright (C) 2026 SHX VPN
*
* This program is free software; you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the
* Free Software Foundation; either version 2 of the License, or (at your
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
*
* This program is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
* for more details.
*/
package org.strongswan.android.data;
import java.io.File;
import java.io.FileNotFoundException;
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.ConcurrentHashMap;
import android.content.ContentProvider;
import android.content.ContentValues;
import android.content.Context;
import android.database.Cursor;
import android.database.MatrixCursor;
import android.net.Uri;
import android.os.ParcelFileDescriptor;
import android.os.SystemClock;
import android.provider.OpenableColumns;
/**
* Serves exported VPN profile files to other apps. Instead of grantUriPermissions
* (which does not work reliably with ACTION_SEND) this provider is exported but only
* serves files through randomly generated ticket Uris created with createContentUri().
*/
public class ProfileExportProvider extends ContentProvider
{
public static final String MIME_TYPE = "application/vnd.strongswan.profile";
private static final String AUTHORITY = "one.shx.strongswan.ext.content.export";
/* an Uri is valid for 30 minutes */
private static final long URI_VALIDITY = 30 * 60 * 1000;
private static class Ticket
{
final File mFile;
final String mDisplayName;
final long mCreatedAt;
Ticket(File file, String displayName, long createdAt)
{
mFile = file;
mDisplayName = displayName;
mCreatedAt = createdAt;
}
}
private static final ConcurrentHashMap<Uri, Ticket> mTickets = new ConcurrentHashMap<Uri, Ticket>();
public ProfileExportProvider()
{
}
@Override
public boolean onCreate()
{
return true;
}
/**
* The given file can only be accessed by Uris created with this method
* @return null if failed to create the Uri
*/
public static Uri createContentUri(Context context, File file, String displayName)
{
SecureRandom random;
try
{
random = SecureRandom.getInstance("SHA1PRNG");
}
catch (NoSuchAlgorithmException e)
{
return null;
}
Uri uri = Uri.parse("content://" + AUTHORITY + "/" + random.nextLong());
Ticket ticket = new Ticket(exportFile(context, file.getName()), displayName, SystemClock.uptimeMillis());
mTickets.put(uri, ticket);
return uri;
}
/**
* Directory exported profile files are stored in (also used by the exporter).
*/
public static File exportDirectory(Context context)
{
File dir = new File(context.getCacheDir(), "export");
if (!dir.exists())
{
dir.mkdirs();
}
return dir;
}
/**
* The file the exported profile is stored in (resolves against the export directory).
*/
public static File exportFile(Context context, String fileName)
{
return new File(exportDirectory(context), fileName);
}
@Override
public String getType(Uri uri)
{
return MIME_TYPE;
}
@Override
public Cursor query(Uri uri, String[] projection, String selection,
String[] selectionArgs, String sortOrder)
{
/* this is called by apps to find out the name and size of the file */
if (projection == null)
{
projection = new String[]{ OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE };
}
Ticket ticket = mTickets.get(uri);
if (ticket == null)
{
return null;
}
List<String> cols = new ArrayList<>();
List<Object> vals = new ArrayList<>();
for (String col : projection)
{
if (OpenableColumns.DISPLAY_NAME.equals(col))
{
cols.add(OpenableColumns.DISPLAY_NAME);
vals.add(ticket.mDisplayName);
}
else if (OpenableColumns.SIZE.equals(col))
{
cols.add(OpenableColumns.SIZE);
vals.add(ticket.mFile.length());
}
}
MatrixCursor cursor = new MatrixCursor(cols.toArray(new String[0]), 1);
cursor.addRow(vals.toArray());
return cursor;
}
@Override
public ParcelFileDescriptor openFile(Uri uri, String mode) throws FileNotFoundException
{
Ticket ticket = mTickets.get(uri);
if (ticket != null)
{
long elapsed = SystemClock.uptimeMillis() - ticket.mCreatedAt;
if (elapsed >= 0 && elapsed < URI_VALIDITY && ticket.mFile.exists())
{
return ParcelFileDescriptor.open(ticket.mFile, ParcelFileDescriptor.MODE_READ_ONLY);
}
mTickets.remove(uri);
}
return super.openFile(uri, mode);
}
@Override
public Uri insert(Uri uri, ContentValues values)
{
/* not supported */
return null;
}
@Override
public int delete(Uri uri, String selection, String[] selectionArgs)
{
/* not supported */
return 0;
}
@Override
public int update(Uri uri, ContentValues values, String selection,
String[] selectionArgs)
{
/* not supported */
return 0;
}
}
@@ -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)
}
@@ -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");
}
}
@@ -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()
@@ -0,0 +1,423 @@
/*
* Copyright (C) 2026 SHX VPN
*
* This program is free software; you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the
* Free Software Foundation; either version 2 of the License, or (at your
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
*
* This program is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
* for more details.
*/
package org.strongswan.android.logic;
import android.content.Context;
import android.net.Uri;
import android.text.TextUtils;
import android.util.Base64;
import org.json.JSONArray;
import org.json.JSONObject;
import org.strongswan.android.data.ManagedCertificate;
import org.strongswan.android.data.ManagedUserCertificate;
import org.strongswan.android.data.ManagedVpnProfile;
import org.strongswan.android.data.ProfileExportProvider;
import org.strongswan.android.data.VpnProfile;
import org.strongswan.android.data.VpnProfile.SelectedAppsHandling;
import org.strongswan.android.data.VpnType;
import org.strongswan.android.data.VpnType.VpnTypeFeature;
import java.io.File;
import java.io.FileWriter;
import java.io.IOException;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.security.Key;
import java.security.KeyStore;
import java.security.cert.Certificate;
import java.security.cert.X509Certificate;
import java.util.Enumeration;
/**
* Exports a VpnProfile as a VPN profile file in the format used by the original
* strongSwan app (.sswan), with the certificates embedded, so it can be shared
* and imported by any strongSwan-compatible app.
*/
public class VpnProfileExporter
{
/** File name extension used by the original strongSwan app. */
public static final String FILE_EXTENSION = ".sswan";
private VpnProfileExporter()
{
}
/**
* Serialize the given profile to the cache directory and create a shareable
* content Uri for it.
*
* @param includePassword whether a stored password is written to the file (as cleartext)
* @return the content Uri, or null if the profile could not be exported
*/
public static Uri exportProfile(Context context, VpnProfile profile, boolean includePassword)
{
try
{
JSONObject obj = serializeProfile(profile, includePassword);
File file = writeFile(context, profile.getUUID().toString(), obj.toString(2));
return ProfileExportProvider.createContentUri(context, file, displayName(profile.getName()));
}
catch (Exception e)
{
e.printStackTrace();
return null;
}
}
static JSONObject serializeProfile(VpnProfile profile, boolean includePassword) throws IOException
{
if (TextUtils.isEmpty(profile.getGateway()))
{
throw new IOException("profile has no gateway");
}
JSONObject obj = new JSONObject();
try
{
obj.put("uuid", profile.getUUID().toString());
obj.put("name", profile.getName());
obj.put("type", profile.getVpnType().getIdentifier());
obj.put("remote", buildRemote(profile));
JSONObject local = buildLocal(profile, includePassword);
if (local.length() > 0)
{
obj.put("local", local);
}
obj.putOpt("ike-proposal", profile.getIkeProposal());
obj.putOpt("esp-proposal", profile.getEspProposal());
JSONArray dns = toArray(profile.getDnsServers());
if (dns.length() > 0)
{
obj.put("dns-servers", dns);
}
obj.putOpt("mtu", profile.getMTU());
obj.putOpt("nat-keepalive", profile.getNATKeepAlive());
if (hasFlag(profile, VpnProfile.FLAGS_IPv6_TRANSPORT))
{
obj.put("ipv6-transport", true);
}
JSONObject proxy = buildProxy(profile);
if (proxy.length() > 0)
{
obj.put("proxy", proxy);
}
JSONObject split = buildSplitTunneling(profile);
if (split.length() > 0)
{
obj.put("split-tunneling", split);
}
if (profile.getSelectedAppsHandling() == SelectedAppsHandling.SELECTED_APPS_ONLY)
{
JSONArray apps = toArray(profile.getSelectedApps());
if (apps.length() > 0)
{
obj.put("apps", apps);
}
}
else if (profile.getSelectedAppsHandling() == SelectedAppsHandling.SELECTED_APPS_EXCLUDE)
{
JSONArray apps = toArray(profile.getSelectedApps());
if (apps.length() > 0)
{
obj.put("excluded-apps", apps);
}
}
return obj;
}
catch (IOException e)
{
throw e;
}
catch (Exception e)
{
IOException wrapped = new IOException("failed to serialize profile");
wrapped.initCause(e);
throw wrapped;
}
}
private static JSONObject buildRemote(VpnProfile profile) throws Exception
{
JSONObject remote = new JSONObject();
remote.put("addr", profile.getGateway());
remote.putOpt("port", profile.getPort());
remote.putOpt("id", profile.getRemoteId());
String cert = trustedCertificateData(profile);
if (!TextUtils.isEmpty(cert))
{
remote.put("cert", cert);
}
if (hasFlag(profile, VpnProfile.FLAGS_SUPPRESS_CERT_REQS))
{
remote.put("certreq", false);
}
JSONObject revocation = new JSONObject();
if (hasFlag(profile, VpnProfile.FLAGS_DISABLE_CRL))
{
revocation.put("crl", false);
}
if (hasFlag(profile, VpnProfile.FLAGS_DISABLE_OCSP))
{
revocation.put("ocsp", false);
}
if (hasFlag(profile, VpnProfile.FLAGS_STRICT_REVOCATION))
{
revocation.put("strict", true);
}
if (revocation.length() > 0)
{
remote.put("revocation", revocation);
}
return remote;
}
private static JSONObject buildLocal(VpnProfile profile, boolean includePassword) throws Exception
{
JSONObject local = new JSONObject();
local.putOpt("id", profile.getLocalId());
VpnType type = profile.getVpnType();
if (type.has(VpnTypeFeature.USER_PASS))
{
local.putOpt("eap_id", profile.getUsername());
if (includePassword)
{
local.putOpt("shared_secret", profile.getPassword());
}
}
if (type.has(VpnTypeFeature.CERTIFICATE))
{
String p12 = userCertificateData(profile);
if (!TextUtils.isEmpty(p12))
{
local.put("p12", p12);
}
if (hasFlag(profile, VpnProfile.FLAGS_RSA_PSS))
{
local.put("rsa-pss", true);
}
}
return local;
}
private static JSONObject buildProxy(VpnProfile profile) throws Exception
{
JSONObject proxy = new JSONObject();
if (!TextUtils.isEmpty(profile.getProxyHost()))
{
proxy.put("host", profile.getProxyHost());
proxy.putOpt("port", profile.getProxyPort());
JSONArray exclusions = toArray(profile.getProxyExclusions());
if (exclusions.length() > 0)
{
proxy.put("exclusions", exclusions);
}
}
return proxy;
}
private static JSONObject buildSplitTunneling(VpnProfile profile) throws Exception
{
JSONObject split = new JSONObject();
JSONArray included = toArray(profile.getIncludedSubnets());
if (included.length() > 0)
{
split.put("subnets", included);
}
JSONArray excluded = toArray(profile.getExcludedSubnets());
if (excluded.length() > 0)
{
split.put("excluded", excluded);
}
Integer st = profile.getSplitTunneling();
if (st != null)
{
if ((st & VpnProfile.SPLIT_TUNNELING_BLOCK_IPV4) != 0)
{
split.put("block-ipv4", true);
}
if ((st & VpnProfile.SPLIT_TUNNELING_BLOCK_IPV6) != 0)
{
split.put("block-ipv6", true);
}
}
return split;
}
/**
* Data of the CA/server certificate as Base64-encoded DER, or null if unavailable.
*/
private static String trustedCertificateData(VpnProfile profile)
{
if (profile instanceof ManagedVpnProfile)
{ /* the raw certificate is stored with the profile */
ManagedCertificate cert = ((ManagedVpnProfile)profile).getTrustedCertificate();
return cert != null ? cert.getData() : null;
}
String alias = profile.getCertificateAlias();
if (TextUtils.isEmpty(alias))
{
return null;
}
try
{ /* resolves from the app-local certificate store and all system stores */
X509Certificate certificate = TrustedCertificateManager.getInstance().getCACertificateFromAlias(alias);
if (certificate == null)
{
return null;
}
return Base64.encodeToString(certificate.getEncoded(), Base64.NO_WRAP);
}
catch (Exception e)
{
e.printStackTrace();
return null;
}
}
/**
* Data of the user certificate as Base64-encoded PKCS#12, or null if not exportable.
* Only certificates stored by the app itself (managed profiles) can be exported;
* keys installed in the system KeyChain are non-exportable by design.
*/
static String userCertificateData(VpnProfile profile)
{
if (!(profile instanceof ManagedVpnProfile))
{
return null;
}
ManagedUserCertificate cert = ((ManagedVpnProfile)profile).getUserCertificate();
if (cert == null)
{
return null;
}
String repacked = repackageWithoutPassword(cert);
return !TextUtils.isEmpty(repacked) ? repacked : null;
}
/**
* The profile file format has no field for the PKCS#12 password, so the container
* is rewritten with an empty password. The full certificate chain is preserved.
*/
private static String repackageWithoutPassword(ManagedUserCertificate cert)
{
try
{
char[] password = cert.getPrivateKeyPassword() == null ? new char[0]
: cert.getPrivateKeyPassword().toCharArray();
KeyStore in = KeyStore.getInstance("PKCS12");
in.load(new ByteArrayInputStream(Base64.decode(cert.getData(), Base64.DEFAULT)), password);
KeyStore out = KeyStore.getInstance("PKCS12");
out.load(null, null);
for (Enumeration<String> aliases = in.aliases(); aliases.hasMoreElements(); )
{
String alias = aliases.nextElement();
Key key = in.getKey(alias, password);
Certificate[] chain = in.getCertificateChain(alias);
if (key != null)
{
out.setKeyEntry(alias, key, null, chain != null ? chain : new Certificate[0]);
}
else
{
Certificate certificate = in.getCertificate(alias);
if (certificate != null)
{
out.setCertificateEntry(alias, certificate);
}
}
}
ByteArrayOutputStream outStream = new ByteArrayOutputStream();
out.store(outStream, null); /* empty password */
return Base64.encodeToString(outStream.toByteArray(), Base64.NO_WRAP);
}
catch (Exception e)
{
e.printStackTrace();
return null;
}
}
private static boolean hasFlag(VpnProfile profile, int flag)
{
Integer flags = profile.getFlags();
return flags != null && (flags & flag) != 0;
}
/**
* Convert a space-separated string to a JSON array (inverse of what the
* importer joins with spaces).
*/
private static JSONArray toArray(String value)
{
JSONArray array = new JSONArray();
if (!TextUtils.isEmpty(value))
{
for (String item : value.trim().split("\\s+"))
{
if (!TextUtils.isEmpty(item))
{
array.put(item);
}
}
}
return array;
}
private static File writeFile(Context context, String uuid, String data) throws IOException
{
File dir = ProfileExportProvider.exportDirectory(context);
/* drop exports of other profiles so the cache does not grow indefinitely */
File[] files = dir.listFiles();
if (files != null)
{
for (File file : files)
{
file.delete();
}
}
File file = ProfileExportProvider.exportFile(context, uuid + FILE_EXTENSION);
try (FileWriter writer = new FileWriter(file))
{
writer.write(data);
}
return file;
}
/**
* File name offered to the receiving app, derived from the profile name.
*/
private static String displayName(String name)
{
String sanitized = name == null ? "" : name.replaceAll("[\\\\/:*?\"<>|\\p{Cntrl}]", "_").trim();
sanitized = sanitized.replaceAll("^[. ]+|[. ]+$", "");
if (TextUtils.isEmpty(sanitized))
{
sanitized = "profile";
}
if (sanitized.length() > 100)
{
sanitized = sanitized.substring(0, 100);
}
return sanitized + FILE_EXTENSION;
}
}
@@ -19,6 +19,7 @@ package org.strongswan.android.logic;
import android.app.Service;
import android.content.Context;
import android.content.Intent;
import android.content.SharedPreferences;
import android.os.Binder;
import android.os.Bundle;
import android.os.Handler;
@@ -26,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);
}
}
}
@@ -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);
}
}
@@ -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
}
}
@@ -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)
}
@@ -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
}
}
@@ -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 }
}
}
}
@@ -0,0 +1,88 @@
/*
* Copyright (C) 2026 SHX VPN
*
* This program is free software; you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the
* Free Software Foundation; either version 2 of the License, or (at your
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
*
* This program is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
* for more details.
*/
package org.strongswan.android.logic.connectivity
import android.content.Context
import android.net.ConnectivityManager
import android.net.Network
import android.net.NetworkCapabilities
/**
* Finds the underlying (non-VPN) network so the VPN server can be reached
* directly, outside the tunnel. Wi-Fi is preferred over cellular, mirroring
* [org.strongswan.android.logic.autoconnect.NetworkMonitor].
*/
object UnderlyingNetwork
{
fun resolve(context: Context): Network?
{
val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager ?: return null
var wifi: Network? = null
var cellular: Network? = null
var validatedWifi: Network? = null
var validatedCellular: Network? = null
for (network in cm.allNetworks)
{
val caps = try
{
cm.getNetworkCapabilities(network)
}
catch (_: Exception)
{
null
} ?: continue
if (caps.hasTransport(NetworkCapabilities.TRANSPORT_VPN))
{
continue
}
/* captive portals answer connects for anything, so an unvalidated
* network would probe as "everything reachable" and hide blocking */
val validated = caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)
if (caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI))
{
wifi = network
if (validated)
{
validatedWifi = network
}
}
else if (caps.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR))
{
cellular = network
if (validated)
{
validatedCellular = network
}
}
}
return validatedWifi ?: validatedCellular ?: wifi ?: cellular
}
/**
* Resolves a hostname through the given network so the VPN tunnel's DNS
* is never used for the direct server probe.
*/
fun resolveHost(network: Network?, host: String): List<java.net.InetAddress>?
{
return try
{
if (network != null) network.getAllByName(host).toList() else java.net.InetAddress.getAllByName(host).toList()
}
catch (_: Exception)
{
null
}
}
}
@@ -0,0 +1,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 }
}
}
}
@@ -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()
}
}
}
@@ -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 }
}
}
}
@@ -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(),
)
@@ -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 }
}
}
}
@@ -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()
}
}
}
@@ -0,0 +1,96 @@
/*
* Copyright (C) 2026 SHX VPN
*
* This program is free software; you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the
* Free Software Foundation; either version 2 of the License, or (at your
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
*
* This program is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
* for more details.
*/
package org.strongswan.android.logic.updates
import java.net.URL
import java.security.MessageDigest
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.TimeZone
import javax.crypto.Mac
import javax.crypto.spec.SecretKeySpec
/**
* Minimal AWS Signature Version 4 signer for authenticated GET requests to an
* S3-compatible storage (no request body, no query parameters).
*/
object S3Signer
{
private const val ALGORITHM = "AWS4-HMAC-SHA256"
private val EMPTY_BODY_SHA256 = sha256Hex(ByteArray(0))
/**
* Computes the headers that must be set on the request:
* x-amz-date, x-amz-content-sha256 and Authorization
*/
fun signGet(url: URL, region: String, accessKey: String, secretKey: String, nowMillis: Long): Map<String, String>
{
val timestamp = SimpleDateFormat("yyyyMMdd'T'HHmmss'Z'", Locale.US).apply {
timeZone = TimeZone.getTimeZone("UTC")
}.format(Date(nowMillis))
val dateStamp = timestamp.substring(0, 8)
val host = url.host.let { if (url.port in -1..0 || url.port == 443) it else "$it:${url.port}" }
val path = url.path?.ifEmpty { "/" } ?: "/"
val canonicalHeaders = "host:$host\n" +
"x-amz-content-sha256:$EMPTY_BODY_SHA256\n" +
"x-amz-date:$timestamp\n"
val signedHeaders = "host;x-amz-content-sha256;x-amz-date"
val canonicalRequest = "GET\n$path\n\n$canonicalHeaders\n$signedHeaders\n$EMPTY_BODY_SHA256"
val scope = "$dateStamp/$region/s3/aws4_request"
val stringToSign = "$ALGORITHM\n$timestamp\n$scope\n${sha256Hex(canonicalRequest.toByteArray())}"
val signingKey = deriveSigningKey(secretKey, dateStamp, region, "s3")
val signature = hex(hmacSha256(signingKey, stringToSign.toByteArray()))
return mapOf(
"x-amz-date" to timestamp,
"x-amz-content-sha256" to EMPTY_BODY_SHA256,
"Authorization" to "$ALGORITHM Credential=$accessKey/$scope, SignedHeaders=$signedHeaders, Signature=$signature",
)
}
/**
* The HMAC-SHA256 key chain described by AWS:
* kDate -> kRegion -> kService -> kSigning
*/
fun deriveSigningKey(secretKey: String, dateStamp: String, region: String, service: String): ByteArray
{
var key = hmacSha256(("AWS4$secretKey").toByteArray(), dateStamp.toByteArray())
key = hmacSha256(key, region.toByteArray())
key = hmacSha256(key, service.toByteArray())
return hmacSha256(key, "aws4_request".toByteArray())
}
internal fun hmacSha256(key: ByteArray, data: ByteArray): ByteArray
{
val mac = Mac.getInstance("HmacSHA256")
mac.init(SecretKeySpec(key, "HmacSHA256"))
return mac.doFinal(data)
}
private fun sha256Hex(data: ByteArray): String
{
return hex(MessageDigest.getInstance("SHA-256").digest(data))
}
internal fun hex(bytes: ByteArray): String
{
return bytes.joinToString("") { "%02x".format(it) }
}
}
@@ -0,0 +1,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"
}
}
@@ -0,0 +1,74 @@
/*
* Copyright (C) 2026 SHX VPN
*
* This program is free software; you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the
* Free Software Foundation; either version 2 of the License, or (at your
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
*
* This program is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
* for more details.
*/
package org.strongswan.android.logic.updates
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.pm.PackageInstaller
import android.widget.Toast
import androidx.core.content.IntentCompat
import org.strongswan.android.R
/**
* Receives the result of the self-update installation from PackageInstaller,
* shows the system confirmation dialog when required and notifies the user
* about the outcome
*/
class UpdateInstallStatusReceiver : BroadcastReceiver()
{
override fun onReceive(context: Context, intent: Intent)
{
val status = intent.getIntExtra(PackageInstaller.EXTRA_STATUS, PackageInstaller.STATUS_FAILURE)
when (status)
{
PackageInstaller.STATUS_PENDING_USER_ACTION ->
{
/* the system requires the user to confirm the installation */
android.util.Log.w("UpdateInstall", "System required user action, the silent install was overridden")
val confirmation = IntentCompat.getParcelableExtra(intent, Intent.EXTRA_INTENT, Intent::class.java)
if (confirmation != null)
{
confirmation.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
try
{
context.startActivity(confirmation)
}
catch (e: Exception)
{
Toast.makeText(context,
context.getString(R.string.update_install_failed_fmt, ""),
Toast.LENGTH_LONG).show()
}
}
}
PackageInstaller.STATUS_SUCCESS ->
{
Toast.makeText(context, R.string.update_install_success, Toast.LENGTH_LONG).show()
UpdateManager.getInstance(context).onInstallFinished()
}
else ->
{
val message = intent.getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE)
Toast.makeText(
context,
context.getString(R.string.update_install_failed_fmt, message ?: ""),
Toast.LENGTH_LONG
).show()
UpdateManager.getInstance(context).onInstallFinished()
}
}
}
}
@@ -0,0 +1,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))
}
}
@@ -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)
}
}
}
@@ -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()
}
}
@@ -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();
}
}
@@ -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()
}
}
}
}
}
}
@@ -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();
}
});
}
}
}
@@ -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();
}
}
}
@@ -1,141 +0,0 @@
/*
* Copyright (C) 2013-2016 Tobias Brunner
*
* Copyright (C) secunet Security Networks AG
*
* This program is free software; you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the
* Free Software Foundation; either version 2 of the License, or (at your
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
*
* This program is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
* for more details.
*/
package org.strongswan.android.ui;
import android.os.Build;
import android.os.Bundle;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.ArrayAdapter;
import android.widget.FrameLayout;
import android.widget.TextView;
import org.strongswan.android.R;
import org.strongswan.android.logic.imc.RemediationInstruction;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.fragment.app.ListFragment;
public class RemediationInstructionFragment extends ListFragment
{
public static final String ARG_REMEDIATION_INSTRUCTION = "instruction";
private RemediationInstruction mInstruction = null;
private TextView mTitle;
private TextView mDescription;
private TextView mHeader;
@Override
public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState)
{
/* while the documentation recommends to include "@android:layout/list_content" to retain
* the default functionality, this does not actually work with the ListFragment provided by
* the support library as it builds the view manually and uses different IDs */
View layout = inflater.inflate(R.layout.remediation_instruction, container, false);
FrameLayout list = (FrameLayout)layout.findViewById(R.id.list_container);
list.addView(super.onCreateView(inflater, list, savedInstanceState));
return layout;
}
@Override
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState)
{
super.onViewCreated(view, savedInstanceState);
if (savedInstanceState != null)
{
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU)
{
mInstruction = getInstructionCompat(savedInstanceState);
}
else
{
mInstruction = savedInstanceState.getParcelable(ARG_REMEDIATION_INSTRUCTION, RemediationInstruction.class);
}
}
/* show dividers only between list items */
getListView().setHeaderDividersEnabled(false);
getListView().setFooterDividersEnabled(false);
/* don't show loader while adapter is not set */
setListShown(true);
mTitle = (TextView)getView().findViewById(R.id.title);
mDescription = (TextView)getView().findViewById(R.id.description);
mHeader = (TextView)getView().findViewById(R.id.list_header);
}
@Override
public void onSaveInstanceState(Bundle outState)
{
super.onSaveInstanceState(outState);
outState.putParcelable(ARG_REMEDIATION_INSTRUCTION, mInstruction);
}
@Override
public void onStart()
{
super.onStart();
Bundle args = getArguments();
if (args != null)
{
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU)
{
mInstruction = getInstructionCompat(args);
}
else
{
mInstruction = args.getParcelable(ARG_REMEDIATION_INSTRUCTION, RemediationInstruction.class);
}
}
updateView(mInstruction);
}
public void updateView(RemediationInstruction instruction)
{
mInstruction = instruction;
if (mInstruction != null)
{
mTitle.setText(mInstruction.getTitle());
mDescription.setText(mInstruction.getDescription());
if (mInstruction.getHeader() != null)
{
mHeader.setText(mInstruction.getHeader());
setListAdapter(new ArrayAdapter<String>(getActivity(),
android.R.layout.simple_list_item_1, mInstruction.getItems()));
}
else
{
mHeader.setText("");
setListAdapter(null);
}
}
else
{
mTitle.setText("");
mDescription.setText("");
mHeader.setText("");
setListAdapter(null);
}
}
@SuppressWarnings("deprecation")
private static RemediationInstruction getInstructionCompat(Bundle bundle)
{
return bundle.getParcelable(ARG_REMEDIATION_INSTRUCTION);
}
}
@@ -1,107 +0,0 @@
/*
* Copyright (C) 2013 Tobias Brunner
*
* Copyright (C) secunet Security Networks AG
*
* This program is free software; you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the
* Free Software Foundation; either version 2 of the License, or (at your
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
*
* This program is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
* for more details.
*/
package org.strongswan.android.ui;
import android.os.Bundle;
import androidx.appcompat.app.AppCompatActivity;
import androidx.core.view.WindowCompat;
import android.view.MenuItem;
import org.strongswan.android.R;
import org.strongswan.android.logic.imc.RemediationInstruction;
import org.strongswan.android.ui.RemediationInstructionsFragment.OnRemediationInstructionSelectedListener;
import java.util.ArrayList;
public class RemediationInstructionsActivity extends AppCompatActivity implements OnRemediationInstructionSelectedListener
{
@Override
protected void onCreate(Bundle savedInstanceState)
{
super.onCreate(savedInstanceState);
setContentView(R.layout.remediation_instructions);
WindowCompat.enableEdgeToEdge(getWindow());
getSupportActionBar().setDisplayHomeAsUpEnabled(true);
if (savedInstanceState != null)
{ /* only update if we're not restoring */
return;
}
RemediationInstructionsFragment frag = (RemediationInstructionsFragment)getSupportFragmentManager().findFragmentById(R.id.remediation_instructions_fragment);
if (frag != null)
{ /* two-pane layout, update fragment */
Bundle extras = getIntent().getExtras();
ArrayList<RemediationInstruction> list = null;
if (android.os.Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.TIRAMISU)
{
list = RemediationInstructionsFragment.getInstructionsCompat(extras);
}
else
{
list = extras.getParcelableArrayList(RemediationInstructionsFragment.EXTRA_REMEDIATION_INSTRUCTIONS,
RemediationInstruction.class);
}
frag.updateView(list);
}
else
{ /* one-pane layout, create fragment */
frag = new RemediationInstructionsFragment();
frag.setArguments(getIntent().getExtras());
getSupportFragmentManager().beginTransaction().add(R.id.fragment_container, frag).commit();
}
}
@Override
public boolean onOptionsItemSelected(MenuItem item)
{
switch (item.getItemId())
{
case android.R.id.home:
/* one-pane layout, pop possible fragment from stack, finish otherwise */
if (!getSupportFragmentManager().popBackStackImmediate())
{
finish();
}
getSupportActionBar().setTitle(getTitle());
return true;
default:
return super.onOptionsItemSelected(item);
}
}
@Override
public void onRemediationInstructionSelected(RemediationInstruction instruction)
{
RemediationInstructionFragment frag = (RemediationInstructionFragment)getSupportFragmentManager().findFragmentById(R.id.remediation_instruction_fragment);
if (frag != null)
{ /* two-pane layout, update directly */
frag.updateView(instruction);
}
else
{ /* one-pane layout, replace fragment */
frag = new RemediationInstructionFragment();
Bundle args = new Bundle();
args.putParcelable(RemediationInstructionFragment.ARG_REMEDIATION_INSTRUCTION, instruction);
frag.setArguments(args);
getSupportFragmentManager().beginTransaction().replace(R.id.fragment_container, frag).addToBackStack(null).commit();
getSupportActionBar().setTitle(instruction.getTitle());
}
}
}
@@ -0,0 +1,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))
}
}
}
}
}
@@ -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);
}
}
@@ -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();
}
}
}
@@ -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)
@@ -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();
}
}
@@ -1,248 +0,0 @@
/*
* Copyright (C) 2014 Tobias Brunner
*
* Copyright (C) secunet Security Networks AG
*
* This program is free software; you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the
* Free Software Foundation; either version 2 of the License, or (at your
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
*
* This program is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
* for more details.
*/
package org.strongswan.android.ui;
import android.app.Dialog;
import android.content.ActivityNotFoundException;
import android.content.DialogInterface;
import android.content.Intent;
import android.net.Uri;
import android.os.Build;
import android.os.Bundle;
import android.widget.Toast;
import org.strongswan.android.R;
import org.strongswan.android.data.VpnProfileDataSource;
import org.strongswan.android.logic.TrustedCertificateManager;
import java.io.FileNotFoundException;
import java.io.InputStream;
import java.security.KeyStore;
import java.security.cert.CertificateException;
import java.security.cert.CertificateFactory;
import java.security.cert.X509Certificate;
import androidx.activity.result.ActivityResultLauncher;
import androidx.activity.result.contract.ActivityResultContracts;
import androidx.appcompat.app.AlertDialog;
import androidx.appcompat.app.AppCompatActivity;
import androidx.appcompat.app.AppCompatDialogFragment;
import androidx.fragment.app.FragmentTransaction;
public class TrustedCertificateImportActivity extends AppCompatActivity
{
private static final String DIALOG_TAG = "Dialog";
private Uri mCertificateUri;
private final ActivityResultLauncher<Intent> mOpenDocument = registerForActivityResult(
new ActivityResultContracts.StartActivityForResult(),
result -> {
if (result.getResultCode() == RESULT_OK && result.getData() != null)
{
mCertificateUri = result.getData().getData();
return;
}
finish();
}
);
@Override
public void onCreate(Bundle savedInstanceState)
{
super.onCreate(savedInstanceState);
if (savedInstanceState != null)
{ /* do nothing when we are restoring */
return;
}
Intent intent = getIntent();
String action = intent.getAction();
if (Intent.ACTION_VIEW.equals(action))
{
importCertificate(intent.getData());
}
else
{
Intent openIntent = new Intent(Intent.ACTION_OPEN_DOCUMENT);
openIntent.setType("*/*");
try
{
mOpenDocument.launch(openIntent);
}
catch (ActivityNotFoundException e)
{ /* some devices are unable to browse for files */
finish();
}
}
}
@Override
protected void onPostResume()
{
super.onPostResume();
if (mCertificateUri != null)
{
importCertificate(mCertificateUri);
mCertificateUri = null;
}
}
/**
* Import the file pointed to by the given URI as a certificate.
*
* @param uri
*/
private void importCertificate(Uri uri)
{
X509Certificate certificate = parseCertificate(uri);
if (certificate == null)
{
Toast.makeText(this, R.string.cert_import_failed, Toast.LENGTH_LONG).show();
finish();
return;
}
/* Ask the user whether to import the certificate. This is particularly
* necessary because the import activity can be triggered by any app on
* the system. Also, if our app is the only one that is registered to
* open certificate files by MIME type the user would have no idea really
* where the file was imported just by reading the Toast we display. */
ConfirmImportDialog dialog = new ConfirmImportDialog();
Bundle args = new Bundle();
args.putSerializable(VpnProfileDataSource.KEY_CERTIFICATE, certificate);
dialog.setArguments(args);
FragmentTransaction ft = getSupportFragmentManager().beginTransaction();
ft.add(dialog, DIALOG_TAG);
ft.commit();
}
/**
* Load the file from the given URI and try to parse it as X.509 certificate.
*
* @param uri
* @return certificate or null
*/
private X509Certificate parseCertificate(Uri uri)
{
X509Certificate certificate = null;
try
{
CertificateFactory factory = CertificateFactory.getInstance("X.509");
InputStream in = getContentResolver().openInputStream(uri);
certificate = (X509Certificate)factory.generateCertificate(in);
/* we don't check whether it's actually a CA certificate or not */
}
catch (CertificateException e)
{
e.printStackTrace();
}
catch (FileNotFoundException e)
{
e.printStackTrace();
}
return certificate;
}
/**
* Try to store the given certificate in the KeyStore.
*
* @param certificate
* @return whether it was successfully stored
*/
private boolean storeCertificate(X509Certificate certificate)
{
try
{
KeyStore store = KeyStore.getInstance("LocalCertificateStore");
store.load(null, null);
store.setCertificateEntry(null, certificate);
TrustedCertificateManager.getInstance().reset();
return true;
}
catch (Exception e)
{
e.printStackTrace();
return false;
}
}
/**
* Class that displays a confirmation dialog when a certificate should get
* imported. If the user confirms the import we try to store it.
*/
public static class ConfirmImportDialog extends AppCompatDialogFragment
{
@Override
public Dialog onCreateDialog(Bundle savedInstanceState)
{
final X509Certificate certificate;
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU)
{
certificate = getCertificateCompat(getArguments());
}
else
{
certificate = getArguments().getSerializable(VpnProfileDataSource.KEY_CERTIFICATE, X509Certificate.class);
}
return new AlertDialog.Builder(getActivity())
.setIcon(R.mipmap.ic_app)
.setTitle(R.string.import_certificate)
.setMessage(certificate.getSubjectDN().toString())
.setPositiveButton(R.string.import_certificate, new DialogInterface.OnClickListener()
{
@Override
public void onClick(DialogInterface dialog, int whichButton)
{
TrustedCertificateImportActivity activity = (TrustedCertificateImportActivity)getActivity();
if (activity.storeCertificate(certificate))
{
Toast.makeText(getActivity(), R.string.cert_imported_successfully, Toast.LENGTH_LONG).show();
getActivity().setResult(RESULT_OK);
}
else
{
Toast.makeText(getActivity(), R.string.cert_import_failed, Toast.LENGTH_LONG).show();
}
getActivity().finish();
}
})
.setNegativeButton(android.R.string.cancel, new DialogInterface.OnClickListener()
{
@Override
public void onClick(DialogInterface dialog, int which)
{
getActivity().finish();
}
}).create();
}
@Override
public void onCancel(DialogInterface dialog)
{
getActivity().finish();
}
@SuppressWarnings("deprecation")
private static X509Certificate getCertificateCompat(Bundle bundle)
{
return (X509Certificate)bundle.getSerializable(VpnProfileDataSource.KEY_CERTIFICATE);
}
}
}
@@ -0,0 +1,205 @@
/*
* Copyright (C) 2014 Tobias Brunner
*
* Copyright (C) secunet Security Networks AG
*
* This program is free software; you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the
* Free Software Foundation; either version 2 of the License, or (at your
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
*
* This program is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
* for more details.
*/
package org.strongswan.android.ui
import android.content.ActivityNotFoundException
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.widget.Toast
import androidx.activity.compose.setContent
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import 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)
}
@@ -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);
}
}
@@ -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)
}
}
@@ -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();
}
}
@@ -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()
@@ -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)
{
@@ -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() },
@@ -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
@@ -1,53 +0,0 @@
/*
* Copyright (C) 2016-2019 Tobias Brunner
*
* Copyright (C) secunet Security Networks AG
*
* This program is free software; you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the
* Free Software Foundation; either version 2 of the License, or (at your
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
*
* This program is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
* for more details.
*/
package org.strongswan.android.ui.adapter;
import android.content.Context;
import android.widget.ArrayAdapter;
import org.strongswan.android.security.TrustedCertificateEntry;
public class CertificateIdentitiesAdapter extends ArrayAdapter<String>
{
TrustedCertificateEntry mCertificate;
public CertificateIdentitiesAdapter(Context context)
{
super(context, android.R.layout.simple_dropdown_item_1line);
extractIdentities();
}
/**
* Set a new certificate for this adapter.
*
* @param certificate the certificate to extract identities from (null to clear)
*/
public void setCertificate(TrustedCertificateEntry certificate)
{
mCertificate = certificate;
clear();
extractIdentities();
}
private void extractIdentities()
{
if (mCertificate != null)
{
addAll(mCertificate.getSubjectAltNames());
}
}
}
@@ -1,72 +0,0 @@
/*
* Copyright (C) 2013 Tobias Brunner
*
* Copyright (C) secunet Security Networks AG
*
* This program is free software; you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the
* Free Software Foundation; either version 2 of the License, or (at your
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
*
* This program is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
* for more details.
*/
package org.strongswan.android.ui.adapter;
import java.util.List;
import org.strongswan.android.R;
import org.strongswan.android.logic.imc.RemediationInstruction;
import android.content.Context;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.ArrayAdapter;
import android.widget.TextView;
public class RemediationInstructionAdapter extends ArrayAdapter<RemediationInstruction>
{
public RemediationInstructionAdapter(Context context)
{
super(context, 0);
}
/**
* Set new data for this adapter.
*
* @param data the new data (null to clear)
*/
public void setData(List<RemediationInstruction> data)
{
clear();
if (data != null)
{
addAll(data);
}
}
@Override
public View getView(int position, View convertView, ViewGroup parent)
{
View view;
if (convertView != null)
{
view = convertView;
}
else
{
LayoutInflater inflater = LayoutInflater.from(getContext());
view = inflater.inflate(R.layout.remediation_instruction_item, parent, false);
}
RemediationInstruction item = getItem(position);
TextView text = (TextView)view.findViewById(android.R.id.text1);
text.setText(item.getTitle());
text = (TextView)view.findViewById(android.R.id.text2);
text.setText(item.getDescription());
return view;
}
}
@@ -1,175 +0,0 @@
/*
* Copyright (C) 2017 Tobias Brunner
*
* Copyright (C) secunet Security Networks AG
*
* This program is free software; you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the
* Free Software Foundation; either version 2 of the License, or (at your
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
*
* This program is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
* for more details.
*/
package org.strongswan.android.ui.adapter;
import android.content.Context;
import android.text.TextUtils;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.BaseAdapter;
import android.widget.Filter;
import android.widget.Filterable;
import android.widget.ImageView;
import android.widget.TextView;
import org.strongswan.android.R;
import org.strongswan.android.ui.widget.CheckableLinearLayout;
import java.util.ArrayList;
import java.util.List;
public class SelectedApplicationsAdapter extends BaseAdapter implements Filterable
{
private final Context mContext;
private final Object mLock = new Object();
private final List<SelectedApplicationEntry> mData;
private List<SelectedApplicationEntry> mDataFiltered;
private SelectedApplicationsFilter mFilter;
private boolean mReadOnly;
public SelectedApplicationsAdapter(Context context)
{
mContext = context;
mData = mDataFiltered = new ArrayList<>();
}
/**
* Set new data for this adapter.
*
* @param data the new data (null to clear)
*/
public void setData(List<SelectedApplicationEntry> data)
{
synchronized (mLock)
{
mData.clear();
mDataFiltered = mData;
if (data != null)
{
mData.addAll(data);
}
}
notifyDataSetChanged();
}
@Override
public int getCount()
{
return mDataFiltered.size();
}
@Override
public SelectedApplicationEntry getItem(int position)
{
return mDataFiltered.get(position);
}
@Override
public long getItemId(int position)
{
return mDataFiltered.get(position).toString().hashCode();
}
@Override
public View getView(int position, View convertView, ViewGroup parent)
{
View view;
if (convertView != null)
{
view = convertView;
}
else
{
LayoutInflater inflater = LayoutInflater.from(mContext);
view = inflater.inflate(R.layout.selected_application_item, parent, false);
}
SelectedApplicationEntry item = getItem(position);
CheckableLinearLayout checkable = (CheckableLinearLayout)view;
checkable.setChecked(item.isSelected());
checkable.setEnabled(!mReadOnly);
ImageView icon = view.findViewById(R.id.app_icon);
icon.setImageDrawable(item.getIcon());
TextView text = view.findViewById(R.id.app_name);
text.setText(item.toString());
return view;
}
@Override
public Filter getFilter()
{
if (mFilter == null)
{
mFilter = new SelectedApplicationsFilter();
}
return mFilter;
}
public boolean isReadOnly()
{
return mReadOnly;
}
public void setReadOnly(final boolean readOnly)
{
this.mReadOnly = readOnly;
}
private class SelectedApplicationsFilter extends Filter
{
@Override
protected FilterResults performFiltering(CharSequence constraint)
{
FilterResults results = new FilterResults();
ArrayList<SelectedApplicationEntry> data, filtered;
synchronized (mLock)
{
data = new ArrayList<>(mData);
}
if (TextUtils.isEmpty(constraint))
{
filtered = data;
}
else
{
String filter = constraint.toString().toLowerCase();
filtered = new ArrayList<>();
for (SelectedApplicationEntry entry : data)
{
if (entry.toString().toLowerCase().contains(filter))
{
filtered.add(entry);
}
}
}
results.values = filtered;
results.count = filtered.size();
return results;
}
@Override
@SuppressWarnings("unchecked")
protected void publishResults(CharSequence constraint, FilterResults results)
{
mDataFiltered = (List<SelectedApplicationEntry>)results.values;
notifyDataSetChanged();
}
}
}
@@ -1,72 +0,0 @@
/*
* Copyright (C) 2012 Tobias Brunner
*
* Copyright (C) secunet Security Networks AG
*
* This program is free software; you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the
* Free Software Foundation; either version 2 of the License, or (at your
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
*
* This program is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
* for more details.
*/
package org.strongswan.android.ui.adapter;
import java.util.List;
import org.strongswan.android.R;
import org.strongswan.android.security.TrustedCertificateEntry;
import android.content.Context;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.ArrayAdapter;
import android.widget.TextView;
public class TrustedCertificateAdapter extends ArrayAdapter<TrustedCertificateEntry>
{
public TrustedCertificateAdapter(Context context)
{
super(context, R.layout.trusted_certificates_item);
}
/**
* Set new data for this adapter.
*
* @param data the new data (null to clear)
*/
public void setData(List<TrustedCertificateEntry> data)
{
clear();
if (data != null)
{
addAll(data);
}
}
@Override
public View getView(int position, View convertView, ViewGroup parent)
{
View view;
if (convertView != null)
{
view = convertView;
}
else
{
LayoutInflater inflater = LayoutInflater.from(getContext());
view = inflater.inflate(R.layout.trusted_certificates_item, parent, false);
}
TrustedCertificateEntry item = getItem(position);
TextView text = (TextView)view.findViewById(R.id.subject_primary);
text.setText(item.getSubjectPrimary());
text = (TextView)view.findViewById(R.id.subject_secondary);
text.setText(item.getSubjectSecondary());
return view;
}
}
@@ -0,0 +1,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))
}
}
}
@@ -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
@@ -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
}
}
@@ -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),
)
}
}
}
@@ -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)) },
@@ -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,
@@ -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),
)
}
}
}
@@ -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,
)
}
@@ -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,
@@ -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)
{
}
}
}
@@ -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
}
@@ -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?,
@@ -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))
}
},
)
}
@@ -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) }
}
}
}
@@ -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,
)
}
@@ -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;
}
}
@@ -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();
}
}
}
@@ -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";
}
@@ -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