Update Android project configuration and permissions
Android / pre-check (push) Failing after 7s
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 4s
CodeQL / analyze (python) (push) Skipped
CodeQL / analyze (ruby) (push) Skipped
CodeQL / analyze-cpp (push) Skipped
FreeBSD / pre-check (push) Failing after 3s
FreeBSD / freebsd (14.4) (push) Skipped
FreeBSD / freebsd (15.0) (push) Skipped
Linux / pre-check (push) Failing after 4s
Linux / latest (apidoc) (push) Skipped
Linux / latest (clang, no, all) (push) Skipped
Linux / latest (clang, no, default) (push) Skipped
macOS / pre-check (push) Failing after 4s
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
macOS / macos (macos-14) (push) Skipped
Linux / crypto (ubuntu-22.04, openssl-sys) (push) Skipped
macOS / macos (macos-latest) (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
SonarCloud / pre-check (push) Failing after 4s
SonarCloud / sonarcloud (push) Skipped
TKM / pre-check (push) Failing after 4s
TKM / tkm (push) Skipped
Windows / pre-check (push) Failing after 3s
Windows / cross-compile (win32) (push) Skipped
Windows / cross-compile (win64) (push) Skipped
Windows / native (i686, mingw32, win32) (push) Skipped
Windows / native (x86_64, mingw64, win64) (push) Skipped

- Added new entries to .gitignore to exclude Android build artifacts and keystore files.
- Updated build.gradle files to include Kotlin Gradle plugin dependencies.
- Enhanced gradle.properties with JVM arguments for better performance.
- Modified AndroidManifest.xml to include additional permissions for network and location access.
- Refactored package names in several classes to use a new namespace.
- Removed deprecated LogActivity, SelectedApplicationsActivity, SettingsActivity, and TrustedCertificatesActivity.
- Introduced a new scheduling mechanism in the Scheduler class to handle exact alarms on Android 13+.
- Updated MainActivity to integrate new UI components.

These changes improve the overall structure and functionality of the Android application.
This commit is contained in:
Denozordec
2026-09-01 15:38:09 +07:00
parent 2265fcd1b4
commit 9dcc56c821
88 changed files with 5986 additions and 5562 deletions
+9
View File
@@ -56,3 +56,12 @@ coverage/
test-driver
nbproject/
*.[si]
*.apk
*.aab
*.apks
*.idsig
*.jks
*.keystore
src/frontends/android/app/src/main/jni/openssl/
src/frontends/android/local.properties
*.hprof
+7
View File
@@ -4,5 +4,12 @@ build/
app/build/
app/src/main/libs
app/src/main/obj
app/src/main/jni/openssl/
*.iml
local.properties
*.apk
*.aab
*.apks
*.idsig
*.jks
*.keystore
-57
View File
@@ -1,57 +0,0 @@
apply plugin: 'com.android.application'
android {
namespace = 'org.strongswan.android'
defaultConfig {
applicationId "org.strongswan.android"
compileSdk = 36
minSdkVersion 21
targetSdkVersion 36
versionCode 96
versionName "2.6.2"
externalNativeBuild {
ndkBuild {
arguments '-j' + Runtime.runtime.availableProcessors()
}
}
}
ndkVersion = "27.3.13750724"
externalNativeBuild {
ndkBuild {
path 'src/main/jni/Android.mk'
}
}
tasks.withType(JavaCompile).configureEach {
compileTask ->
options.compilerArgs << "-Xlint:unchecked" << "-Xlint:deprecation"
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.txt'
}
}
compileOptions {
targetCompatibility 1.8
sourceCompatibility 1.8
}
}
dependencies {
implementation 'androidx.appcompat:appcompat:1.7.1'
implementation 'androidx.core:core:1.17.0'
implementation 'androidx.lifecycle:lifecycle-process:2.9.4'
implementation 'androidx.preference:preference:1.2.1'
implementation 'androidx.localbroadcastmanager:localbroadcastmanager:1.1.0'
implementation 'com.google.android.material:material:1.13.0'
testImplementation 'junit:junit:4.13.2'
testImplementation 'org.assertj:assertj-core:3.27.6'
testImplementation 'org.mockito:mockito-core:5.20.0'
}
@@ -0,0 +1,60 @@
# SHX VPN R8 rules
#
# The native library (libandroidbridge) looks up Java classes and methods by
# name via JNI. R8 must not rename or remove any of them.
# --- JNI entry points called from native code by name ---
-keep class org.strongswan.android.logic.CharonVpnService {
*;
}
# inner class BuilderAdapter is called from vpnservice_builder.c
-keep class org.strongswan.android.logic.CharonVpnService$BuilderAdapter {
*;
}
-keep class org.strongswan.android.logic.SimpleFetcher {
*;
}
-keep class org.strongswan.android.logic.NetworkManager {
*;
}
-keep class org.strongswan.android.logic.Scheduler {
*;
}
-keep class org.strongswan.android.logic.imc.AndroidImc {
*;
}
# native methods (and classes containing them) must keep their names
-keepclasseswithmembernames class * {
native <methods>;
}
# --- Content providers exported to other apps ---
-keep class org.strongswan.android.data.LogContentProvider {
*;
}
# --- Parcelable CREATORs (RemediationInstruction etc.) ---
-keepclassmembers class * implements android.os.Parcelable {
public static final ** CREATOR;
}
# --- Enum values used via valueOf/values (VpnType, State, ErrorState...) ---
-keepclassmembers enum * {
public static **[] values();
public static ** valueOf(java.lang.String);
}
# --- WebView / TextView reflection-free; keep line numbers for readable
# crash reports in released APKs ---
-keepattributes SourceFile,LineNumberTable
-renamesourcefileattribute SourceFile
# --- squash warnings for optional dependencies referenced only from XML ---
-dontwarn org.bouncycastle.jsse.util.**
@@ -0,0 +1,80 @@
/*
* Copyright (C) 2026 SHX VPN
*
* This program is free software; you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the
* Free Software Foundation; either version 2 of the License, or (at your
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
*
* This program is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
* for more details.
*/
package org.strongswan.android.ui.compose
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.onAllNodesWithText
import androidx.test.platform.app.InstrumentationRegistry
import org.junit.Rule
import org.junit.Test
import org.strongswan.android.R
import org.strongswan.android.ui.compose.theme.ShxTheme
class SettingsScreenTest
{
@get:Rule
val composeRule = createComposeRule()
private fun string(id: Int): String =
InstrumentationRegistry.getInstrumentation().targetContext.getString(id)
private fun setContent()
{
composeRule.setContent {
ShxTheme {
SettingsScreen(profiles = emptyList())
}
}
composeRule.waitForIdle()
}
@Test
fun showsSectionHeaders()
{
setContent()
composeRule.onNodeWithText(
string(R.string.settings_section_automation)
).assertIsDisplayed()
composeRule.onNodeWithText(
string(R.string.settings_section_security)
).assertIsDisplayed()
composeRule.onNodeWithText(
string(R.string.settings_section_support)
).assertIsDisplayed()
}
@Test
fun showsTrustedNetworksEntry()
{
setContent()
composeRule.onNodeWithText(
string(R.string.auto_connect_title)
).assertIsDisplayed()
}
@Test
fun showsKillSwitchEntry()
{
setContent()
composeRule.onNodeWithText(
string(R.string.kill_switch_title)
).assertIsDisplayed()
composeRule.onAllNodesWithText(
string(R.string.kill_switch_summary_off)
).fetchSemanticsNodes().isNotEmpty()
}
}
@@ -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.
*/
package org.strongswan.android.ui.compose
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.test.platform.app.InstrumentationRegistry
import org.junit.Rule
import org.junit.Test
import org.strongswan.android.R
import org.strongswan.android.ui.compose.theme.ShxTheme
class TrustedNetworksScreenTest
{
@get:Rule
val composeRule = createComposeRule()
private fun string(id: Int): String =
InstrumentationRegistry.getInstrumentation().targetContext.getString(id)
private fun setContent()
{
composeRule.setContent {
ShxTheme {
TrustedNetworksScreen(profiles = emptyList(), currentSsid = null)
}
}
composeRule.waitForIdle()
}
@Test
fun showsMasterToggle()
{
setContent()
composeRule.onNodeWithText(
string(R.string.auto_connect_title)
).assertIsDisplayed()
composeRule.onNodeWithText(
string(R.string.auto_connect_summary)
).assertIsDisplayed()
}
@Test
fun showsProfilePickerRow()
{
setContent()
composeRule.onNodeWithText(
string(R.string.auto_connect_profile)
).assertIsDisplayed()
}
@Test
fun showsEmptyTrustedList()
{
setContent()
composeRule.onNodeWithText(
string(R.string.auto_connect_no_networks)
).assertIsDisplayed()
}
@Test
fun showsAddNetworkButton()
{
setContent()
composeRule.onNodeWithText(
string(R.string.auto_connect_add_network)
).assertIsDisplayed()
}
}
@@ -21,12 +21,23 @@
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<!-- SSID is redacted in the background on Android 10+ without this. -->
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />
<uses-permission android:name="android.permission.NEARBY_WIFI_DEVICES" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
<!-- Required to reliably schedule NAT keepalives/rekeyings via the AlarmManager.
On Android 13+ this special permission is denied by default, but it can be
granted by the user in system settings (Scheduler falls back to inexact
alarms while it is not granted). -->
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" />
<!-- necessary to allow users to select ex-/included apps and EAP-TNC -->
<uses-permission android:name="android.permission.QUERY_ALL_PACKAGES"
tools:ignore="QueryAllPackagesPermission" />
@@ -61,11 +72,11 @@
android:launchMode="singleTask"
android:exported="true">
<intent-filter>
<action android:name="org.strongswan.android.action.START_PROFILE" />
<action android:name="one.shx.strongswan.ext.action.START_PROFILE" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
<intent-filter>
<action android:name="org.strongswan.android.action.DISCONNECT" />
<action android:name="one.shx.strongswan.ext.action.DISCONNECT" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
</activity>
@@ -85,12 +96,17 @@
android:label="@string/log_title" >
</activity>
<activity
android:name=".ui.SettingsActivity"
android:label="@string/pref_title">
</activity>
android:name=".ui.SettingsActivity"
android:label="@string/pref_title">
</activity>
<activity
android:name=".ui.TrustedNetworksActivity"
android:label="@string/auto_connect_title">
</activity>
<activity
android:name=".ui.RemediationInstructionsActivity"
android:label="@string/remediation_instructions_title" >
android:label="@string/remediation_instructions_title"
android:theme="@style/ApplicationTheme.ActionBar" >
</activity>
<activity
android:name=".ui.VpnProfileSelectActivity"
@@ -105,6 +121,7 @@
<activity
android:name=".ui.VpnProfileImportActivity"
android:label="@string/profile_import"
android:theme="@style/ApplicationTheme.ActionBar"
android:taskAffinity=""
android:excludeFromRecents="true"
android:exported="true">
@@ -202,7 +219,7 @@
<provider
android:name=".data.LogContentProvider"
android:authorities="org.strongswan.android.content.log"
android:authorities="one.shx.strongswan.ext.content.log"
android:exported="true" >
<!-- android:grantUriPermissions="true" combined with a custom permission does
not work (probably too many indirections with ACTION_SEND) so we secure
@@ -1,157 +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.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 = "org.strongswan.android.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>();
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()
{
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());
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 this is simple to implement */
if (projection == null)
{
projection = new String[]{ OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE };
}
Long timestamp = mUris.get(uri);
if (timestamp == 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(CharonVpnService.LOG_FILE);
}
else if (OpenableColumns.SIZE.equals(col))
{
cols.add(OpenableColumns.SIZE);
vals.add(mLogFile.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
{
Long timestamp = mUris.get(uri);
if (timestamp != null)
{
long elapsed = SystemClock.uptimeMillis() - timestamp;
if (elapsed > 0 && elapsed < URI_VALIDITY)
{ /* we fail if clock wrapped, should happen rarely though */
return ParcelFileDescriptor.open(mLogFile, ParcelFileDescriptor.MODE_CREATE | ParcelFileDescriptor.MODE_READ_ONLY);
}
mUris.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;
}
}
@@ -32,7 +32,7 @@ import java.util.UUID;
public class VpnProfileManagedDataSource implements VpnProfileDataSource
{
private static final String NAME_MANAGED_VPN_PROFILES = "org.strongswan.android.data.VpnProfileManagedDataSource.preferences";
private static final String NAME_MANAGED_VPN_PROFILES = "one.shx.strongswan.ext.data.VpnProfileManagedDataSource.preferences";
private static final String PREFIX_USER_CERT = "usercert:";
private final ManagedConfigurationService mManagedConfigurationService;
@@ -24,6 +24,7 @@ import android.content.Context;
import android.content.Intent;
import android.content.IntentFilter;
import android.os.Build;
import android.util.Log;
import java.util.ArrayList;
import java.util.PriorityQueue;
@@ -33,7 +34,8 @@ import androidx.annotation.RequiresApi;
public class Scheduler extends BroadcastReceiver
{
private final String EXECUTE_JOB = "org.strongswan.android.Scheduler.EXECUTE_JOB";
private final String EXECUTE_JOB = "one.shx.strongswan.ext.Scheduler.EXECUTE_JOB";
private final String TAG = Scheduler.class.getSimpleName();
private final Context mContext;
private final AlarmManager mManager;
private final PriorityQueue<ScheduledJob> mJobs;
@@ -99,6 +101,42 @@ public class Scheduler extends BroadcastReceiver
return PendingIntent.getBroadcast(mContext, 0, intent, flags);
}
/**
* Schedule the next wakeup alarm. Uses exact alarms so the device is woken
* for NAT keepalives and rekeyings while it is idle, but since Android 13
* that special permission is denied by default, so fall back to inexact
* alarms instead of crashing with a SecurityException (connectivity then
* only degrades slightly while timers may be delayed in doze mode).
*
* @param time wakeup time based on RTC
*/
@RequiresApi(api = Build.VERSION_CODES.M)
private void scheduleAlarm(long time)
{
PendingIntent pending = createIntent();
boolean exact = true;
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S)
{
exact = mManager.canScheduleExactAlarms();
}
try
{
if (exact)
{
mManager.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, time, pending);
}
else
{
mManager.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, time, pending);
}
}
catch (SecurityException e)
{ /* permission might have been revoked, or OEM restrictions apply */
Log.w(TAG, "Falling back to inexact alarm after SecurityException");
mManager.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, time, pending);
}
}
/**
* Schedule executing a job in the future.
* Called via JNI from different threads.
@@ -116,8 +154,7 @@ public class Scheduler extends BroadcastReceiver
if (job == mJobs.peek())
{ /* update the alarm if the job has to be executed before all others */
PendingIntent pending = createIntent();
mManager.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, job.Time, pending);
scheduleAlarm(job.Time);
}
}
}
@@ -143,8 +180,7 @@ public class Scheduler extends BroadcastReceiver
}
if (job != null)
{
PendingIntent pending = createIntent();
mManager.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, job.Time, pending);
scheduleAlarm(job.Time);
}
}
@@ -28,6 +28,7 @@ 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.security.LocalCertificateKeyStoreProvider;
import org.strongswan.android.utils.Constants;
@@ -93,6 +94,9 @@ public class StrongSwanApplication extends Application
final IntentFilter restrictionsFilter = new IntentFilter(Intent.ACTION_APPLICATION_RESTRICTIONS_CHANGED);
registerReceiver(mRestrictionsReceiver, restrictionsFilter);
/* starts monitoring networks if the trusted networks feature is enabled */
AutoConnectManager.getInstance(this).start();
}
private void reloadManagedConfigurationAndNotifyListeners()
@@ -0,0 +1,394 @@
/*
* 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.autoconnect
import android.app.Service
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.ServiceConnection
import android.net.VpnService
import android.os.Bundle
import android.os.Handler
import android.os.IBinder
import android.os.Looper
import android.util.Log
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.ProcessLifecycleOwner
import org.json.JSONArray
import org.json.JSONException
import org.json.JSONObject
import org.strongswan.android.data.VpnProfileDataSource
import org.strongswan.android.logic.VpnStateService
import org.strongswan.android.ui.VpnProfileControlActivity
import java.util.concurrent.atomic.AtomicBoolean
/**
* Trusted networks model: a trusted Wi-Fi means the VPN should be down, everything else
* (cellular or untrusted Wi-Fi) means the VPN should be up.
*
* The manager disconnects any active session on a trusted Wi-Fi so that
* leaving the network (cellular / untrusted Wi-Fi) can start the tunnel again.
*/
class AutoConnectManager private constructor(
private val context: Context,
) : VpnStateService.VpnStateListener
{
data class Config(
val enabled: Boolean = false,
val profileUuid: String? = null,
val trustedSsids: Set<String> = emptySet(),
)
enum class NetworkKind
{
TRUSTED_WIFI,
UNTRUSTED_WIFI,
UNKNOWN_WIFI,
CELLULAR,
}
enum class Action
{
CONNECT,
DISCONNECT,
IGNORE,
}
private val handler = Handler(Looper.getMainLooper())
private val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
private val pendingEvaluate = AtomicBoolean(false)
private val running = AtomicBoolean(false)
private var service: VpnStateService? = null
private var config = Config()
private var currentNetwork: NetworkKind? = null
/* marks the session this manager started itself */
private val autoSession = AtomicBoolean(false)
private val monitor = NetworkMonitor(context, { config.trustedSsids }, ::onNetworkChanged)
private val lifecycleBound = AtomicBoolean(false)
private val appLifecycleObserver = object : DefaultLifecycleObserver
{
override fun onStart(owner: LifecycleOwner)
{
if (running.get())
{
monitor.forceEvaluate()
}
}
}
private val connection = object : ServiceConnection
{
override fun onServiceConnected(name: ComponentName?, binder: IBinder?)
{
service = (binder as VpnStateService.LocalBinder).service
service?.registerListener(this@AutoConnectManager)
evaluate()
}
override fun onServiceDisconnected(name: ComponentName?)
{
service = null
}
}
fun start()
{
config = loadConfig()
if (!config.enabled)
{
return
}
if (!running.compareAndSet(false, true))
{
monitor.forceEvaluate()
return
}
context.bindService(
Intent(context, VpnStateService::class.java), connection, Service.BIND_AUTO_CREATE
)
monitor.start()
bindAppLifecycle()
}
fun stop()
{
unbindAppLifecycle()
monitor.stop()
if (!running.compareAndSet(true, false))
{
return
}
handler.removeCallbacksAndMessages(null)
service?.unregisterListener(this)
try
{
context.unbindService(connection)
}
catch (_: IllegalArgumentException)
{
}
service = null
}
fun reloadConfig()
{
config = loadConfig()
if (config.enabled)
{
start()
}
else
{
stop()
autoSession.set(false)
}
}
val currentConfig: Config
get()
{
if (!running.get())
{
config = loadConfig()
}
return config
}
fun updateConfig(transform: (Config) -> Config)
{
val updated = transform(config)
saveConfig(updated)
config = updated
if (updated.enabled)
{
start()
currentNetwork = monitor.currentKind()
evaluate()
}
else
{
stop()
autoSession.set(false)
}
}
/**
* Called by [NetworkMonitor] when the active network changed (already debounced).
*/
fun onNetworkChanged(kind: NetworkKind?)
{
if (!running.get())
{
return
}
currentNetwork = kind
evaluate()
}
override fun stateChanged()
{
val state = service?.state ?: return
if (state == VpnStateService.State.DISABLED)
{
autoSession.set(false)
}
}
/**
* Pure decision function, unit-testable.
*/
private fun evaluate(config: Config, network: NetworkKind?, vpnActive: Boolean, autoSession: Boolean): Action =
evaluateDecision(config, network, vpnActive, autoSession)
private fun evaluate()
{
if (!running.get())
{
return
}
val svc = service ?: return
val state = svc.state
val vpnActive = state == VpnStateService.State.CONNECTED || state == VpnStateService.State.CONNECTING
when (evaluate(config, currentNetwork, vpnActive, autoSession.get()))
{
Action.CONNECT -> connectProfile()
Action.DISCONNECT ->
{
Log.i(TAG, "Trusted network detected, disconnecting")
autoSession.set(false)
svc.disconnect()
}
Action.IGNORE ->
{
}
}
}
private fun connectProfile()
{
val uuid = config.profileUuid ?: return
if (!pendingEvaluate.compareAndSet(false, true))
{
return
}
handler.postDelayed({
pendingEvaluate.set(false)
val svc = service
if (svc != null && running.get())
{
val state = svc.state
val vpnActive = state == VpnStateService.State.CONNECTED ||
state == VpnStateService.State.CONNECTING
if (evaluate(config, currentNetwork, vpnActive, autoSession.get()) == Action.CONNECT)
{
Log.i(TAG, "Untrusted network detected, starting auto session")
autoSession.set(true)
startVpn(svc, uuid)
}
}
}, CONNECT_DELAY_MS)
}
private fun startVpn(svc: VpnStateService, uuid: String)
{
val needsConsent = try
{
VpnService.prepare(context) != null
}
catch (e: Exception)
{
Log.e(TAG, "VpnService.prepare failed", e)
true
}
if (!needsConsent)
{
val extras = Bundle()
extras.putString(VpnProfileDataSource.KEY_UUID, uuid)
svc.connect(extras, true)
return
}
val intent = Intent(context, VpnProfileControlActivity::class.java)
intent.action = VpnProfileControlActivity.START_PROFILE
intent.putExtra(VpnProfileControlActivity.EXTRA_VPN_PROFILE_UUID, uuid)
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
try
{
context.startActivity(intent)
}
catch (e: Exception)
{
Log.e(TAG, "Failed to start profile control activity", e)
autoSession.set(false)
}
}
private fun loadConfig(): Config
{
val json = prefs.getString(KEY_CONFIG, null) ?: return Config()
return try
{
val obj = JSONObject(json)
Config(
enabled = obj.optBoolean("enabled", false),
profileUuid = if (obj.isNull("profileUuid")) null else obj.optString("profileUuid").takeIf { it.isNotBlank() },
trustedSsids = obj.optJSONArray("trustedSsids")?.let { arr ->
(0 until arr.length()).mapNotNull { i ->
arr.optString(i)?.takeIf { it.isNotBlank() }
}.toSet()
} ?: emptySet(),
)
}
catch (_: JSONException)
{
Config()
}
}
private fun saveConfig(config: Config)
{
val obj = JSONObject()
obj.put("enabled", config.enabled)
obj.put("profileUuid", config.profileUuid ?: JSONObject.NULL)
obj.put("trustedSsids", JSONArray(config.trustedSsids.sorted()))
prefs.edit().putString(KEY_CONFIG, obj.toString()).apply()
}
private fun bindAppLifecycle()
{
if (!lifecycleBound.compareAndSet(false, true))
{
return
}
ProcessLifecycleOwner.get().lifecycle.addObserver(appLifecycleObserver)
}
private fun unbindAppLifecycle()
{
if (!lifecycleBound.compareAndSet(true, false))
{
return
}
ProcessLifecycleOwner.get().lifecycle.removeObserver(appLifecycleObserver)
}
companion object
{
private const val TAG = "AutoConnectManager"
private const val PREFS_NAME = "one.shx.strongswan.ext.AUTO_CONNECT"
private const val KEY_CONFIG = "config"
private const val CONNECT_DELAY_MS = 3000L
/**
* Pure decision function: trusted Wi-Fi means VPN down, everything else
* means VPN up.
*/
fun evaluateDecision(
config: Config,
network: NetworkKind?,
vpnActive: Boolean,
autoSession: Boolean,
): Action
{
if (!config.enabled || config.profileUuid == null)
{
return Action.IGNORE
}
return when (network)
{
null -> Action.IGNORE
NetworkKind.TRUSTED_WIFI -> if (vpnActive) 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
}
}
@Volatile
private var instance: AutoConnectManager? = null
@JvmStatic
fun getInstance(context: Context): AutoConnectManager =
instance ?: synchronized(this) {
instance ?: AutoConnectManager(context.applicationContext).also { instance = it }
}
}
}
@@ -0,0 +1,292 @@
/*
* Copyright (C) 2026 SHX VPN
*
* This program is free software; you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the
* Free Software Foundation; either version 2 of the License, or (at your
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
*
* This program is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
* for more details.
*/
package org.strongswan.android.logic.autoconnect
import android.content.Context
import android.net.ConnectivityManager
import android.net.Network
import android.net.NetworkCapabilities
import android.net.NetworkRequest
import android.net.wifi.WifiInfo
import android.os.Build
import android.os.Handler
import android.os.Looper
import android.util.Log
import java.util.concurrent.ConcurrentHashMap
/**
* Watches underlying (non-VPN) networks. While a VPN is up,
* [ConnectivityManager.getActiveNetwork] is the tunnel, so we must track
* Wi-Fi / cellular from the callback instead.
*
* Wi-Fi is preferred over cellular when both are up. Events are debounced
* because Wi-Fi ↔ mobile switches arrive as a cascade.
*
* SSID is cached from [ConnectivityManager.NetworkCallback] callbacks registered
* with [ConnectivityManager.NetworkCallback.FLAG_INCLUDE_LOCATION_INFO] so it
* stays readable while the app is in the background.
*/
class NetworkMonitor(
private val context: Context,
private val trustedSsidsProvider: () -> Set<String>,
private val onNetworkChanged: (AutoConnectManager.NetworkKind?) -> Unit,
)
{
private val handler = Handler(Looper.getMainLooper())
private val networks = ConcurrentHashMap<Network, NetworkCapabilities>()
private val ssids = ConcurrentHashMap<Network, String>()
private var registered = false
private var lastReported: AutoConnectManager.NetworkKind? = null
private var ssidRetries = 0
private val events = object
{
fun onAvailable(network: Network)
{
val cm = connectivityManager() ?: return
cm.getNetworkCapabilities(network)?.let { remember(network, it) }
scheduleEvaluate()
}
fun onLost(network: Network)
{
networks.remove(network)
ssids.remove(network)
scheduleEvaluate()
}
fun onCapabilitiesChanged(network: Network, capabilities: NetworkCapabilities)
{
if (capabilities.hasTransport(NetworkCapabilities.TRANSPORT_VPN))
{
networks.remove(network)
ssids.remove(network)
}
else
{
remember(network, capabilities)
}
scheduleEvaluate()
}
}
private val callback: ConnectivityManager.NetworkCallback = createCallback()
fun start()
{
if (registered)
{
return
}
val cm = connectivityManager() ?: return
val request = NetworkRequest.Builder()
.addCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN)
.build()
try
{
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O)
{
cm.registerNetworkCallback(request, callback, handler)
}
else
{
cm.registerNetworkCallback(request, callback)
}
registered = true
snapshotExisting(cm)
scheduleEvaluate()
}
catch (e: Exception)
{
Log.e(TAG, "Failed to register network callback", e)
}
}
fun stop()
{
if (!registered)
{
return
}
handler.removeCallbacks(evaluateRunnable)
try
{
connectivityManager()?.unregisterNetworkCallback(callback)
}
catch (_: Exception)
{
}
registered = false
networks.clear()
ssids.clear()
lastReported = null
ssidRetries = 0
}
fun forceEvaluate()
{
lastReported = null
ssidRetries = 0
handler.removeCallbacks(evaluateRunnable)
handler.post(evaluateRunnable)
}
fun currentKind(): AutoConnectManager.NetworkKind?
{
var wifiNetwork: Network? = null
var wifiCaps: NetworkCapabilities? = null
var cellular = false
for ((network, caps) in networks)
{
if (caps.hasTransport(NetworkCapabilities.TRANSPORT_VPN))
{
continue
}
if (caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI))
{
wifiNetwork = network
wifiCaps = caps
}
else if (caps.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR))
{
cellular = true
}
}
if (wifiNetwork != null && wifiCaps != null)
{
val ssid = ssidFor(wifiNetwork, wifiCaps)
val trusted = trustedSsidsProvider()
Log.i(TAG, "Underlying Wi-Fi ssid=$ssid trusted=$trusted")
if (ssid == null)
{
return AutoConnectManager.NetworkKind.UNKNOWN_WIFI
}
return if (trusted.any { it.equals(ssid, ignoreCase = true) })
{
AutoConnectManager.NetworkKind.TRUSTED_WIFI
}
else
{
AutoConnectManager.NetworkKind.UNTRUSTED_WIFI
}
}
if (cellular)
{
return AutoConnectManager.NetworkKind.CELLULAR
}
return null
}
private fun remember(network: Network, caps: NetworkCapabilities)
{
networks[network] = caps
SsidReader.fromWifiInfo(caps.transportInfo as? WifiInfo)?.let { ssids[network] = it }
}
private fun ssidFor(network: Network, caps: NetworkCapabilities): String?
{
ssids[network]?.let { return it }
return SsidReader.from(context, caps)
}
private fun snapshotExisting(cm: ConnectivityManager)
{
try
{
for (network in cm.allNetworks)
{
val caps = cm.getNetworkCapabilities(network) ?: continue
if (caps.hasTransport(NetworkCapabilities.TRANSPORT_VPN))
{
continue
}
if (caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) ||
caps.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR))
{
remember(network, caps)
}
}
}
catch (e: Exception)
{
Log.w(TAG, "Failed to snapshot networks", e)
}
}
private fun connectivityManager(): ConnectivityManager? =
context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
private fun createCallback(): ConnectivityManager.NetworkCallback
{
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S)
{
return object : ConnectivityManager.NetworkCallback(
ConnectivityManager.NetworkCallback.FLAG_INCLUDE_LOCATION_INFO
)
{
override fun onAvailable(network: Network) = events.onAvailable(network)
override fun onLost(network: Network) = events.onLost(network)
override fun onCapabilitiesChanged(network: Network, capabilities: NetworkCapabilities) =
events.onCapabilitiesChanged(network, capabilities)
}
}
return object : ConnectivityManager.NetworkCallback()
{
override fun onAvailable(network: Network) = events.onAvailable(network)
override fun onLost(network: Network) = events.onLost(network)
override fun onCapabilitiesChanged(network: Network, capabilities: NetworkCapabilities) =
events.onCapabilitiesChanged(network, capabilities)
}
}
private fun scheduleEvaluate()
{
handler.removeCallbacks(evaluateRunnable)
handler.postDelayed(evaluateRunnable, DEBOUNCE_MS)
}
private fun evaluateNow()
{
val kind = currentKind()
if (kind == AutoConnectManager.NetworkKind.UNKNOWN_WIFI && ssidRetries < SSID_RETRY_MAX)
{
ssidRetries++
Log.i(TAG, "Wi-Fi SSID not readable yet, retry $ssidRetries/$SSID_RETRY_MAX")
handler.postDelayed(evaluateRunnable, SSID_RETRY_MS)
return
}
ssidRetries = 0
if (kind != lastReported)
{
Log.i(TAG, "Network kind $lastReported -> $kind")
lastReported = kind
onNetworkChanged(kind)
}
}
private val evaluateRunnable = Runnable { evaluateNow() }
companion object
{
private const val TAG = "NetworkMonitor"
private const val DEBOUNCE_MS = 1500L
private const val SSID_RETRY_MS = 400L
private const val SSID_RETRY_MAX = 8
}
}
@@ -0,0 +1,75 @@
/*
* 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.autoconnect
import android.content.Context
import android.net.NetworkCapabilities
import android.net.wifi.WifiInfo
import android.net.wifi.WifiManager
import android.os.Build
/**
* Reads the current Wi-Fi SSID.
*
* [WifiManager.connectionInfo] returns `"<unknown ssid>"` in the background on
* Android 10+ unless the app is in the foreground or holds background location.
* [NetworkCapabilities.transportInfo] is similarly redacted unless the
* [android.net.ConnectivityManager.NetworkCallback] was registered with
* `FLAG_INCLUDE_LOCATION_INFO` (API 31+).
*/
object SsidReader
{
private const val UNKNOWN = "<unknown ssid>"
fun from(context: Context, caps: NetworkCapabilities? = null): String?
{
fromWifiInfo(caps?.transportInfo as? WifiInfo)?.let { return it }
@Suppress("DEPRECATION")
val info = try
{
val wifi = context.applicationContext.getSystemService(Context.WIFI_SERVICE) as? WifiManager
wifi?.connectionInfo
}
catch (_: Exception)
{
null
}
return fromWifiInfo(info)
}
fun fromWifiInfo(info: WifiInfo?): String?
{
if (info == null)
{
return null
}
normalize(info.ssid)?.let { return it }
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R)
{
normalize(info.passpointProviderFriendlyName)?.let { return it }
}
return null
}
fun normalize(raw: String?): String?
{
val ssid = raw?.removeSurrounding("\"")?.trim() ?: return null
if (ssid.isEmpty() || ssid.equals(UNKNOWN, ignoreCase = true) || ssid == "0x")
{
return null
}
return ssid
}
}
@@ -1,92 +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;
import android.content.Intent;
import android.content.pm.PackageManager.NameNotFoundException;
import android.os.Bundle;
import android.view.Menu;
import android.view.MenuItem;
import android.widget.Toast;
import org.strongswan.android.R;
import org.strongswan.android.data.LogContentProvider;
import org.strongswan.android.logic.CharonVpnService;
import org.strongswan.android.utils.Utils;
import java.io.File;
import androidx.appcompat.app.AppCompatActivity;
import androidx.core.view.WindowCompat;
public class LogActivity extends AppCompatActivity
{
@Override
public void onCreate(Bundle savedInstanceState)
{
super.onCreate(savedInstanceState);
setContentView(R.layout.log_activity);
WindowCompat.enableEdgeToEdge(getWindow());
Utils.applyWindowInsetsAsMarginsForLists(findViewById(R.id.layout));
getSupportActionBar().setDisplayHomeAsUpEnabled(true);
}
@Override
public boolean onCreateOptionsMenu(Menu menu)
{
getMenuInflater().inflate(R.menu.log, menu);
return true;
}
@Override
public boolean onOptionsItemSelected(MenuItem item)
{
switch (item.getItemId())
{
case android.R.id.home:
finish();
return true;
case R.id.menu_send_log:
File logfile = new File(getFilesDir(), CharonVpnService.LOG_FILE);
if (!logfile.exists() || logfile.length() == 0)
{
Toast.makeText(this, getString(R.string.empty_log), Toast.LENGTH_SHORT).show();
return true;
}
String version = "";
try
{
version = getPackageManager().getPackageInfo(getPackageName(), 0).versionName;
}
catch (NameNotFoundException e)
{
e.printStackTrace();
}
Intent intent = new Intent(Intent.ACTION_SEND);
intent.putExtra(Intent.EXTRA_EMAIL, new String[]{MainActivity.CONTACT_EMAIL});
intent.putExtra(Intent.EXTRA_SUBJECT, String.format(getString(R.string.log_mail_subject), version));
intent.setType("text/plain");
intent.putExtra(Intent.EXTRA_STREAM, LogContentProvider.createContentUri());
startActivity(Intent.createChooser(intent, getString(R.string.send_log)));
return true;
}
return super.onOptionsItemSelected(item);
}
}
@@ -0,0 +1,210 @@
/*
* Copyright (C) 2026 SHX VPN
*
* This program is free software; you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the
* Free Software Foundation; either version 2 of the License, or (at your
* option) any later version.
*/
package org.strongswan.android.ui
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Bundle
import android.widget.Toast
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Share
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.ViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.compose.viewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
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.ui.compose.SecondaryScaffold
import org.strongswan.android.ui.compose.theme.ShxTheme
import java.io.BufferedReader
import java.io.File
import java.io.FileReader
class LogActivity : AppCompatActivity()
{
override fun onCreate(savedInstanceState: Bundle?)
{
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
ShxTheme {
LogScreen(onBack = { finish() }, onSend = { sendLog() })
}
}
}
private fun sendLog()
{
val logfile = File(filesDir, CharonVpnService.LOG_FILE)
if (!logfile.exists() || logfile.length() == 0L)
{
Toast.makeText(this, R.string.empty_log, Toast.LENGTH_SHORT).show()
return
}
var version = ""
try
{
version = packageManager.getPackageInfo(packageName, 0).versionName ?: ""
}
catch (_: PackageManager.NameNotFoundException)
{
}
val intent = Intent(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())
startActivity(Intent.createChooser(intent, getString(R.string.send_log)))
}
}
class LogViewModel : ViewModel()
{
private val _lines = MutableStateFlow<List<String>>(emptyList())
val lines: StateFlow<List<String>> = _lines
private var started = false
fun start(file: File)
{
if (started) return
started = true
viewModelScope.launch(Dispatchers.IO) {
val buffer = ArrayList<String>()
var reader: BufferedReader? = null
try
{
reader = if (file.exists()) BufferedReader(FileReader(file)) else null
while (isActive)
{
val line = reader?.readLine()
if (line == null)
{
if (buffer.isNotEmpty())
{
val batch = buffer.toList()
buffer.clear()
withContext(Dispatchers.Main) {
_lines.value = _lines.value + batch
}
}
delay(800)
}
else
{
buffer.add(line)
if (buffer.size >= 50)
{
val batch = buffer.toList()
buffer.clear()
withContext(Dispatchers.Main) {
_lines.value = _lines.value + batch
}
}
}
}
}
catch (_: Exception)
{
}
finally
{
try { reader?.close() } catch (_: Exception) {}
}
}
}
}
@Composable
private fun LogScreen(onBack: () -> Unit, onSend: () -> Unit, viewModel: LogViewModel = viewModel())
{
val context = LocalContext.current
val lines by viewModel.lines.collectAsStateWithLifecycle()
val listState = rememberLazyListState()
LaunchedEffect(Unit) {
viewModel.start(File(context.filesDir, CharonVpnService.LOG_FILE))
}
LaunchedEffect(lines.size) {
if (lines.isNotEmpty())
{
listState.scrollToItem(lines.lastIndex)
}
}
SecondaryScaffold(
title = stringResource(R.string.log_title),
onBack = onBack,
actions = {
IconButton(onClick = onSend) {
Icon(Icons.Default.Share, contentDescription = stringResource(R.string.send_log))
}
},
) { padding ->
if (lines.isEmpty())
{
Text(
stringResource(R.string.empty_log),
modifier = Modifier.padding(padding).padding(24.dp),
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
else
{
LazyColumn(
state = listState,
modifier = Modifier.fillMaxSize().padding(padding).padding(horizontal = 12.dp),
) {
items(lines.size) { index ->
val line = lines[index]
val isError = line.contains(" ERROR ", ignoreCase = false)
Text(
line,
style = MaterialTheme.typography.bodySmall.copy(
fontSize = 12.sp,
fontFamily = FontFamily.Monospace,
),
color = if (isError) MaterialTheme.colorScheme.error
else MaterialTheme.colorScheme.onSurface,
modifier = Modifier.padding(vertical = 2.dp),
)
}
}
}
}
}
@@ -1,7 +1,5 @@
/*
* Copyright (C) 2012-2018 Tobias Brunner
* Copyright (C) 2012 Giuliano Grassi
* Copyright (C) 2012 Ralf Sager
*
* Copyright (C) secunet Security Networks AG
*
@@ -18,217 +16,25 @@
package org.strongswan.android.ui;
import android.app.Dialog;
import android.content.DialogInterface;
import android.content.Intent;
import android.os.Bundle;
import android.text.format.Formatter;
import android.view.Menu;
import android.view.MenuItem;
import android.widget.Toast;
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.logic.StrongSwanApplication;
import org.strongswan.android.logic.TrustedCertificateManager;
import org.strongswan.android.ui.VpnProfileListFragment.OnVpnProfileSelectedListener;
import org.strongswan.android.utils.Utils;
import org.strongswan.android.ui.compose.ShxAppKt;
import java.io.File;
import java.util.ArrayList;
import java.util.List;
import androidx.appcompat.app.ActionBar;
import androidx.appcompat.app.AlertDialog;
import androidx.appcompat.app.AppCompatActivity;
import androidx.appcompat.app.AppCompatDialogFragment;
import androidx.core.view.WindowCompat;
import androidx.fragment.app.Fragment;
import androidx.fragment.app.FragmentManager;
import androidx.fragment.app.FragmentTransaction;
public class MainActivity extends AppCompatActivity implements OnVpnProfileSelectedListener
public class MainActivity extends AppCompatActivity
{
public static final String CONTACT_EMAIL = "android@strongswan.org";
public static final String EXTRA_CRL_LIST = "org.strongswan.android.CRL_LIST";
/**
* Use "bring your own device" (BYOD) features
*/
public static final boolean USE_BYOD = true;
private static final String DIALOG_TAG = "Dialog";
private ManagedConfigurationService mManagedConfigurationService;
@Override
public void onCreate(Bundle savedInstanceState)
{
super.onCreate(savedInstanceState);
setContentView(R.layout.main);
WindowCompat.enableEdgeToEdge(getWindow());
Utils.applyWindowInsetsAsMarginsForLists(findViewById(R.id.layout));
ActionBar bar = getSupportActionBar();
bar.setDisplayShowHomeEnabled(true);
bar.setDisplayShowTitleEnabled(false);
bar.setIcon(R.mipmap.ic_app);
/* load CA certificates in a background thread */
((StrongSwanApplication)getApplication()).getExecutor().execute(() -> {
TrustedCertificateManager.getInstance().load();
});
mManagedConfigurationService = StrongSwanApplication.getInstance().getManagedConfigurationService();
}
@Override
public boolean onCreateOptionsMenu(Menu menu)
{
getMenuInflater().inflate(R.menu.main, menu);
return true;
}
@Override
public boolean onPrepareOptionsMenu(Menu menu)
{
final MenuItem importProfile = menu.findItem(R.id.menu_import_profile);
if (importProfile != null)
{
final ManagedConfiguration managedConfiguration = mManagedConfigurationService.getManagedConfiguration();
importProfile.setVisible(managedConfiguration.isAllowProfileImport());
importProfile.setEnabled(managedConfiguration.isAllowProfileImport());
}
return true;
}
@Override
public boolean onOptionsItemSelected(MenuItem item)
{
switch (item.getItemId())
{
case R.id.menu_import_profile:
Intent intent = new Intent(this, VpnProfileImportActivity.class);
startActivity(intent);
return true;
case R.id.menu_manage_certs:
Intent certIntent = new Intent(this, TrustedCertificatesActivity.class);
startActivity(certIntent);
return true;
case R.id.menu_crl_cache:
clearCRLs();
return true;
case R.id.menu_show_log:
Intent logIntent = new Intent(this, LogActivity.class);
startActivity(logIntent);
return true;
case R.id.menu_settings:
Intent settingsIntent = new Intent(this, SettingsActivity.class);
startActivity(settingsIntent);
return true;
default:
return super.onOptionsItemSelected(item);
}
}
@Override
public void onVpnProfileSelected(VpnProfile profile)
{
Intent intent = new Intent(this, VpnProfileControlActivity.class);
intent.setAction(VpnProfileControlActivity.START_PROFILE);
intent.putExtra(VpnProfileControlActivity.EXTRA_VPN_PROFILE_UUID, profile.getUUID().toString());
startActivity(intent);
}
/**
* Ask the user whether to clear the CRL cache.
*/
private void clearCRLs()
{
final String FILE_PREFIX = "crl-";
ArrayList<String> list = new ArrayList<>();
for (String file : fileList())
{
if (file.startsWith(FILE_PREFIX))
{
list.add(file);
}
}
if (list.size() == 0)
{
Toast.makeText(this, R.string.clear_crl_cache_msg_none, Toast.LENGTH_SHORT).show();
return;
}
removeFragmentByTag(DIALOG_TAG);
Bundle args = new Bundle();
args.putStringArrayList(EXTRA_CRL_LIST, list);
CRLCacheDialog dialog = new CRLCacheDialog();
dialog.setArguments(args);
dialog.show(this.getSupportFragmentManager(), DIALOG_TAG);
}
/**
* Dismiss dialog if shown
*/
public void removeFragmentByTag(String tag)
{
FragmentManager fm = getSupportFragmentManager();
Fragment login = fm.findFragmentByTag(tag);
if (login != null)
{
FragmentTransaction ft = fm.beginTransaction();
ft.remove(login);
ft.commit();
}
}
/**
* Confirmation dialog to clear CRL cache
*/
public static class CRLCacheDialog extends AppCompatDialogFragment
{
@Override
public Dialog onCreateDialog(Bundle savedInstanceState)
{
final List<String> list = getArguments().getStringArrayList(EXTRA_CRL_LIST);
String size;
long s = 0;
for (String file : list)
{
File crl = getActivity().getFileStreamPath(file);
s += crl.length();
}
size = Formatter.formatFileSize(getActivity(), s);
AlertDialog.Builder builder = new AlertDialog.Builder(getActivity())
.setTitle(R.string.clear_crl_cache_title)
.setNegativeButton(android.R.string.cancel, new DialogInterface.OnClickListener()
{
@Override
public void onClick(DialogInterface dialog, int which)
{
dismiss();
}
})
.setPositiveButton(R.string.clear, new DialogInterface.OnClickListener()
{
@Override
public void onClick(DialogInterface dialog, int whichButton)
{
for (String file : list)
{
getActivity().deleteFile(file);
}
}
});
builder.setMessage(getActivity().getResources().getQuantityString(R.plurals.clear_crl_cache_msg, list.size(), list.size(), size));
return builder.create();
}
ShxAppKt.installShxUi(this);
}
}
@@ -1,88 +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.content.Intent;
import android.os.Bundle;
import android.view.MenuItem;
import org.strongswan.android.R;
import org.strongswan.android.data.VpnProfileDataSource;
import org.strongswan.android.utils.Utils;
import androidx.activity.OnBackPressedCallback;
import androidx.annotation.Nullable;
import androidx.appcompat.app.ActionBar;
import androidx.appcompat.app.AppCompatActivity;
import androidx.core.view.WindowCompat;
import androidx.fragment.app.FragmentManager;
public class SelectedApplicationsActivity extends AppCompatActivity
{
private static final String LIST_TAG = "ApplicationList";
private SelectedApplicationsListFragment mApps;
@Override
protected void onCreate(@Nullable Bundle savedInstanceState)
{
super.onCreate(savedInstanceState);
setContentView(R.layout.selected_applications_activity);
WindowCompat.enableEdgeToEdge(getWindow());
Utils.applyWindowInsetsAsMarginsForLists(findViewById(R.id.fragment_container));
ActionBar actionBar = getSupportActionBar();
actionBar.setDisplayHomeAsUpEnabled(true);
getOnBackPressedDispatcher().addCallback(this, new OnBackPressedCallback(true)
{
@Override
public void handleOnBackPressed()
{
prepareResult();
finish();
}
});
FragmentManager fm = getSupportFragmentManager();
mApps = (SelectedApplicationsListFragment)fm.findFragmentByTag(LIST_TAG);
if (mApps == null)
{
mApps = new SelectedApplicationsListFragment();
fm.beginTransaction().add(R.id.fragment_container, mApps, LIST_TAG).commit();
}
}
@Override
public boolean onOptionsItemSelected(MenuItem item)
{
switch (item.getItemId())
{
case android.R.id.home:
prepareResult();
finish();
return true;
}
return super.onOptionsItemSelected(item);
}
private void prepareResult()
{
Intent data = new Intent();
data.putExtra(VpnProfileDataSource.KEY_SELECTED_APPS_LIST, mApps.getSelectedApplications());
setResult(RESULT_OK, data);
}
}
@@ -0,0 +1,190 @@
/*
* Copyright (C) 2026 SHX VPN
*
* This program is free software; you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the
* Free Software Foundation; either version 2 of the License, or (at your
* option) any later version.
*/
package org.strongswan.android.ui
import android.Manifest
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Bundle
import androidx.activity.OnBackPressedCallback
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.Checkbox
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ListItem
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Column
import androidx.core.graphics.drawable.toBitmap
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.strongswan.android.R
import org.strongswan.android.data.VpnProfileDataSource
import org.strongswan.android.ui.adapter.SelectedApplicationEntry
import org.strongswan.android.ui.compose.SecondaryScaffold
import org.strongswan.android.ui.compose.theme.ShxTheme
import java.util.TreeSet
class SelectedApplicationsActivity : AppCompatActivity()
{
private val selection = TreeSet<String>()
private var readOnly = false
override fun onCreate(savedInstanceState: Bundle?)
{
super.onCreate(savedInstanceState)
enableEdgeToEdge()
readOnly = intent.getBooleanExtra(VpnProfileDataSource.KEY_READ_ONLY, false)
val initial = savedInstanceState?.getStringArrayList(VpnProfileDataSource.KEY_SELECTED_APPS_LIST)
?: intent.getStringArrayListExtra(VpnProfileDataSource.KEY_SELECTED_APPS_LIST)
?: arrayListOf()
selection.addAll(initial)
onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true)
{
override fun handleOnBackPressed()
{
prepareResult()
finish()
}
})
setContent {
ShxTheme {
SelectedAppsScreen(
initial = selection,
readOnly = readOnly,
onBack = {
prepareResult()
finish()
},
onChange = {
selection.clear()
selection.addAll(it)
},
)
}
}
}
override fun onSaveInstanceState(outState: Bundle)
{
super.onSaveInstanceState(outState)
outState.putStringArrayList(VpnProfileDataSource.KEY_SELECTED_APPS_LIST, ArrayList(selection))
}
private fun prepareResult()
{
val data = Intent()
data.putExtra(VpnProfileDataSource.KEY_SELECTED_APPS_LIST, ArrayList(selection))
setResult(RESULT_OK, data)
}
}
@Composable
private fun SelectedAppsScreen(
initial: Set<String>,
readOnly: Boolean,
onBack: () -> Unit,
onChange: (Set<String>) -> Unit,
)
{
val context = LocalContext.current
var query by remember { mutableStateOf("") }
var apps by remember { mutableStateOf<List<SelectedApplicationEntry>>(emptyList()) }
var selected by remember { mutableStateOf(initial.toSet()) }
var loading by remember { mutableStateOf(true) }
LaunchedEffect(Unit) {
val loaded = withContext(Dispatchers.IO) {
val pm = context.packageManager
val list = ArrayList<SelectedApplicationEntry>()
for (info in pm.getInstalledApplications(PackageManager.GET_META_DATA))
{
if (pm.checkPermission(Manifest.permission.INTERNET, info.packageName) == PackageManager.PERMISSION_GRANTED)
{
val entry = SelectedApplicationEntry(pm, info)
entry.isSelected = selected.contains(info.packageName)
list.add(entry)
}
}
list.sorted()
}
apps = loaded
loading = false
}
fun toggle(pkg: String)
{
if (readOnly) return
selected = if (selected.contains(pkg)) selected - pkg else selected + pkg
onChange(selected)
}
val filtered = remember(apps, query) {
if (query.isBlank()) apps
else apps.filter { it.toString().contains(query, ignoreCase = true) || it.info.packageName.contains(query, ignoreCase = true) }
}
SecondaryScaffold(title = stringResource(R.string.profile_select_apps), onBack = onBack) { padding ->
Column(Modifier.fillMaxSize().padding(padding)) {
OutlinedTextField(
value = query,
onValueChange = { query = it },
label = { Text(stringResource(R.string.search)) },
singleLine = true,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp).fillMaxWidth(),
)
if (loading)
{
CircularProgressIndicator(Modifier.padding(24.dp).size(32.dp))
}
else
{
LazyColumn(Modifier.fillMaxSize()) {
items(filtered, key = { it.info.packageName }) { entry ->
val pkg = entry.info.packageName
val checked = selected.contains(pkg)
ListItem(
headlineContent = { Text(entry.toString()) },
supportingContent = { Text(pkg) },
leadingContent = {
val bmp = remember(pkg) { entry.icon.toBitmap() }
Image(bitmap = bmp.asImageBitmap(), contentDescription = null, modifier = Modifier.size(40.dp))
},
trailingContent = {
Checkbox(checked = checked, onCheckedChange = { toggle(pkg) }, enabled = !readOnly)
},
modifier = Modifier.clickable(enabled = !readOnly) { toggle(pkg) },
)
}
}
}
}
}
}
@@ -1,60 +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 android.os.Bundle;
import android.view.MenuItem;
import org.strongswan.android.R;
import androidx.appcompat.app.AppCompatActivity;
import androidx.core.view.WindowCompat;
public class SettingsActivity extends AppCompatActivity
{
@Override
protected void onCreate(Bundle savedInstanceState)
{
super.onCreate(savedInstanceState);
setContentView(R.layout.settings_activity);
WindowCompat.enableEdgeToEdge(getWindow());
getSupportActionBar().setDisplayHomeAsUpEnabled(true);
if (savedInstanceState == null)
{
getSupportFragmentManager().beginTransaction()
.setReorderingAllowed(true)
.add(R.id.fragment_container, SettingsFragment.class, null)
.commit();
}
}
@Override
public boolean onOptionsItemSelected(MenuItem item)
{
switch (item.getItemId())
{
case android.R.id.home:
finish();
return true;
default:
return super.onOptionsItemSelected(item);
}
}
}
@@ -0,0 +1,45 @@
/*
* Copyright (C) 2026 SHX VPN
*
* This program is free software; you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the
* Free Software Foundation; either version 2 of the License, or (at your
* option) any later version.
*/
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.data.VpnProfileSource
import org.strongswan.android.ui.compose.SecondaryScaffold
import org.strongswan.android.ui.compose.SettingsScreen
import org.strongswan.android.ui.compose.theme.ShxTheme
class SettingsActivity : AppCompatActivity()
{
override fun onCreate(savedInstanceState: Bundle?)
{
super.onCreate(savedInstanceState)
enableEdgeToEdge()
val source = VpnProfileSource(this).open()
val profiles = source.allVpnProfiles
source.close()
setContent {
ShxTheme {
SecondaryScaffold(title = stringResource(R.string.pref_title), onBack = { finish() }) { padding ->
Box(Modifier.padding(padding)) {
SettingsScreen(profiles = profiles, showTitle = false)
}
}
}
}
}
}
@@ -1,238 +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.Intent;
import android.os.Bundle;
import android.view.Menu;
import android.view.MenuItem;
import com.google.android.material.tabs.TabLayout;
import com.google.android.material.tabs.TabLayoutMediator;
import org.strongswan.android.R;
import org.strongswan.android.data.ManagedConfiguration;
import org.strongswan.android.data.ManagedConfigurationService;
import org.strongswan.android.data.VpnProfileDataSource;
import org.strongswan.android.logic.StrongSwanApplication;
import org.strongswan.android.logic.TrustedCertificateManager;
import org.strongswan.android.logic.TrustedCertificateManager.TrustedCertificateSource;
import org.strongswan.android.security.TrustedCertificateEntry;
import org.strongswan.android.ui.CertificateDeleteConfirmationDialog.OnCertificateDeleteListener;
import java.security.KeyStore;
import androidx.activity.result.ActivityResultLauncher;
import androidx.activity.result.contract.ActivityResultContracts;
import androidx.annotation.NonNull;
import androidx.appcompat.app.ActionBar;
import androidx.appcompat.app.AppCompatActivity;
import androidx.core.view.WindowCompat;
import androidx.fragment.app.Fragment;
import androidx.fragment.app.FragmentActivity;
import androidx.viewpager2.adapter.FragmentStateAdapter;
import androidx.viewpager2.widget.ViewPager2;
public class TrustedCertificatesActivity extends AppCompatActivity implements TrustedCertificateListFragment.OnTrustedCertificateSelectedListener, OnCertificateDeleteListener
{
public static final String SELECT_CERTIFICATE = "org.strongswan.android.action.SELECT_CERTIFICATE";
private static final String DIALOG_TAG = "Dialog";
private TrustedCertificatesPagerAdapter mAdapter;
private ViewPager2 mPager;
private boolean mSelect;
private ManagedConfigurationService mManagedConfigurationService;
private final ActivityResultLauncher<Intent> mImportCertificate = registerForActivityResult(
new ActivityResultContracts.StartActivityForResult(),
result -> {
if (result.getResultCode() == RESULT_OK)
{
reloadCertificates();
}
}
);
@Override
public void onCreate(Bundle savedInstanceState)
{
super.onCreate(savedInstanceState);
setContentView(R.layout.trusted_certificates_activity);
WindowCompat.enableEdgeToEdge(getWindow());
ActionBar actionBar = getSupportActionBar();
actionBar.setDisplayHomeAsUpEnabled(true);
mAdapter = new TrustedCertificatesPagerAdapter(this);
mPager = findViewById(R.id.viewpager);
mPager.setAdapter(mAdapter);
TabLayout tabs = findViewById(R.id.tabs);
new TabLayoutMediator(tabs, mPager, (tab, position) -> {
tab.setText(mAdapter.getTitle(position));
}).attach();
mSelect = SELECT_CERTIFICATE.equals(getIntent().getAction());
mManagedConfigurationService = StrongSwanApplication.getInstance().getManagedConfigurationService();
}
@Override
public boolean onCreateOptionsMenu(Menu menu)
{
getMenuInflater().inflate(R.menu.certificates, menu);
return true;
}
@Override
public boolean onPrepareOptionsMenu(Menu menu)
{
final MenuItem importCertificate = menu.findItem(R.id.menu_import_certificate);
if (importCertificate != null)
{
final ManagedConfiguration managedConfiguration = mManagedConfigurationService.getManagedConfiguration();
importCertificate.setVisible(managedConfiguration.isAllowCertificateImport());
importCertificate.setEnabled(managedConfiguration.isAllowCertificateImport());
}
return true;
}
@Override
public boolean onOptionsItemSelected(MenuItem item)
{
switch (item.getItemId())
{
case android.R.id.home:
finish();
return true;
case R.id.menu_reload_certs:
reloadCertificates();
return true;
case R.id.menu_import_certificate:
Intent intent = new Intent(this, TrustedCertificateImportActivity.class);
mImportCertificate.launch(intent);
return true;
}
return super.onOptionsItemSelected(item);
}
@Override
public void onTrustedCertificateSelected(TrustedCertificateEntry selected)
{
if (mSelect)
{
/* the user selected a certificate, return to calling activity */
Intent intent = new Intent();
intent.putExtra(VpnProfileDataSource.KEY_CERTIFICATE, selected.getAlias());
setResult(RESULT_OK, intent);
finish();
}
else if (mAdapter.getSource(mPager.getCurrentItem()) == TrustedCertificateSource.LOCAL)
{
Bundle args = new Bundle();
args.putString(CertificateDeleteConfirmationDialog.ALIAS, selected.getAlias());
CertificateDeleteConfirmationDialog dialog = new CertificateDeleteConfirmationDialog();
dialog.setArguments(args);
dialog.show(getSupportFragmentManager(), DIALOG_TAG);
}
}
@Override
public void onDelete(String alias)
{
try
{
KeyStore store = KeyStore.getInstance("LocalCertificateStore");
store.load(null, null);
store.deleteEntry(alias);
reloadCertificates();
}
catch (Exception e)
{
e.printStackTrace();
}
}
private void reloadCertificates()
{
TrustedCertificateManager.getInstance().reset();
}
public static class TrustedCertificatesPagerAdapter extends FragmentStateAdapter
{
private final TrustedCertificatesTab[] mTabs;
public TrustedCertificatesPagerAdapter(@NonNull FragmentActivity fragmentActivity)
{
super(fragmentActivity);
mTabs = new TrustedCertificatesTab[]{
new TrustedCertificatesTab(fragmentActivity.getString(R.string.system_tab), TrustedCertificateSource.SYSTEM),
new TrustedCertificatesTab(fragmentActivity.getString(R.string.user_tab), TrustedCertificateSource.USER),
new TrustedCertificatesTab(fragmentActivity.getString(R.string.local_tab), TrustedCertificateSource.LOCAL),
};
}
public CharSequence getTitle(int position)
{
return mTabs[position].getTitle();
}
public TrustedCertificateSource getSource(int position)
{
return mTabs[position].getSource();
}
@Override
public int getItemCount()
{
return mTabs.length;
}
@NonNull
@Override
public Fragment createFragment(int position)
{
TrustedCertificateListFragment fragment = new TrustedCertificateListFragment();
Bundle args = new Bundle();
args.putSerializable(TrustedCertificateListFragment.EXTRA_CERTIFICATE_SOURCE, mTabs[position].getSource());
fragment.setArguments(args);
return fragment;
}
}
public static class TrustedCertificatesTab
{
private final String mTitle;
private final TrustedCertificateSource mSource;
public TrustedCertificatesTab(String title, TrustedCertificateSource source)
{
mTitle = title;
mSource = source;
}
public String getTitle()
{
return mTitle;
}
public TrustedCertificateSource getSource()
{
return mSource;
}
}
}
@@ -0,0 +1,208 @@
/*
* Copyright (C) 2026 SHX VPN
*
* This program is free software; you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the
* Free Software Foundation; either version 2 of the License, or (at your
* option) any later version.
*/
package org.strongswan.android.ui
import android.content.Intent
import android.os.Bundle
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItem
import androidx.compose.material3.SecondaryTabRow
import androidx.compose.material3.Tab
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
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.StrongSwanApplication
import org.strongswan.android.logic.TrustedCertificateManager
import org.strongswan.android.logic.TrustedCertificateManager.TrustedCertificateSource
import org.strongswan.android.security.TrustedCertificateEntry
import org.strongswan.android.ui.compose.SecondaryScaffold
import org.strongswan.android.ui.compose.theme.ShxTheme
import java.beans.PropertyChangeListener
import java.security.KeyStore
class TrustedCertificatesActivity : AppCompatActivity()
{
companion object
{
const val SELECT_CERTIFICATE = "one.shx.strongswan.ext.action.SELECT_CERTIFICATE"
}
override fun onCreate(savedInstanceState: Bundle?)
{
super.onCreate(savedInstanceState)
enableEdgeToEdge()
val select = SELECT_CERTIFICATE == intent.action
setContent {
ShxTheme {
CertificatesScreen(
selectMode = select,
onBack = { finish() },
onPicked = { alias ->
setResult(RESULT_OK, Intent().putExtra(VpnProfileDataSource.KEY_CERTIFICATE, alias))
finish()
},
)
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun CertificatesScreen(
selectMode: Boolean,
onBack: () -> Unit,
onPicked: (String) -> Unit,
)
{
val context = LocalContext.current
val sources = remember {
listOf(
TrustedCertificateSource.SYSTEM to context.getString(R.string.system_tab),
TrustedCertificateSource.USER to context.getString(R.string.user_tab),
TrustedCertificateSource.LOCAL to context.getString(R.string.local_tab),
)
}
var tab by remember { mutableIntStateOf(0) }
var entries by remember { mutableStateOf(listOf<TrustedCertificateEntry>()) }
var pendingDelete by remember { mutableStateOf<TrustedCertificateEntry?>(null) }
val scope = rememberCoroutineScope()
val allowImport = StrongSwanApplication.getInstance().managedConfigurationService.managedConfiguration.isAllowCertificateImport
val importLauncher = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) {
if (it.resultCode == android.app.Activity.RESULT_OK)
{
scope.launch { reload(sources[tab].first) { entries = it } }
}
}
fun load()
{
scope.launch { reload(sources[tab].first) { entries = it } }
}
DisposableEffect(tab) {
load()
val observer = PropertyChangeListener { load() }
TrustedCertificateManager.getInstance().addObserver(observer)
onDispose { TrustedCertificateManager.getInstance().deleteObserver(observer) }
}
SecondaryScaffold(
title = stringResource(R.string.trusted_certs_title),
onBack = onBack,
actions = {
if (allowImport)
{
IconButton(onClick = {
importLauncher.launch(Intent(context, TrustedCertificateImportActivity::class.java))
}) { Icon(Icons.Default.Add, contentDescription = stringResource(R.string.import_certificate)) }
}
IconButton(onClick = {
scope.launch {
withContext(Dispatchers.IO) {
TrustedCertificateManager.getInstance().reset().load()
}
reload(sources[tab].first) { entries = it }
}
}) { Icon(Icons.Default.Refresh, contentDescription = stringResource(R.string.reload_trusted_certs)) }
},
) { padding ->
Column(Modifier.fillMaxSize().padding(padding)) {
SecondaryTabRow(selectedTabIndex = tab) {
sources.forEachIndexed { index, pair ->
Tab(selected = tab == index, onClick = { tab = index }, text = { Text(pair.second) })
}
}
LazyColumn(Modifier.fillMaxSize()) {
items(entries, key = { it.alias }) { entry ->
ListItem(
headlineContent = { Text(entry.subjectPrimary) },
supportingContent = { Text(entry.subjectSecondary.ifEmpty { entry.alias }) },
modifier = Modifier.clickable {
if (selectMode) onPicked(entry.alias)
else if (sources[tab].first == TrustedCertificateSource.LOCAL) pendingDelete = entry
},
)
}
}
}
}
pendingDelete?.let { entry ->
AlertDialog(
onDismissRequest = { pendingDelete = null },
title = { Text(stringResource(R.string.delete_certificate_question)) },
text = { Text(entry.subjectPrimary) },
confirmButton = {
TextButton(onClick = {
try
{
val store = KeyStore.getInstance("LocalCertificateStore")
store.load(null, null)
store.deleteEntry(entry.alias)
TrustedCertificateManager.getInstance().reset()
}
catch (_: Exception)
{
}
pendingDelete = null
load()
}) { Text(stringResource(R.string.delete_profile)) }
},
dismissButton = {
TextButton(onClick = { pendingDelete = null }) {
Text(stringResource(android.R.string.cancel))
}
},
)
}
}
private suspend fun reload(source: TrustedCertificateSource, onResult: (List<TrustedCertificateEntry>) -> Unit)
{
val list = withContext(Dispatchers.IO) {
val manager = TrustedCertificateManager.getInstance().load()
manager.getCACertificates(source).map { (alias, cert) -> TrustedCertificateEntry(alias, cert) }
.sorted()
}
onResult(list)
}
@@ -0,0 +1,54 @@
/*
* 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.data.VpnProfileSource
import org.strongswan.android.logic.autoconnect.AutoConnectManager
import org.strongswan.android.logic.autoconnect.SsidReader
import org.strongswan.android.ui.compose.SecondaryScaffold
import org.strongswan.android.ui.compose.TrustedNetworksScreen
import org.strongswan.android.ui.compose.theme.ShxTheme
class TrustedNetworksActivity : AppCompatActivity()
{
override fun onCreate(savedInstanceState: Bundle?)
{
super.onCreate(savedInstanceState)
enableEdgeToEdge()
AutoConnectManager.getInstance(this).reloadConfig()
val source = VpnProfileSource(this).open()
val profiles = source.allVpnProfiles
source.close()
val currentSsid = SsidReader.from(this)
setContent {
ShxTheme {
SecondaryScaffold(title = stringResource(R.string.auto_connect_title), onBack = { finish() }) { padding ->
Box(Modifier.padding(padding)) {
TrustedNetworksScreen(profiles = profiles, currentSsid = currentSsid)
}
}
}
}
}
}
@@ -62,10 +62,10 @@ import androidx.preference.PreferenceManager;
public class VpnProfileControlActivity extends AppCompatActivity
{
public static final String START_PROFILE = "org.strongswan.android.action.START_PROFILE";
public static final String DISCONNECT = "org.strongswan.android.action.DISCONNECT";
public static final String EXTRA_VPN_PROFILE_UUID = "org.strongswan.android.VPN_PROFILE_UUID";
private static final String EXTRA_VPN_PROFILE_ID = "org.strongswan.android.VPN_PROFILE_ID";
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 EXTRA_VPN_PROFILE_UUID = "one.shx.strongswan.ext.VPN_PROFILE_UUID";
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";
private static final String PROFILE_NAME = "PROFILE_NAME";
@@ -695,7 +695,7 @@ public class VpnProfileControlActivity extends AppCompatActivity
*/
public static class VpnNotSupportedError extends AppCompatDialogFragment
{
static final String ERROR_MESSAGE_ID = "org.strongswan.android.VpnNotSupportedError.MessageId";
static final String ERROR_MESSAGE_ID = "one.shx.strongswan.ext.VpnNotSupportedError.MessageId";
public static void showWithMessage(AppCompatActivity activity, int messageId)
{
@@ -0,0 +1,106 @@
/*
* Copyright (C) 2026 SHX VPN
*
* This program is free software; you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the
* Free Software Foundation; either version 2 of the License, or (at your
* option) any later version.
*/
package org.strongswan.android.ui
import android.content.Intent
import android.os.Bundle
import android.widget.Toast
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
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.isValid
import org.strongswan.android.ui.compose.toFormState
import org.strongswan.android.ui.compose.toProfile
import org.strongswan.android.ui.compose.validate
import org.strongswan.android.ui.compose.theme.ShxTheme
import org.strongswan.android.utils.Constants
class VpnProfileDetailActivity : AppCompatActivity()
{
private lateinit var dataSource: VpnProfileDataSource
private var existing: VpnProfile? = null
override fun onCreate(savedInstanceState: Bundle?)
{
super.onCreate(savedInstanceState)
enableEdgeToEdge()
dataSource = VpnProfileSource(this).open()
val uuid = savedInstanceState?.getString(VpnProfileDataSource.KEY_UUID)
?: intent.extras?.getString(VpnProfileDataSource.KEY_UUID)
if (uuid != null)
{
existing = dataSource.getVpnProfile(uuid)
if (existing == null)
{
finish()
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 },
onCancel = { finish() },
onSave = {
val checked = state.validate(this)
state = checked
if (!checked.isValid())
{
Toast.makeText(this, R.string.alert_text_no_input_gateway, Toast.LENGTH_SHORT).show()
return@ProfileFormScreen
}
val profile = checked.toProfile(existing)
if (existing != null) dataSource.updateVpnProfile(profile)
else dataSource.insertProfile(profile)
val intent = Intent(Constants.VPN_PROFILES_CHANGED)
intent.putExtra(Constants.VPN_PROFILES_SINGLE, profile.getUUID().toString())
LocalBroadcastManager.getInstance(this).sendBroadcast(intent)
setResult(RESULT_OK, Intent().putExtra(VpnProfileDataSource.KEY_UUID, profile.getUUID().toString()))
finish()
},
)
}
}
}
override fun onSaveInstanceState(outState: Bundle)
{
if (existing != null)
{
outState.putString(VpnProfileDataSource.KEY_UUID, existing!!.getUUID().toString())
}
super.onSaveInstanceState(outState)
}
override fun onDestroy()
{
if (::dataSource.isInitialized)
{
dataSource.close()
}
super.onDestroy()
}
}
@@ -77,6 +77,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;
@@ -198,8 +199,12 @@ public class VpnProfileImportActivity extends AppCompatActivity
{
super.onCreate(savedInstanceState);
getSupportActionBar().setHomeAsUpIndicator(R.drawable.ic_close_white_24dp);
getSupportActionBar().setDisplayHomeAsUpEnabled(true);
ActionBar bar = getSupportActionBar();
if (bar != null)
{
bar.setHomeAsUpIndicator(R.drawable.ic_close_white_24dp);
bar.setDisplayHomeAsUpEnabled(true);
}
mDataSource = new VpnProfileSource(this);
mDataSource.open();
@@ -1,407 +0,0 @@
/*
* Copyright (C) 2023 Relution GmbH
* Copyright (C) 2012-2019 Tobias Brunner
* Copyright (C) 2012 Giuliano Grassi
* Copyright (C) 2012 Ralf Sager
*
* 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.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.content.IntentFilter;
import android.content.res.TypedArray;
import android.os.Bundle;
import android.util.AttributeSet;
import android.view.ActionMode;
import android.view.LayoutInflater;
import android.view.Menu;
import android.view.MenuInflater;
import android.view.MenuItem;
import android.view.View;
import android.view.ViewGroup;
import android.widget.AbsListView.MultiChoiceModeListener;
import android.widget.AdapterView;
import android.widget.AdapterView.OnItemClickListener;
import android.widget.ListView;
import android.widget.Toast;
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 org.strongswan.android.ui.adapter.VpnProfileAdapter;
import org.strongswan.android.utils.Constants;
import org.strongswan.android.utils.Utils;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.Iterator;
import java.util.List;
import java.util.Objects;
import java.util.Set;
import java.util.UUID;
import androidx.annotation.NonNull;
import androidx.core.view.MenuProvider;
import androidx.fragment.app.Fragment;
import androidx.localbroadcastmanager.content.LocalBroadcastManager;
public class VpnProfileListFragment extends Fragment implements MenuProvider
{
private static final String SELECTED_KEY = "SELECTED";
private List<VpnProfile> mVpnProfiles;
private VpnProfileDataSource mDataSource;
private VpnProfileAdapter mListAdapter;
private ListView mListView;
private OnVpnProfileSelectedListener mListener;
private Set<Integer> mSelected;
private boolean mReadOnly;
private ManagedConfigurationService mManagedConfigurationService;
private final BroadcastReceiver mProfilesChanged = new BroadcastReceiver()
{
@Override
public void onReceive(Context context, Intent intent)
{
String uuid;
String[] uuids;
if ((uuid = intent.getStringExtra(Constants.VPN_PROFILES_SINGLE)) != null)
{
VpnProfile profile = mDataSource.getVpnProfile(uuid);
if (profile != null)
{ /* in case this was an edit, we remove it first */
mVpnProfiles.remove(profile);
mVpnProfiles.add(profile);
mListAdapter.notifyDataSetChanged();
}
}
else if ((uuids = intent.getStringArrayExtra(Constants.VPN_PROFILES_MULTIPLE)) != null)
{
for (final String id : uuids)
{
final Iterator<VpnProfile> profiles = mVpnProfiles.iterator();
while (profiles.hasNext())
{
final VpnProfile profile = profiles.next();
if (Objects.equals(profile.getUUID().toString(), id))
{ /* in case this was an edit, we remove it first */
profiles.remove();
break;
}
}
VpnProfile profile = mDataSource.getVpnProfile(id);
if (profile != null)
{
mVpnProfiles.add(profile);
}
}
mListAdapter.notifyDataSetChanged();
}
}
};
/**
* The activity containing this fragment should implement this interface
*/
public interface OnVpnProfileSelectedListener
{
void onVpnProfileSelected(VpnProfile profile);
}
@Override
public void onInflate(Context context, AttributeSet attrs, Bundle savedInstanceState)
{
super.onInflate(context, attrs, savedInstanceState);
TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.Fragment);
mReadOnly = a.getBoolean(R.styleable.Fragment_read_only, false);
a.recycle();
}
@Override
public View onCreateView(LayoutInflater inflater, ViewGroup container,
Bundle savedInstanceState)
{
View view = inflater.inflate(R.layout.profile_list_fragment, null);
mListView = view.findViewById(R.id.profile_list);
mListView.setAdapter(mListAdapter);
mListView.setEmptyView(view.findViewById(R.id.profile_list_empty));
mListView.setOnItemClickListener(mVpnProfileClicked);
Utils.applyWindowInsetsAsPaddingForLists(mListView);
if (!mReadOnly)
{
requireActivity().addMenuProvider(this, getViewLifecycleOwner());
mListView.setChoiceMode(ListView.CHOICE_MODE_MULTIPLE_MODAL);
mListView.setMultiChoiceModeListener(mVpnProfileSelected);
}
return view;
}
@Override
public void onCreate(Bundle savedInstanceState)
{
super.onCreate(savedInstanceState);
Bundle args = getArguments();
if (args != null)
{
mReadOnly = args.getBoolean("read_only", mReadOnly);
}
if (!mReadOnly)
{
ArrayList<Integer> selected = null;
if (savedInstanceState != null)
{
selected = savedInstanceState.getIntegerArrayList(SELECTED_KEY);
}
mSelected = selected != null ? new HashSet<>(selected) : new HashSet<>();
}
mDataSource = new VpnProfileSource(this.getActivity());
mDataSource.open();
mManagedConfigurationService = StrongSwanApplication.getInstance().getManagedConfigurationService();
/* cached list of profiles used as backend for the ListView */
mVpnProfiles = mDataSource.getAllVpnProfiles();
mListAdapter = new VpnProfileAdapter(getActivity(), R.layout.profile_list_item, mVpnProfiles);
IntentFilter profileChangesFilter = new IntentFilter(Constants.VPN_PROFILES_CHANGED);
LocalBroadcastManager.getInstance(getActivity()).registerReceiver(mProfilesChanged, profileChangesFilter);
}
@Override
public void onSaveInstanceState(Bundle outState)
{
super.onSaveInstanceState(outState);
if (!mReadOnly)
{
outState.putIntegerArrayList(SELECTED_KEY, new ArrayList<>(mSelected));
}
}
@Override
public void onDestroy()
{
super.onDestroy();
mDataSource.close();
LocalBroadcastManager.getInstance(getActivity()).unregisterReceiver(mProfilesChanged);
}
@Override
public void onAttach(Context context)
{
super.onAttach(context);
if (context instanceof OnVpnProfileSelectedListener)
{
mListener = (OnVpnProfileSelectedListener)context;
}
}
@Override
public void onCreateMenu(@NonNull Menu menu, @NonNull MenuInflater menuInflater)
{
menuInflater.inflate(R.menu.profile_list, menu);
}
@Override
public void onPrepareMenu(@NonNull Menu menu)
{
final MenuItem addProfile = menu.findItem(R.id.add_profile);
if (addProfile != null)
{
final ManagedConfiguration managedConfiguration = mManagedConfigurationService.getManagedConfiguration();
addProfile.setVisible(managedConfiguration.isAllowProfileCreation());
addProfile.setEnabled(managedConfiguration.isAllowProfileCreation());
}
}
@Override
public boolean onMenuItemSelected(@NonNull MenuItem menuItem)
{
if (menuItem.getItemId() == R.id.add_profile)
{
Intent connectionIntent = new Intent(getActivity(),
VpnProfileDetailActivity.class);
startActivity(connectionIntent);
return true;
}
return false;
}
private final OnItemClickListener mVpnProfileClicked = new OnItemClickListener()
{
@Override
public void onItemClick(AdapterView<?> a, View v, int position, long id)
{
if (mListener != null)
{
mListener.onVpnProfileSelected((VpnProfile)a.getItemAtPosition(position));
}
}
};
private final MultiChoiceModeListener mVpnProfileSelected = new MultiChoiceModeListener()
{
private MenuItem mEditProfile;
private MenuItem mCopyProfile;
private MenuItem mDeleteProfile;
private boolean mCanEdit;
private boolean mCanCopy;
private boolean mCanDelete;
private int mReadOnlyCount;
@Override
public boolean onPrepareActionMode(ActionMode mode, Menu menu)
{
mEditProfile.setEnabled(mCanEdit);
mCopyProfile.setEnabled(mCanCopy);
mDeleteProfile.setEnabled(mCanDelete);
return true;
}
@Override
public void onDestroyActionMode(ActionMode mode)
{
mReadOnlyCount = 0;
mSelected.clear();
}
@Override
public boolean onCreateActionMode(ActionMode mode, Menu menu)
{
MenuInflater inflater = mode.getMenuInflater();
inflater.inflate(R.menu.profile_list_context, menu);
mEditProfile = menu.findItem(R.id.edit_profile);
mCopyProfile = menu.findItem(R.id.copy_profile);
mDeleteProfile = menu.findItem(R.id.delete_profile);
mode.setTitle(R.string.select_profiles);
return true;
}
@Override
public boolean onActionItemClicked(ActionMode mode, MenuItem item)
{
switch (item.getItemId())
{
case R.id.edit_profile:
{
int position = mSelected.iterator().next();
VpnProfile profile = (VpnProfile)mListView.getItemAtPosition(position);
Intent connectionIntent = new Intent(getActivity(), VpnProfileDetailActivity.class);
connectionIntent.putExtra(VpnProfileDataSource.KEY_UUID, profile.getUUID().toString());
startActivity(connectionIntent);
break;
}
case R.id.copy_profile:
{
int position = mSelected.iterator().next();
VpnProfile profile = (VpnProfile)mListView.getItemAtPosition(position);
profile = profile.clone();
profile.setUUID(UUID.randomUUID());
profile.setName(String.format(getString(R.string.copied_name), profile.getName()));
mDataSource.insertProfile(profile);
Intent intent = new Intent(Constants.VPN_PROFILES_CHANGED);
intent.putExtra(Constants.VPN_PROFILES_SINGLE, profile.getUUID().toString());
LocalBroadcastManager.getInstance(getActivity()).sendBroadcast(intent);
Intent connectionIntent = new Intent(getActivity(), VpnProfileDetailActivity.class);
connectionIntent.putExtra(VpnProfileDataSource.KEY_UUID, profile.getUUID().toString());
startActivity(connectionIntent);
break;
}
case R.id.delete_profile:
{
ArrayList<VpnProfile> profiles = new ArrayList<>();
for (int position : mSelected)
{
profiles.add((VpnProfile)mListView.getItemAtPosition(position));
}
String[] uuids = new String[profiles.size()];
for (int i = 0; i < profiles.size(); i++)
{
VpnProfile profile = profiles.get(i);
uuids[i] = profile.getUUID().toString();
mDataSource.deleteVpnProfile(profile);
}
Intent intent = new Intent(Constants.VPN_PROFILES_CHANGED);
intent.putExtra(Constants.VPN_PROFILES_MULTIPLE, uuids);
LocalBroadcastManager.getInstance(getActivity()).sendBroadcast(intent);
Toast.makeText(VpnProfileListFragment.this.getActivity(),
R.string.profiles_deleted, Toast.LENGTH_SHORT).show();
break;
}
default:
return false;
}
mode.finish();
return true;
}
@Override
public void onItemCheckedStateChanged(ActionMode mode, int position,
long id, boolean checked)
{
VpnProfile profile = (VpnProfile)mListView.getItemAtPosition(position);
if (checked)
{
mSelected.add(position);
mReadOnlyCount += profile.isReadOnly() ? 1 : 0;
}
else
{
mSelected.remove(position);
mReadOnlyCount -= profile.isReadOnly() ? 1 : 0;
}
final int checkedCount = mSelected.size();
switch (checkedCount)
{
case 0:
mode.setSubtitle(R.string.no_profile_selected);
break;
case 1:
mode.setSubtitle(R.string.one_profile_selected);
break;
default:
mode.setSubtitle(String.format(getString(R.string.x_profiles_selected), checkedCount));
break;
}
mCanEdit = checkedCount == 1;
mCanCopy = checkedCount == 1 && mReadOnlyCount == 0;
mCanDelete = checkedCount > 0 && mReadOnlyCount == 0;
mode.invalidate();
}
};
}
@@ -1,61 +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.Intent;
import android.os.Bundle;
import org.strongswan.android.R;
import org.strongswan.android.data.VpnProfile;
import org.strongswan.android.ui.VpnProfileListFragment.OnVpnProfileSelectedListener;
import org.strongswan.android.utils.Utils;
import androidx.appcompat.app.AppCompatActivity;
import androidx.core.content.pm.ShortcutInfoCompat;
import androidx.core.content.pm.ShortcutManagerCompat;
import androidx.core.graphics.drawable.IconCompat;
import androidx.core.view.WindowCompat;
public class VpnProfileSelectActivity extends AppCompatActivity implements OnVpnProfileSelectedListener
{
@Override
protected void onCreate(Bundle savedInstanceState)
{
super.onCreate(savedInstanceState);
setContentView(R.layout.vpn_profile_select);
WindowCompat.enableEdgeToEdge(getWindow());
Utils.applyWindowInsetsAsMarginsForLists(findViewById(R.id.layout));
/* we should probably return a result also if the user clicks the back
* button before selecting a profile */
setResult(RESULT_CANCELED);
}
@Override
public void onVpnProfileSelected(VpnProfile profile)
{
Intent shortcut = new Intent(VpnProfileControlActivity.START_PROFILE);
shortcut.putExtra(VpnProfileControlActivity.EXTRA_VPN_PROFILE_UUID, profile.getUUID().toString());
ShortcutInfoCompat.Builder builder = new ShortcutInfoCompat.Builder(this, profile.getUUID().toString());
builder.setIntent(shortcut);
builder.setShortLabel(profile.getName());
builder.setIcon(IconCompat.createWithResource(this, R.mipmap.ic_shortcut));
setResult(RESULT_OK, ShortcutManagerCompat.createShortcutResultIntent(this, builder.build()));
finish();
}
}
@@ -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.
*/
package org.strongswan.android.ui
import android.content.Intent
import android.os.Bundle
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.ListItem
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.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 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.theme.ShxTheme
class VpnProfileSelectActivity : AppCompatActivity()
{
override fun onCreate(savedInstanceState: Bundle?)
{
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setResult(RESULT_CANCELED)
val source = VpnProfileSource(this).open()
val profiles = source.allVpnProfiles.sortedBy { it.name?.lowercase() ?: "" }
source.close()
setContent {
ShxTheme {
ProfileSelectScreen(
profiles = profiles,
onBack = { finish() },
onSelect = { profile -> selectProfile(profile) },
)
}
}
}
private fun selectProfile(profile: VpnProfile)
{
val shortcut = Intent(VpnProfileControlActivity.START_PROFILE)
shortcut.putExtra(VpnProfileControlActivity.EXTRA_VPN_PROFILE_UUID, profile.getUUID().toString())
val builder = ShortcutInfoCompat.Builder(this, profile.getUUID().toString())
builder.setIntent(shortcut)
builder.setShortLabel(profile.name ?: profile.gateway ?: "")
builder.setIcon(IconCompat.createWithResource(this, R.mipmap.ic_shortcut))
setResult(RESULT_OK, ShortcutManagerCompat.createShortcutResultIntent(this, builder.build()))
finish()
}
}
@Composable
private fun ProfileSelectScreen(
profiles: List<VpnProfile>,
onBack: () -> Unit,
onSelect: (VpnProfile) -> Unit,
)
{
SecondaryScaffold(title = stringResource(R.string.strongswan_shortcut), onBack = onBack) { padding ->
if (profiles.isEmpty())
{
Text(stringResource(R.string.no_profiles), modifier = Modifier.padding(padding).padding(24.dp))
}
else
{
LazyColumn(Modifier.fillMaxSize().padding(padding)) {
items(profiles, key = { it.getUUID().toString() }) { profile ->
ListItem(
headlineContent = { Text(profile.name ?: "") },
supportingContent = { Text(profile.gateway ?: "") },
modifier = Modifier.clickable { onSelect(profile) },
)
}
}
}
}
}
@@ -1,278 +0,0 @@
/*
* Copyright (C) 2012-2018 Tobias Brunner
* Copyright (C) 2012 Giuliano Grassi
* Copyright (C) 2012 Ralf Sager
*
* 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.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.Button;
import android.widget.LinearLayout;
import android.widget.ProgressBar;
import android.widget.TextView;
import org.strongswan.android.R;
import org.strongswan.android.data.VpnProfile;
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.VpnStateService.VpnStateListener;
import androidx.core.content.ContextCompat;
import androidx.fragment.app.Fragment;
public class VpnStateFragment extends Fragment implements VpnStateListener
{
private boolean mVisible;
private TextView mProfileNameView;
private TextView mProfileView;
private TextView mStateView;
private int mColorStateBase;
private int mColorStateError;
private int mColorStateSuccess;
private Button mActionButton;
private ProgressBar mProgress;
private LinearLayout mErrorView;
private TextView mErrorText;
private Button mErrorRetry;
private Button mShowLog;
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(VpnStateFragment.this);
updateView();
}
}
};
@Override
public void onCreate(Bundle savedInstanceState)
{
super.onCreate(savedInstanceState);
mColorStateError = ContextCompat.getColor(getActivity(), R.color.error_text);
mColorStateSuccess = ContextCompat.getColor(getActivity(), R.color.success_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);
}
@Override
public View onCreateView(LayoutInflater inflater, ViewGroup container,
Bundle savedInstanceState)
{
View view = inflater.inflate(R.layout.vpn_state_fragment, null);
mActionButton = (Button)view.findViewById(R.id.action);
mActionButton.setOnClickListener(v -> {
if (mService != null)
{
mService.disconnect();
}
});
enableActionButton(null);
mErrorView = view.findViewById(R.id.vpn_error);
mErrorText = view.findViewById(R.id.vpn_error_text);
mErrorRetry = view.findViewById(R.id.retry);
mShowLog = view.findViewById(R.id.show_log);
mProgress = (ProgressBar)view.findViewById(R.id.progress);
mStateView = (TextView)view.findViewById(R.id.vpn_state);
mColorStateBase = mStateView.getCurrentTextColor();
mProfileView = (TextView)view.findViewById(R.id.vpn_profile_label);
mProfileNameView = (TextView)view.findViewById(R.id.vpn_profile_name);
mErrorRetry.setOnClickListener(v -> {
if (mService != null)
{
mService.reconnect();
}
});
mShowLog.setOnClickListener(v -> {
Intent intent = new Intent(getActivity(), LogActivity.class);
startActivity(intent);
});
return view;
}
@Override
public void onStart()
{
super.onStart();
mVisible = true;
if (mService != null)
{
mService.registerListener(this);
updateView();
}
}
@Override
public void onStop()
{
super.onStop();
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()
{
long connectionID = mService.getConnectionID();
VpnProfile profile = mService.getProfile();
State state = mService.getState();
ErrorState error = mService.getErrorState();
String name = "";
if (getActivity() == null)
{
return;
}
if (profile != null)
{
name = profile.getName();
}
if (reportError(connectionID, name, error))
{
return;
}
mProfileNameView.setText(name);
mProgress.setIndeterminate(true);
switch (state)
{
case DISABLED:
showProfile(false);
mProgress.setVisibility(View.GONE);
enableActionButton(null);
mStateView.setText(R.string.state_disabled);
mStateView.setTextColor(mColorStateBase);
break;
case CONNECTING:
showProfile(true);
mProgress.setVisibility(View.VISIBLE);
enableActionButton(getString(android.R.string.cancel));
mStateView.setText(R.string.state_connecting);
mStateView.setTextColor(mColorStateBase);
break;
case CONNECTED:
showProfile(true);
mProgress.setVisibility(View.GONE);
enableActionButton(getString(R.string.disconnect));
mStateView.setText(R.string.state_connected);
mStateView.setTextColor(mColorStateSuccess);
break;
case DISCONNECTING:
showProfile(true);
mProgress.setVisibility(View.VISIBLE);
enableActionButton(null);
mStateView.setText(R.string.state_disconnecting);
mStateView.setTextColor(mColorStateBase);
break;
}
}
private boolean reportError(long connectionID, String name, ErrorState error)
{
if (error == ErrorState.NO_ERROR)
{
mErrorView.setVisibility(View.GONE);
return false;
}
mProfileNameView.setText(name);
showProfile(true);
mStateView.setText(R.string.state_error);
mStateView.setTextColor(mColorStateError);
enableActionButton(getString(android.R.string.cancel));
int retry = mService.getRetryIn();
if (retry > 0)
{
mProgress.setIndeterminate(false);
mProgress.setMax(mService.getRetryTimeout());
mProgress.setProgress(retry);
mProgress.setVisibility(View.VISIBLE);
mStateView.setText(getResources().getQuantityString(R.plurals.retry_in, retry, retry));
}
else if (mService.getRetryTimeout() <= 0)
{
mProgress.setVisibility(View.GONE);
}
String text = getString(R.string.error_format, getString(mService.getErrorText()));
mErrorText.setText(text);
mErrorView.setVisibility(View.VISIBLE);
return true;
}
private void showProfile(boolean show)
{
mProfileView.setVisibility(show ? View.VISIBLE : View.GONE);
mProfileNameView.setVisibility(show ? View.VISIBLE : View.GONE);
}
private void enableActionButton(String text)
{
mActionButton.setText(text);
mActionButton.setEnabled(text != null);
mActionButton.setVisibility(text != null ? View.VISIBLE : View.GONE);
}
}
@@ -1,118 +0,0 @@
/*
* Copyright (C) 2012 Tobias Brunner
* Copyright (C) 2012 Giuliano Grassi
* Copyright (C) 2012 Ralf Sager
*
* 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.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.ArrayAdapter;
import android.widget.TextView;
import org.strongswan.android.R;
import org.strongswan.android.data.VpnProfile;
import org.strongswan.android.data.VpnType.VpnTypeFeature;
import java.util.Collections;
import java.util.Comparator;
import java.util.List;
public class VpnProfileAdapter extends ArrayAdapter<VpnProfile>
{
private final int resource;
private final List<VpnProfile> items;
public VpnProfileAdapter(Context context, int resource,
List<VpnProfile> items)
{
super(context, resource, items);
this.resource = resource;
this.items = items;
sortItems();
}
@Override
public View getView(int position, View convertView, ViewGroup parent)
{
View vpnProfileView;
if (convertView != null)
{
vpnProfileView = convertView;
}
else
{
LayoutInflater inflater = LayoutInflater.from(getContext());
vpnProfileView = inflater.inflate(resource, null);
}
VpnProfile profile = getItem(position);
TextView tv = vpnProfileView.findViewById(R.id.profile_item_name);
tv.setText(profile.getName());
tv = vpnProfileView.findViewById(R.id.profile_item_managed);
tv.setVisibility(profile.isReadOnly() ? View.VISIBLE : View.GONE);
tv = vpnProfileView.findViewById(R.id.profile_item_gateway);
tv.setText(getContext().getString(R.string.profile_gateway_label) + ": " + profile.getGateway());
tv = vpnProfileView.findViewById(R.id.profile_item_username);
if (profile.getVpnType().has(VpnTypeFeature.USER_PASS))
{ /* if the view is reused we make sure it is visible */
tv.setVisibility(View.VISIBLE);
tv.setText(getContext().getString(R.string.profile_username_label) + ": " + profile.getUsername());
}
else if (profile.getVpnType().has(VpnTypeFeature.CERTIFICATE) &&
profile.getLocalId() != null)
{
tv.setVisibility(View.VISIBLE);
tv.setText(getContext().getString(R.string.profile_local_id_label) + ": " + profile.getLocalId());
}
else
{
tv.setVisibility(View.GONE);
}
tv = vpnProfileView.findViewById(R.id.profile_item_certificate);
if (profile.getVpnType().has(VpnTypeFeature.CERTIFICATE))
{
String alias = profile.getUserCertificateAlias();
tv.setText(getContext().getString(R.string.profile_user_certificate_label) + ": " + (alias != null ? alias : ""));
tv.setVisibility(View.VISIBLE);
}
else
{
tv.setVisibility(View.GONE);
}
return vpnProfileView;
}
@Override
public void notifyDataSetChanged()
{
sortItems();
super.notifyDataSetChanged();
}
private void sortItems()
{
Collections.sort(this.items, new Comparator<VpnProfile>()
{
@Override
public int compare(VpnProfile lhs, VpnProfile rhs)
{
return lhs.getName().compareToIgnoreCase(rhs.getName());
}
});
}
}
@@ -0,0 +1,50 @@
/*
* 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 java.util.Locale
import kotlin.math.abs
fun formatBytes(bytes: Long): String
{
if (bytes <= 0L)
{
return "0 B"
}
val units = arrayOf("B", "KB", "MB", "GB", "TB")
var value = bytes.toDouble()
var unit = 0
while (value >= 1024 && unit < units.lastIndex)
{
value /= 1024
unit++
}
return if (unit == 0) String.format(Locale.US, "%d %s", bytes, units[unit])
else String.format(Locale.US, "%.1f %s", value, units[unit])
}
fun formatRate(bytesPerSecond: Long): String
{
if (abs(bytesPerSecond) < 32)
{
return "0 B/s"
}
return formatBytes(bytesPerSecond) + "/s"
}
fun formatDuration(seconds: Long): String
{
val s = seconds.coerceAtLeast(0)
val h = s / 3600
val m = (s % 3600) / 60
val sec = s % 60
return if (h > 0) String.format(Locale.US, "%d:%02d:%02d", h, m, sec)
else String.format(Locale.US, "%02d:%02d", m, sec)
}
@@ -0,0 +1,682 @@
/*
* Copyright (C) 2026 SHX VPN
*
* This program is free software; you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the
* Free Software Foundation; either version 2 of the License, or (at your
* option) any later version.
*/
package org.strongswan.android.ui.compose
import androidx.compose.animation.AnimatedContent
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.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
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.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.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.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.ExpandLess
import androidx.compose.material.icons.rounded.Language
import androidx.compose.material.icons.rounded.PlayArrow
import androidx.compose.material.icons.rounded.Schedule
import androidx.compose.material.icons.rounded.Edit
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.Icon
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.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.geometry.Offset
import androidx.compose.ui.graphics.Brush
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.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.flow.StateFlow
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.ui.compose.theme.LocalShxStatusColors
import org.strongswan.android.ui.compose.theme.ScreenPadding
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
@Composable
fun HomeScreen(
state: HomeUiState,
trafficFlow: StateFlow<TrafficUi>,
viewModel: HomeViewModel,
onAddProfile: () -> Unit,
onEditProfile: (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()
var pendingDelete by remember { mutableStateOf<VpnProfile?>(null) }
var sheetProfile by remember { mutableStateOf<VpnProfile?>(null) }
val connected = state.vpnState == State.CONNECTED
val connecting = state.vpnState == State.CONNECTING || state.vpnState == State.DISCONNECTING
val hasError = state.error != ErrorState.NO_ERROR
val selected = state.selected
val durationSec = if (connected) traffic.durationSec else 0L
val status = LocalShxStatusColors.current
val statusText = when
{
hasError -> stringResource(state.errorText)
connected -> stringResource(R.string.status_protected)
connecting && state.vpnState == State.DISCONNECTING -> stringResource(R.string.state_disconnecting)
connecting -> stringResource(R.string.status_connecting)
else -> stringResource(R.string.status_unprotected)
}
val statusColor = when
{
hasError -> MaterialTheme.colorScheme.error
connected -> status.connected
connecting -> status.connecting
else -> status.disconnected
}
val subtitle = when
{
connected -> "${selected?.name ?: ""} · ${formatDuration(durationSec)}"
connecting -> selected?.name ?: ""
else -> stringResource(R.string.status_choose_profile)
}
Box(Modifier.fillMaxSize())
{
StatusGlowBackground(color = statusColor, active = connected || connecting)
LazyColumn(
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,
)
}
Spacer(Modifier.height(6.dp))
Text(
text = subtitle,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
item(key = "card") {
ConnectionCard(
modifier = Modifier.padding(horizontal = ScreenPadding),
profile = selected,
vpnState = state.vpnState,
traffic = traffic,
enabled = selected != null && !selected.isReadOnly,
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 (hasError)
{
item(key = "error") {
ErrorBanner(
modifier = Modifier.padding(top = 12.dp, start = ScreenPadding, end = ScreenPadding),
onRetry = { viewModel.reconnect() },
onShowLog = onShowLog,
)
}
}
item(key = "header") {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(top = 28.dp, bottom = 4.dp, start = ScreenPadding, end = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
stringResource(R.string.profiles_title),
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f),
)
IconButton(onClick = onAddProfile) {
Icon(Icons.Rounded.Add, contentDescription = stringResource(R.string.add_profile_cta))
}
}
}
if (state.profiles.isEmpty())
{
item(key = "empty") {
EmptyProfiles(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = ScreenPadding, vertical = 12.dp),
onAdd = 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()
}
}
item(key = "space") { Spacer(Modifier.height(24.dp)) }
}
}
}
if (state.detailsOpen)
{
ConnectionDetailsSheet(
profile = state.activeProfile ?: state.selected,
traffic = traffic,
durationSec = durationSec,
onDismiss = { viewModel.openDetails(false) },
onDisconnect = {
viewModel.openDetails(false)
viewModel.disconnect()
},
)
}
sheetProfile?.let { profile ->
ProfileActionsSheet(
profile = profile,
active = connected && profile.getUUID() == state.activeProfile?.getUUID(),
onDismiss = { sheetProfile = null },
onConnect = {
sheetProfile = null
viewModel.select(profile)
viewModel.connect(context, profile)
},
onEdit = {
sheetProfile = null
onEditProfile(profile)
},
onDelete = {
sheetProfile = null
pendingDelete = profile
},
)
}
pendingDelete?.let { profile ->
AlertDialog(
onDismissRequest = { pendingDelete = null },
title = { Text(stringResource(R.string.delete_profile)) },
text = { Text(profile.name ?: "") },
confirmButton = {
TextButton(onClick = {
viewModel.delete(profile)
pendingDelete = null
}) { Text(stringResource(R.string.delete_profile)) }
},
dismissButton = {
TextButton(onClick = { pendingDelete = null }) {
Text(stringResource(android.R.string.cancel))
}
},
)
}
}
@Composable
private fun StatusGlowBackground(color: Color, active: Boolean)
{
val animated by animateColorAsState(color, label = "glow")
val transition = rememberInfiniteTransition(label = "pulse")
/* read inside the draw scope below so the pulse only invalidates drawing,
* instead of recomposing this composable on every frame */
val pulse by transition.animateFloat(
initialValue = 0.30f,
targetValue = 0.55f,
animationSpec = infiniteRepeatable(tween(1600, easing = LinearEasing), RepeatMode.Reverse),
label = "a",
)
Canvas(Modifier.fillMaxSize())
{
val alpha = if (active) pulse else 0f
val cx = size.width / 2f
val cy = size.height * 0.16f
drawCircle(
brush = Brush.radialGradient(
colors = listOf(animated.copy(alpha = alpha * 0.30f), Color.Transparent),
center = Offset(cx, cy),
radius = size.minDimension * 0.62f,
),
radius = size.minDimension * 0.62f,
center = Offset(cx, cy),
)
}
}
@Composable
private fun ErrorBanner(modifier: Modifier = Modifier, onRetry: () -> Unit, onShowLog: () -> Unit)
{
Surface(
shape = MaterialTheme.shapes.medium,
color = MaterialTheme.colorScheme.errorContainer,
contentColor = MaterialTheme.colorScheme.onErrorContainer,
modifier = modifier.fillMaxWidth(),
) {
Row(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = stringResource(R.string.connection_failed),
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.weight(1f),
maxLines = 2,
)
TextButton(onClick = onRetry) { Text(stringResource(R.string.retry)) }
TextButton(onClick = onShowLog) { Text(stringResource(R.string.show_log)) }
}
}
}
@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)
{
val label = if (down) stringResource(R.string.traffic_down) else stringResource(R.string.traffic_up)
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
)
{
Surface(
shape = CircleShape,
color = MaterialTheme.colorScheme.surfaceContainerHigh,
) {
Icon(
imageVector = if (down) Icons.Rounded.ArrowDownward else Icons.Rounded.ArrowUpward,
contentDescription = label,
modifier = Modifier
.padding(6.dp)
.size(16.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Column {
Text(
text = formatBytes(total),
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Medium,
)
Text(
text = formatRate(rate),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
@Composable
private fun ConnectionCard(
modifier: Modifier = Modifier,
profile: VpnProfile?,
vpnState: State,
traffic: TrafficUi,
enabled: Boolean,
onConnect: () -> Unit,
onDisconnect: () -> Unit,
onCancel: () -> Unit,
onOpenDetails: () -> Unit,
)
{
val connecting = vpnState == State.CONNECTING || vpnState == State.DISCONNECTING
val connected = vpnState == State.CONNECTED
Surface(
shape = MaterialTheme.shapes.large,
color = MaterialTheme.colorScheme.surfaceContainerLow,
border = androidx.compose.foundation.BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant),
modifier = modifier.fillMaxWidth(),
) {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp))
{
Row(
modifier = Modifier
.fillMaxWidth()
.combinedClickable(enabled = profile != null, onClick = onOpenDetails),
verticalAlignment = Alignment.CenterVertically,
) {
ProfileAvatar(icon = Icons.Rounded.Language, connected = connected)
Column(
modifier = Modifier
.weight(1f)
.padding(horizontal = 12.dp),
) {
Text(
text = profile?.name ?: stringResource(R.string.no_profiles),
style = MaterialTheme.typography.titleMedium.copy(fontSize = 18.sp),
fontWeight = FontWeight.Medium,
maxLines = 1,
)
Text(
text = profile?.gateway ?: "",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
)
}
Icon(
Icons.Rounded.ExpandLess,
contentDescription = stringResource(R.string.connection_details),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
AnimatedVisibility(
visible = connected,
enter = expandVertically() + fadeIn(),
exit = shrinkVertically() + fadeOut(),
) {
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly,
verticalAlignment = Alignment.CenterVertically,
) {
TrafficStat(down = true, total = traffic.bytesIn, rate = traffic.rateIn)
Box(
modifier = Modifier
.size(1.dp, 32.dp)
.background(MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.6f)),
)
TrafficStat(down = false, total = traffic.bytesOut, rate = traffic.rateOut)
}
}
when (vpnState)
{
State.CONNECTED -> FilledTonalButton(
onClick = onDisconnect,
modifier = Modifier
.fillMaxWidth()
.height(48.dp),
colors = ButtonDefaults.filledTonalButtonColors(
containerColor = MaterialTheme.colorScheme.errorContainer,
contentColor = MaterialTheme.colorScheme.onErrorContainer,
),
shape = MaterialTheme.shapes.medium,
) { 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,
) {
CircularProgressIndicator(Modifier.size(18.dp), strokeWidth = 2.dp)
Spacer(Modifier.size(12.dp))
Text(stringResource(android.R.string.cancel), fontSize = 16.sp)
}
else -> Button(
onClick = onConnect,
enabled = enabled,
modifier = Modifier
.fillMaxWidth()
.height(48.dp),
shape = MaterialTheme.shapes.medium,
) { Text(stringResource(R.string.connect), fontSize = 16.sp) }
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun ConnectionDetailsSheet(
profile: VpnProfile?,
traffic: TrafficUi,
durationSec: Long,
onDismiss: () -> Unit,
onDisconnect: () -> Unit,
)
{
ModalBottomSheet(
onDismissRequest = onDismiss,
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
containerColor = MaterialTheme.colorScheme.surfaceContainerLow,
)
{
Column(Modifier.padding(horizontal = 24.dp))
{
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp))
{
ProfileAvatar(icon = Icons.Rounded.Language, connected = true)
Column {
Text(
profile?.name ?: "",
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.SemiBold,
)
Text(
profile?.gateway ?: "",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
Spacer(Modifier.height(16.dp))
DetailRow(icon = Icons.Rounded.Language, label = stringResource(R.string.profile_gateway_label), value = profile?.gateway ?: "")
DetailRow(icon = Icons.Rounded.Schedule, label = stringResource(R.string.duration_label), value = formatDuration(durationSec))
DetailRow(
icon = Icons.Rounded.ArrowDownward,
label = stringResource(R.string.traffic_down),
value = "${formatBytes(traffic.bytesIn)} · ${formatRate(traffic.rateIn)}",
)
DetailRow(
icon = Icons.Rounded.ArrowUpward,
label = stringResource(R.string.traffic_up),
value = "${formatBytes(traffic.bytesOut)} · ${formatRate(traffic.rateOut)}",
)
Spacer(Modifier.height(24.dp))
FilledTonalButton(
onClick = onDisconnect,
colors = ButtonDefaults.filledTonalButtonColors(
containerColor = MaterialTheme.colorScheme.errorContainer,
contentColor = MaterialTheme.colorScheme.onErrorContainer,
),
shape = MaterialTheme.shapes.medium,
modifier = Modifier.fillMaxWidth(),
) {
Text(stringResource(R.string.disconnect))
}
Spacer(Modifier.height(32.dp))
}
}
}
@Composable
private fun DetailRow(icon: ImageVector, label: String, value: String)
{
ListItem(
leadingContent = {
Surface(
shape = CircleShape,
color = MaterialTheme.colorScheme.surfaceContainerHigh,
) {
Icon(
icon,
contentDescription = null,
modifier = Modifier
.padding(8.dp)
.size(18.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
},
headlineContent = { Text(label) },
supportingContent = { Text(value) },
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ProfileActionsSheet(
profile: VpnProfile,
active: Boolean,
onDismiss: () -> Unit,
onConnect: () -> Unit,
onEdit: () -> Unit,
onDelete: () -> Unit,
)
{
ModalBottomSheet(
onDismissRequest = onDismiss,
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
containerColor = MaterialTheme.colorScheme.surfaceContainerLow,
)
{
Column(Modifier.fillMaxWidth())
{
ShxProfileRow(profile = profile, active = active, onClick = {}, onLongClick = {})
ShxDivider(indent = false)
SheetAction(icon = Icons.Rounded.PlayArrow, label = stringResource(R.string.connect), onClick = onConnect)
ShxDivider(indent = false)
SheetAction(icon = Icons.Rounded.Edit, label = stringResource(R.string.edit_profile), onClick = onEdit)
ShxDivider(indent = false)
SheetAction(
icon = Icons.Rounded.Delete,
label = stringResource(R.string.delete_profile),
tint = MaterialTheme.colorScheme.error,
onClick = onDelete,
)
Spacer(Modifier.height(24.dp))
}
}
}
@Composable
private fun SheetAction(icon: ImageVector, label: String, onClick: () -> Unit, tint: Color = Color.Unspecified)
{
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.combinedClickable(onClick = onClick)
.padding(horizontal = 24.dp, vertical = 16.dp),
)
{
Icon(icon, contentDescription = null, tint = tint, modifier = Modifier.size(22.dp))
Spacer(Modifier.size(16.dp))
Text(label, style = MaterialTheme.typography.bodyLarge)
}
}
@@ -0,0 +1,288 @@
/*
* 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 android.app.Application
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.os.IBinder
import android.os.SystemClock
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.ProcessLifecycleOwner
import androidx.lifecycle.repeatOnLifecycle
import androidx.lifecycle.viewModelScope
import androidx.localbroadcastmanager.content.LocalBroadcastManager
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.strongswan.android.data.VpnProfile
import org.strongswan.android.data.VpnProfileSource
import org.strongswan.android.logic.CharonVpnService
import org.strongswan.android.logic.VpnStateService
import org.strongswan.android.logic.VpnStateService.ErrorState
import org.strongswan.android.logic.VpnStateService.State
import org.strongswan.android.ui.VpnProfileControlActivity
import org.strongswan.android.utils.Constants
data class TrafficUi(
val bytesIn: Long = 0,
val bytesOut: Long = 0,
val rateIn: Long = 0,
val rateOut: Long = 0,
val durationSec: Long = 0,
)
enum class ShxTab
{
HOME,
PROFILES,
SETTINGS,
}
data class HomeUiState(
val vpnState: State = State.DISABLED,
val error: ErrorState = ErrorState.NO_ERROR,
val errorText: Int = 0,
val activeProfile: VpnProfile? = null,
val profiles: List<VpnProfile> = emptyList(),
val selected: VpnProfile? = null,
val detailsOpen: Boolean = false,
val tab: ShxTab = ShxTab.HOME,
)
class HomeViewModel(app: Application) : AndroidViewModel(app), VpnStateService.VpnStateListener
{
private val _ui = MutableStateFlow(HomeUiState())
val ui: StateFlow<HomeUiState> = _ui
/* kept separate from HomeUiState so traffic ticks don't recompose the whole app */
private val _traffic = MutableStateFlow(TrafficUi())
val traffic: StateFlow<TrafficUi> = _traffic
private var service: VpnStateService? = null
/* written from the main thread (pullState) and the poll coroutine (Default) */
@Volatile private var lastIn = 0L
@Volatile private var lastOut = 0L
@Volatile private var lastAt = 0L
@Volatile private var connectedAt = 0L
private val connection = object : ServiceConnection
{
override fun onServiceConnected(name: ComponentName?, binder: IBinder?)
{
service = (binder as VpnStateService.LocalBinder).service
service?.registerListener(this@HomeViewModel)
pullState()
}
override fun onServiceDisconnected(name: ComponentName?)
{
service = null
}
}
private val profilesChanged = object : BroadcastReceiver()
{
override fun onReceive(context: Context?, intent: Intent?)
{
reloadProfiles()
}
}
init
{
val ctx = getApplication<Application>()
ctx.bindService(Intent(ctx, VpnStateService::class.java), connection, Service.BIND_AUTO_CREATE)
LocalBroadcastManager.getInstance(ctx).registerReceiver(
profilesChanged,
IntentFilter(Constants.VPN_PROFILES_CHANGED)
)
reloadProfiles()
/* poll traffic only while the app is visible, and off the main thread */
viewModelScope.launch {
ProcessLifecycleOwner.get().lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
while (isActive)
{
tickTraffic()
delay(TRAFFIC_POLL_MS)
}
}
}
}
override fun onCleared()
{
val ctx = getApplication<Application>()
service?.unregisterListener(this)
try
{
ctx.unbindService(connection)
}
catch (_: IllegalArgumentException)
{
}
LocalBroadcastManager.getInstance(ctx).unregisterReceiver(profilesChanged)
super.onCleared()
}
override fun stateChanged()
{
pullState()
}
fun setTab(tab: ShxTab)
{
_ui.update { it.copy(tab = tab) }
}
fun select(profile: VpnProfile)
{
_ui.update { it.copy(selected = profile) }
}
fun openDetails(open: Boolean)
{
_ui.update { it.copy(detailsOpen = open) }
}
fun connect(context: Context, profile: VpnProfile)
{
val intent = Intent(context, VpnProfileControlActivity::class.java)
intent.action = VpnProfileControlActivity.START_PROFILE
intent.putExtra(VpnProfileControlActivity.EXTRA_VPN_PROFILE_UUID, profile.getUUID().toString())
context.startActivity(intent)
}
fun disconnect()
{
service?.disconnect()
}
fun reconnect()
{
service?.reconnect()
}
fun delete(profile: VpnProfile)
{
viewModelScope.launch(Dispatchers.IO) {
val source = VpnProfileSource(getApplication())
source.open()
source.deleteVpnProfile(profile)
source.close()
val intent = Intent(Constants.VPN_PROFILES_CHANGED)
intent.putExtra(Constants.VPN_PROFILES_MULTIPLE, arrayOf(profile.getUUID().toString()))
LocalBroadcastManager.getInstance(getApplication()).sendBroadcast(intent)
reloadProfiles()
}
}
private fun reloadProfiles()
{
viewModelScope.launch(Dispatchers.IO) {
val source = VpnProfileSource(getApplication())
source.open()
val all = source.allVpnProfiles.sortedBy { it.name?.lowercase() ?: "" }
source.close()
_ui.update { state ->
val selected = all.firstOrNull { it.getUUID() == state.selected?.getUUID() }
?: all.firstOrNull { it.getUUID() == state.activeProfile?.getUUID() }
?: all.firstOrNull()
state.copy(profiles = all, selected = selected)
}
}
}
private fun pullState()
{
val svc = service ?: return
val state = svc.state
if (state == State.CONNECTED && connectedAt == 0L)
{
connectedAt = SystemClock.elapsedRealtime()
lastIn = 0
lastOut = 0
lastAt = 0
}
if (state != State.CONNECTED)
{
connectedAt = 0
_traffic.value = TrafficUi()
}
_ui.update {
it.copy(
vpnState = state,
error = svc.errorState,
errorText = svc.errorText,
activeProfile = svc.profile,
selected = svc.profile ?: it.selected,
)
}
}
private suspend fun tickTraffic()
{
if (_ui.value.vpnState != State.CONNECTED)
{
return
}
val now = SystemClock.elapsedRealtime()
val stats = try
{
withContext(Dispatchers.Default) { CharonVpnService.queryTraffic() }
}
catch (_: Throwable)
{
null
} ?: longArrayOf(0, 0)
val inbound = stats.getOrElse(0) { 0L }
val outbound = stats.getOrElse(1) { 0L }
val dt = if (lastAt == 0L) TRAFFIC_POLL_MS / 1000.0 else ((now - lastAt) / 1000.0).coerceAtLeast(0.2)
val rateIn = if (lastAt == 0L) 0 else ((inbound - lastIn) / dt).toLong().coerceAtLeast(0)
val rateOut = if (lastAt == 0L) 0 else ((outbound - lastOut) / dt).toLong().coerceAtLeast(0)
val startedAt = connectedAt
lastIn = inbound
lastOut = outbound
lastAt = now
if (startedAt == 0L)
{
return
}
_traffic.value = TrafficUi(
bytesIn = inbound,
bytesOut = outbound,
rateIn = rateIn,
rateOut = rateOut,
durationSec = (now - startedAt) / 1000,
)
}
companion object
{
private const val TRAFFIC_POLL_MS = 500L
}
}
@@ -0,0 +1,644 @@
/*
* 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.security.KeyChain
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material.icons.filled.VisibilityOff
import androidx.compose.material3.Button
import androidx.compose.material3.Checkbox
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExposedDropdownMenuAnchorType
import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.ExposedDropdownMenuDefaults
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItem
import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
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.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringArrayResource
import androidx.compose.ui.res.stringResource
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 org.strongswan.android.R
import org.strongswan.android.data.VpnProfile
import org.strongswan.android.data.VpnProfile.SelectedAppsHandling
import org.strongswan.android.data.VpnProfileDataSource
import org.strongswan.android.data.VpnType
import org.strongswan.android.data.VpnType.VpnTypeFeature
import org.strongswan.android.ui.SelectedApplicationsActivity
import org.strongswan.android.ui.TrustedCertificatesActivity
import org.strongswan.android.utils.Constants
import org.strongswan.android.utils.IPRangeSet
import org.strongswan.android.utils.Utils
import java.util.TreeSet
data class ProfileFormState(
val name: String = "",
val gateway: String = "",
val vpnType: VpnType = VpnType.IKEV2_EAP,
val username: String = "",
val password: String = "",
val caAuto: Boolean = true,
val caAlias: String? = null,
val caLabel: String = "",
val userCertAlias: String? = null,
val remoteId: String = "",
val localId: String = "",
val dns: String = "",
val mtu: String = "",
val port: String = "",
val natKeepalive: String = "",
val certReq: Boolean = true,
val useOcsp: Boolean = true,
val useCrl: Boolean = true,
val strictRevocation: Boolean = false,
val rsaPss: Boolean = false,
val ipv6Transport: Boolean = false,
val includedSubnets: String = "",
val excludedSubnets: String = "",
val blockIpv4: Boolean = false,
val blockIpv6: Boolean = false,
val appsHandling: SelectedAppsHandling = SelectedAppsHandling.SELECTED_APPS_DISABLE,
val selectedApps: Set<String> = emptySet(),
val ikeProposal: String = "",
val espProposal: String = "",
val proxyHost: String = "",
val proxyPort: String = "",
val proxyExclusions: String = "",
val readOnly: Boolean = false,
val gatewayError: String? = null,
val usernameError: String? = null,
val advanced: Boolean = false,
)
fun ProfileFormState.toProfile(existing: VpnProfile?): VpnProfile
{
val profile = existing ?: VpnProfile()
val gw = gateway.trim()
profile.name = name.trim().ifEmpty { gw }
profile.gateway = gw
profile.vpnType = vpnType
if (vpnType.has(VpnTypeFeature.USER_PASS))
{
profile.username = username.trim().ifEmpty { null }
profile.password = password.ifEmpty { null }
}
if (vpnType.has(VpnTypeFeature.CERTIFICATE))
{
profile.userCertificateAlias = userCertAlias
}
profile.certificateAlias = if (caAuto) null else caAlias
profile.remoteId = remoteId.trim().ifEmpty { null }
profile.localId = localId.trim().ifEmpty { null }
profile.dnsServers = dns.trim().ifEmpty { null }
profile.setMTU(mtu.trim().toIntOrNull())
profile.port = port.trim().toIntOrNull()
profile.setNATKeepAlive(natKeepalive.trim().toIntOrNull())
var flags = 0
if (!certReq) flags = flags or VpnProfile.FLAGS_SUPPRESS_CERT_REQS
if (!useCrl) flags = flags or VpnProfile.FLAGS_DISABLE_CRL
if (!useOcsp) flags = flags or VpnProfile.FLAGS_DISABLE_OCSP
if (strictRevocation) flags = flags or VpnProfile.FLAGS_STRICT_REVOCATION
if (rsaPss) flags = flags or VpnProfile.FLAGS_RSA_PSS
if (ipv6Transport) flags = flags or VpnProfile.FLAGS_IPv6_TRANSPORT
profile.flags = flags
profile.includedSubnets = includedSubnets.trim().ifEmpty { null }
profile.excludedSubnets = excludedSubnets.trim().ifEmpty { null }
var st = 0
if (blockIpv4) st = st or VpnProfile.SPLIT_TUNNELING_BLOCK_IPV4
if (blockIpv6) st = st or VpnProfile.SPLIT_TUNNELING_BLOCK_IPV6
profile.splitTunneling = if (st == 0) null else st
profile.selectedAppsHandling = appsHandling
profile.setSelectedApps(TreeSet(selectedApps))
profile.ikeProposal = ikeProposal.trim().ifEmpty { null }
profile.espProposal = espProposal.trim().ifEmpty { null }
profile.proxyHost = proxyHost.trim().ifEmpty { null }
profile.proxyPort = proxyPort.trim().toIntOrNull()
profile.proxyExclusions = proxyExclusions.trim().ifEmpty { null }
return profile
}
fun VpnProfile.toFormState(): ProfileFormState
{
val flags = flags ?: 0
val st = splitTunneling ?: 0
return ProfileFormState(
name = name ?: "",
gateway = gateway ?: "",
vpnType = vpnType ?: VpnType.IKEV2_EAP,
username = username ?: "",
password = password ?: "",
caAuto = certificateAlias == null,
caAlias = certificateAlias,
caLabel = certificateAlias ?: "",
userCertAlias = userCertificateAlias,
remoteId = remoteId ?: "",
localId = localId ?: "",
dns = dnsServers ?: "",
mtu = getMTU()?.toString() ?: "",
port = port?.toString() ?: "",
natKeepalive = getNATKeepAlive()?.toString() ?: "",
certReq = flags and VpnProfile.FLAGS_SUPPRESS_CERT_REQS == 0,
useCrl = flags and VpnProfile.FLAGS_DISABLE_CRL == 0,
useOcsp = flags and VpnProfile.FLAGS_DISABLE_OCSP == 0,
strictRevocation = flags and VpnProfile.FLAGS_STRICT_REVOCATION != 0,
rsaPss = flags and VpnProfile.FLAGS_RSA_PSS != 0,
ipv6Transport = flags and VpnProfile.FLAGS_IPv6_TRANSPORT != 0,
includedSubnets = includedSubnets ?: "",
excludedSubnets = excludedSubnets ?: "",
blockIpv4 = st and VpnProfile.SPLIT_TUNNELING_BLOCK_IPV4 != 0,
blockIpv6 = st and VpnProfile.SPLIT_TUNNELING_BLOCK_IPV6 != 0,
appsHandling = selectedAppsHandling,
selectedApps = selectedAppsSet ?: emptySet(),
ikeProposal = ikeProposal ?: "",
espProposal = espProposal ?: "",
proxyHost = proxyHost ?: "",
proxyPort = proxyPort?.toString() ?: "",
proxyExclusions = proxyExclusions ?: "",
readOnly = isReadOnly,
advanced = remoteId != null || localId != null || dnsServers != null || getMTU() != null
|| port != null || getNATKeepAlive() != null || flags != 0 || st != 0
|| includedSubnets != null || excludedSubnets != null
|| selectedAppsHandling != SelectedAppsHandling.SELECTED_APPS_DISABLE
|| ikeProposal != null || espProposal != null || proxyHost != null,
)
}
fun ProfileFormState.validate(ctx: android.content.Context): ProfileFormState
{
var next = this.copy(gatewayError = null, usernameError = null)
if (gateway.isBlank())
{
next = next.copy(gatewayError = ctx.getString(R.string.alert_text_no_input_gateway))
}
if (vpnType.has(VpnTypeFeature.USER_PASS) && username.isBlank())
{
next = next.copy(usernameError = ctx.getString(R.string.alert_text_no_input_username))
}
if (vpnType.has(VpnTypeFeature.CERTIFICATE) && userCertAlias.isNullOrBlank())
{
next = next.copy(usernameError = ctx.getString(R.string.alert_text_nocertfound_title))
}
val mtuVal = mtu.trim().toIntOrNull()
if (mtu.isNotBlank() && (mtuVal == null || mtuVal < Constants.MTU_MIN || mtuVal > Constants.MTU_MAX))
{
next = next.copy(gatewayError = next.gatewayError ?: ctx.getString(R.string.alert_text_out_of_range, Constants.MTU_MIN, Constants.MTU_MAX))
}
if (includedSubnets.isNotBlank())
{
try { IPRangeSet.fromString(includedSubnets) } catch (_: Exception) {
next = next.copy(advanced = true)
}
}
if (ikeProposal.isNotBlank() && !Utils.isProposalValid(true, ikeProposal))
{
next = next.copy(advanced = true)
}
if (espProposal.isNotBlank() && !Utils.isProposalValid(false, espProposal))
{
next = next.copy(advanced = true)
}
return next
}
fun ProfileFormState.isValid(): Boolean
{
if (gateway.isBlank()) return false
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
return true
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ProfileFormScreen(
title: String,
state: ProfileFormState,
onChange: (ProfileFormState) -> Unit,
onSave: () -> Unit,
onCancel: () -> Unit,
)
{
val context = LocalContext.current
val types = stringArrayResource(R.array.vpn_types)
val appsHandling = stringArrayResource(R.array.apps_handling)
var typeExpanded by remember { mutableStateOf(false) }
var appsExpanded by remember { mutableStateOf(false) }
var showPassword by remember { mutableStateOf(false) }
val enabled = !state.readOnly
val latest = rememberUpdatedState(state)
val pickCa = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
if (result.resultCode == Activity.RESULT_OK)
{
val alias = result.data?.getStringExtra(VpnProfileDataSource.KEY_CERTIFICATE)
onChange(latest.value.copy(caAlias = alias, caLabel = alias ?: "", caAuto = alias == null))
}
}
val pickApps = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
if (result.resultCode == Activity.RESULT_OK)
{
val list = result.data?.getStringArrayListExtra(VpnProfileDataSource.KEY_SELECTED_APPS_LIST) ?: arrayListOf()
onChange(latest.value.copy(selectedApps = list.toSet()))
}
}
Scaffold(
modifier = Modifier.windowInsetsPadding(
WindowInsets.safeDrawing.only(WindowInsetsSides.Bottom),
),
contentWindowInsets = WindowInsets.safeDrawing.only(
WindowInsetsSides.Horizontal + WindowInsetsSides.Top,
),
topBar = {
TopAppBar(
title = { Text(title, maxLines = 1) },
navigationIcon = {
IconButton(onClick = onCancel) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null)
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.background,
),
)
},
bottomBar = {
if (enabled)
{
Surface(
color = MaterialTheme.colorScheme.background,
tonalElevation = 0.dp,
modifier = Modifier.windowInsetsPadding(
WindowInsets.safeDrawing.only(WindowInsetsSides.Bottom),
),
)
{
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 20.dp, vertical = 12.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
OutlinedButton(
onClick = onCancel,
shape = MaterialTheme.shapes.medium,
modifier = Modifier
.weight(1f)
.height(48.dp),
) { Text(stringResource(R.string.profile_edit_cancel)) }
Button(
onClick = onSave,
shape = MaterialTheme.shapes.medium,
modifier = Modifier
.weight(2f)
.height(48.dp),
) { Text(stringResource(R.string.profile_edit_save)) }
}
}
}
},
) { padding ->
Column(
Modifier
.fillMaxSize()
.padding(padding)
.verticalScroll(rememberScrollState())
.padding(20.dp),
verticalArrangement = Arrangement.spacedBy(14.dp),
)
{
if (state.readOnly)
{
Text(stringResource(R.string.alert_text_vpn_profile_read_only), color = MaterialTheme.colorScheme.error)
}
FormSection(title = stringResource(R.string.profile_section_basic))
{
OutlinedTextField(
value = state.gateway,
onValueChange = { onChange(state.copy(gateway = it, gatewayError = null)) },
label = { Text(stringResource(R.string.profile_gateway_label)) },
supportingText = { Text(state.gatewayError ?: stringResource(R.string.profile_gateway_hint)) },
isError = state.gatewayError != null,
enabled = enabled,
singleLine = true,
shape = MaterialTheme.shapes.medium,
modifier = Modifier.fillMaxWidth(),
)
ExposedDropdownMenuBox(expanded = typeExpanded, onExpandedChange = { if (enabled) typeExpanded = it }) {
OutlinedTextField(
value = types.getOrElse(state.vpnType.ordinal) { "" },
onValueChange = {},
readOnly = true,
enabled = enabled,
label = { Text(stringResource(R.string.profile_vpn_type_label)) },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(typeExpanded) },
modifier = Modifier.fillMaxWidth().menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable),
)
ExposedDropdownMenu(expanded = typeExpanded, onDismissRequest = { typeExpanded = false }) {
VpnType.values().forEachIndexed { index, type ->
DropdownMenuItem(
text = { Text(types.getOrElse(index) { type.identifier }) },
onClick = {
onChange(state.copy(vpnType = type))
typeExpanded = false
},
)
}
}
}
if (state.vpnType.has(VpnTypeFeature.USER_PASS))
{
OutlinedTextField(
value = state.username,
onValueChange = { onChange(state.copy(username = it, usernameError = null)) },
label = { Text(stringResource(R.string.profile_username_label)) },
isError = state.usernameError != null,
supportingText = state.usernameError?.let { { Text(it) } },
enabled = enabled,
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = state.password,
onValueChange = { onChange(state.copy(password = it)) },
label = { Text(stringResource(R.string.profile_password_label)) },
supportingText = { Text(stringResource(R.string.profile_password_hint)) },
enabled = enabled,
singleLine = true,
visualTransformation = if (showPassword) VisualTransformation.None else PasswordVisualTransformation(),
trailingIcon = {
IconButton(onClick = { showPassword = !showPassword }) {
Icon(if (showPassword) Icons.Default.VisibilityOff else Icons.Default.Visibility, null)
}
},
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
modifier = Modifier.fillMaxWidth(),
)
}
if (state.vpnType.has(VpnTypeFeature.CERTIFICATE))
{
TextButton(onClick = {
KeyChain.choosePrivateKeyAlias(context as Activity, { alias ->
onChange(latest.value.copy(userCertAlias = alias))
}, arrayOf("RSA", "EC"), null, null, -1, state.userCertAlias)
}, enabled = enabled) {
Text(state.userCertAlias ?: stringResource(R.string.profile_user_select_certificate))
}
}
Row(verticalAlignment = Alignment.CenterVertically) {
Checkbox(
checked = state.caAuto,
onCheckedChange = { onChange(state.copy(caAuto = it)) },
enabled = enabled,
)
Text(stringResource(R.string.profile_ca_auto_label), modifier = Modifier.clickable(enabled = enabled) {
onChange(state.copy(caAuto = !state.caAuto))
})
}
if (!state.caAuto)
{
TextButton(onClick = {
val intent = Intent(context, TrustedCertificatesActivity::class.java)
intent.action = TrustedCertificatesActivity.SELECT_CERTIFICATE
pickCa.launch(intent)
}, enabled = enabled) {
Text(state.caLabel.ifEmpty { stringResource(R.string.profile_ca_select_certificate) })
}
}
OutlinedTextField(
value = state.name,
onValueChange = { onChange(state.copy(name = it)) },
label = { Text(stringResource(R.string.profile_name_label)) },
supportingText = { Text(stringResource(R.string.profile_name_hint)) },
enabled = enabled,
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
HorizontalDivider()
val chevron by animateFloatAsState(if (state.advanced) 180f else 0f, label = "adv")
ListItem(
headlineContent = { Text(stringResource(R.string.profile_advanced_label)) },
supportingContent = { Text(stringResource(R.string.profile_advanced_hint)) },
trailingContent = {
Icon(
Icons.Default.ExpandMore,
contentDescription = stringResource(R.string.profile_show_advanced_label),
modifier = Modifier.rotate(chevron),
)
},
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
modifier = Modifier
.fillMaxWidth()
.clickable { onChange(state.copy(advanced = !state.advanced)) },
)
AnimatedVisibility(
visible = state.advanced,
enter = expandVertically() + fadeIn(),
exit = shrinkVertically() + fadeOut(),
) {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
OutlinedTextField(state.remoteId, { onChange(state.copy(remoteId = it)) },
label = { Text(stringResource(R.string.profile_remote_id_label)) },
supportingText = { Text(stringResource(R.string.profile_remote_id_hint)) },
enabled = enabled, singleLine = true, modifier = Modifier.fillMaxWidth())
OutlinedTextField(state.localId, { onChange(state.copy(localId = it)) },
label = { Text(stringResource(R.string.profile_local_id_label)) },
supportingText = { Text(stringResource(R.string.profile_local_id_hint_user)) },
enabled = enabled, singleLine = true, modifier = Modifier.fillMaxWidth())
OutlinedTextField(state.dns, { onChange(state.copy(dns = it)) },
label = { Text(stringResource(R.string.profile_dns_servers_label)) },
supportingText = { Text(stringResource(R.string.profile_dns_servers_hint)) },
enabled = enabled, singleLine = true, modifier = Modifier.fillMaxWidth())
OutlinedTextField(state.mtu, { onChange(state.copy(mtu = it)) },
label = { Text(stringResource(R.string.profile_mtu_label)) },
enabled = enabled, singleLine = true, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.fillMaxWidth())
OutlinedTextField(state.port, { onChange(state.copy(port = it)) },
label = { Text(stringResource(R.string.profile_port_label)) },
enabled = enabled, singleLine = true, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.fillMaxWidth())
OutlinedTextField(state.natKeepalive, { onChange(state.copy(natKeepalive = it)) },
label = { Text(stringResource(R.string.profile_nat_keepalive_label)) },
enabled = enabled, singleLine = true, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.fillMaxWidth())
SwitchRow(stringResource(R.string.profile_cert_req_label), state.certReq, enabled) { onChange(state.copy(certReq = it)) }
SwitchRow(stringResource(R.string.profile_use_ocsp_label), state.useOcsp, enabled) { onChange(state.copy(useOcsp = it)) }
SwitchRow(stringResource(R.string.profile_use_crl_label), state.useCrl, enabled) { onChange(state.copy(useCrl = it)) }
SwitchRow(stringResource(R.string.profile_strict_revocation_label), state.strictRevocation, enabled) { onChange(state.copy(strictRevocation = it)) }
SwitchRow(stringResource(R.string.profile_rsa_pss_label), state.rsaPss, enabled) { onChange(state.copy(rsaPss = it)) }
SwitchRow(stringResource(R.string.profile_ipv6_transport_label), state.ipv6Transport, enabled) { onChange(state.copy(ipv6Transport = it)) }
OutlinedTextField(state.includedSubnets, { onChange(state.copy(includedSubnets = it)) },
label = { Text(stringResource(R.string.profile_included_subnets_label)) },
enabled = enabled, modifier = Modifier.fillMaxWidth())
OutlinedTextField(state.excludedSubnets, { onChange(state.copy(excludedSubnets = it)) },
label = { Text(stringResource(R.string.profile_excluded_subnets_label)) },
enabled = enabled, modifier = Modifier.fillMaxWidth())
SwitchRow(
label = stringResource(R.string.profile_split_tunnelingv4_title),
checked = state.blockIpv4,
enabled = enabled,
) { onChange(state.copy(blockIpv4 = it)) }
SwitchRow(
label = stringResource(R.string.profile_split_tunnelingv6_title),
supporting = stringResource(R.string.profile_split_tunnelingv6_hint),
checked = state.blockIpv6,
enabled = enabled,
) { onChange(state.copy(blockIpv6 = it)) }
ExposedDropdownMenuBox(expanded = appsExpanded, onExpandedChange = { if (enabled) appsExpanded = it }) {
OutlinedTextField(
value = appsHandling.getOrElse(state.appsHandling.ordinal) { "" },
onValueChange = {},
readOnly = true,
enabled = enabled,
label = { Text(stringResource(R.string.profile_select_apps)) },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(appsExpanded) },
modifier = Modifier.fillMaxWidth().menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable),
)
ExposedDropdownMenu(expanded = appsExpanded, onDismissRequest = { appsExpanded = false }) {
SelectedAppsHandling.values().forEachIndexed { index, handling ->
DropdownMenuItem(
text = { Text(appsHandling.getOrElse(index) { handling.name }) },
onClick = {
onChange(state.copy(appsHandling = handling))
appsExpanded = false
},
)
}
}
}
if (state.appsHandling != SelectedAppsHandling.SELECTED_APPS_DISABLE)
{
TextButton(onClick = {
val intent = Intent(context, SelectedApplicationsActivity::class.java)
intent.putStringArrayListExtra(VpnProfileDataSource.KEY_SELECTED_APPS_LIST, ArrayList(state.selectedApps))
pickApps.launch(intent)
}, enabled = enabled) {
Text(stringResource(R.string.profile_select_apps) + " (${state.selectedApps.size})")
}
}
OutlinedTextField(state.ikeProposal, { onChange(state.copy(ikeProposal = it)) },
label = { Text(stringResource(R.string.profile_proposals_ike_label)) },
enabled = enabled, modifier = Modifier.fillMaxWidth())
OutlinedTextField(state.espProposal, { onChange(state.copy(espProposal = it)) },
label = { Text(stringResource(R.string.profile_proposals_esp_label)) },
enabled = enabled, modifier = Modifier.fillMaxWidth())
OutlinedTextField(state.proxyHost, { onChange(state.copy(proxyHost = it)) },
label = { Text(stringResource(R.string.profile_proxy_host_label)) },
enabled = enabled, modifier = Modifier.fillMaxWidth())
OutlinedTextField(state.proxyPort, { onChange(state.copy(proxyPort = it)) },
label = { Text(stringResource(R.string.profile_proxy_port_label)) },
enabled = enabled, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.fillMaxWidth())
OutlinedTextField(state.proxyExclusions, { onChange(state.copy(proxyExclusions = it)) },
label = { Text(stringResource(R.string.profile_proxy_exclusions_label)) },
enabled = enabled, modifier = Modifier.fillMaxWidth())
}
}
}
}
}
}
@Composable
private fun FormSection(title: String, content: @Composable () -> Unit)
{
Surface(
shape = MaterialTheme.shapes.large,
color = MaterialTheme.colorScheme.surfaceContainerLow,
modifier = Modifier.fillMaxWidth(),
) {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp))
{
Text(
text = title.uppercase(),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
content()
}
}
}
@Composable
private fun SwitchRow(
label: String,
checked: Boolean,
enabled: Boolean,
supporting: String? = null,
onChange: (Boolean) -> Unit,
)
{
ListItem(
headlineContent = { Text(label) },
supportingContent = supporting?.let { { Text(it) } },
trailingContent = {
Switch(checked = checked, onCheckedChange = onChange, enabled = enabled)
},
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
modifier = Modifier.fillMaxWidth(),
)
}
@@ -0,0 +1,141 @@
/*
* Copyright (C) 2026 SHX VPN
*
* This program is free software; you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the
* Free Software Foundation; either version 2 of the License, or (at your
* option) any later version.
*/
package org.strongswan.android.ui.compose
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Language
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import org.strongswan.android.R
import org.strongswan.android.data.VpnProfile
import org.strongswan.android.ui.compose.theme.LocalShxStatusColors
@Composable
fun ProfileAvatar(icon: ImageVector, connected: Boolean, modifier: Modifier = Modifier)
{
val status = LocalShxStatusColors.current
val container = if (connected) status.connectedContainer else MaterialTheme.colorScheme.primaryContainer
val tint = if (connected) status.connected else MaterialTheme.colorScheme.primary
Box(
modifier = modifier
.size(40.dp)
.background(container, CircleShape),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = icon,
contentDescription = null,
tint = tint,
modifier = Modifier.size(22.dp),
)
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun ShxProfileRow(
profile: VpnProfile,
active: Boolean,
leadingIcon: ImageVector = Icons.Rounded.Language,
onClick: () -> Unit,
onLongClick: () -> Unit = {},
trailing: (@Composable () -> Unit)? = null,
)
{
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.combinedClickable(onClick = onClick, onLongClick = onLongClick)
.padding(horizontal = 16.dp, vertical = 12.dp),
) {
ProfileAvatar(icon = leadingIcon, connected = active)
Column(
modifier = Modifier
.weight(1f)
.padding(horizontal = 12.dp),
) {
Text(
text = profile.name ?: "",
style = MaterialTheme.typography.titleSmall.copy(fontSize = 16.sp),
fontWeight = FontWeight.Medium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = profile.gateway ?: "",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
if (trailing != null)
{
trailing()
}
else if (active)
{
ActiveBadge()
}
}
}
@Composable
fun ActiveBadge(modifier: Modifier = Modifier)
{
val status = LocalShxStatusColors.current
Surface(
color = status.connectedContainer,
contentColor = status.connected,
shape = CircleShape,
modifier = modifier,
) {
Text(
text = stringResource(R.string.badge_connected),
style = MaterialTheme.typography.labelSmall,
modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp),
)
}
}
@Composable
fun ShxDivider(modifier: Modifier = Modifier, indent: Boolean = true)
{
Surface(
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.6f),
modifier = modifier
.fillMaxWidth()
.padding(start = if (indent) 68.dp else 0.dp, end = if (indent) 16.dp else 0.dp)
.height(1.dp),
) {}
}
@@ -0,0 +1,269 @@
/*
* Copyright (C) 2026 SHX VPN
*
* This program is free software; you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the
* Free Software Foundation; either version 2 of the License, or (at your
* option) any later version.
*/
package org.strongswan.android.ui.compose
import androidx.compose.foundation.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
import androidx.compose.material.icons.rounded.Add
import androidx.compose.material.icons.rounded.Close
import androidx.compose.material.icons.rounded.Delete
import androidx.compose.material.icons.rounded.Edit
import androidx.compose.material.icons.rounded.Language
import androidx.compose.material.icons.rounded.MoreVert
import androidx.compose.material.icons.rounded.PlayArrow
import androidx.compose.material.icons.rounded.Search
import androidx.compose.material.icons.rounded.Stop
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.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.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.data.VpnProfile
import org.strongswan.android.logic.VpnStateService.State
import org.strongswan.android.ui.compose.theme.LocalShxStatusColors
import org.strongswan.android.ui.compose.theme.ScreenPadding
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ProfilesScreen(
state: HomeUiState,
onConnect: (VpnProfile) -> Unit,
onDisconnect: () -> Unit,
onEdit: (VpnProfile) -> Unit,
onDelete: (VpnProfile) -> Unit,
onAdd: () -> Unit,
)
{
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
val filtered = remember(query, profiles)
{
if (query.isBlank()) profiles
else profiles.filter {
(it.name ?: "").contains(query, ignoreCase = true) || (it.gateway ?: "").contains(query, ignoreCase = true)
}
}
Column(Modifier.fillMaxSize())
{
TopAppBar(
title = { Text(stringResource(R.string.nav_profiles), fontWeight = FontWeight.SemiBold) },
actions = {
IconButton(onClick = onAdd) {
Icon(Icons.Rounded.Add, contentDescription = stringResource(R.string.add_profile_cta))
}
},
colors = androidx.compose.material3.TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.background,
),
)
OutlinedTextField(
value = query,
onValueChange = { query = it },
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = ScreenPadding, vertical = 4.dp),
placeholder = { Text(stringResource(R.string.search_profiles)) },
leadingIcon = { Icon(Icons.Rounded.Search, contentDescription = null) },
trailingIcon = {
if (query.isNotEmpty())
{
IconButton(onClick = { query = "" }) {
Icon(Icons.Rounded.Close, contentDescription = null)
}
}
},
singleLine = true,
shape = MaterialTheme.shapes.medium,
)
if (filtered.isEmpty())
{
EmptyProfilesState(query = query, onAdd = onAdd)
}
else
{
Surface(
shape = MaterialTheme.shapes.large,
color = MaterialTheme.colorScheme.surfaceContainerLow,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = ScreenPadding, vertical = 12.dp),
) {
LazyColumn {
itemsIndexed(filtered, key = { _, p -> p.getUUID().toString() }) { index, profile ->
val isActive = connected && profile.getUUID() == activeUuid
Box {
ShxProfileRow(
profile = profile,
active = isActive,
onClick = { if (isActive) onDisconnect() else onConnect(profile) },
onLongClick = { menuFor = profile },
trailing = {
IconButton(onClick = { menuFor = profile }) {
Icon(
Icons.Rounded.MoreVert,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (isActive) ActiveBadge()
},
)
DropdownMenu(expanded = menuFor == profile, onDismissRequest = { menuFor = null })
{
if (isActive)
{
DropdownMenuItem(
text = { Text(stringResource(R.string.disconnect)) },
leadingIcon = { Icon(Icons.Rounded.Stop, contentDescription = null) },
onClick = {
menuFor = null
onDisconnect()
},
)
}
else
{
DropdownMenuItem(
text = { Text(stringResource(R.string.connect)) },
leadingIcon = { Icon(Icons.Rounded.PlayArrow, contentDescription = null) },
onClick = {
menuFor = null
onConnect(profile)
},
)
}
DropdownMenuItem(
text = { Text(stringResource(R.string.edit_profile)) },
leadingIcon = { Icon(Icons.Rounded.Edit, contentDescription = null) },
onClick = {
menuFor = null
onEdit(profile)
},
)
DropdownMenuItem(
text = {
Text(
stringResource(R.string.delete_profile),
color = MaterialTheme.colorScheme.error,
)
},
leadingIcon = {
Icon(
Icons.Rounded.Delete,
contentDescription = null,
tint = MaterialTheme.colorScheme.error,
)
},
onClick = {
menuFor = null
pendingDelete = profile
},
)
}
}
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(
modifier = Modifier
.fillMaxWidth()
.padding(32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(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))
}
}
}
}
}
@@ -0,0 +1,54 @@
/*
* Copyright (C) 2026 SHX VPN
*
* This program is free software; you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the
* Free Software Foundation; either version 2 of the License, or (at your
* option) any later version.
*/
package org.strongswan.android.ui.compose
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.RowScope
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SecondaryScaffold(
title: String,
onBack: () -> Unit,
actions: @Composable RowScope.() -> Unit = {},
content: @Composable (PaddingValues) -> Unit,
)
{
Scaffold(
topBar = {
TopAppBar(
title = { Text(title, maxLines = 1, overflow = TextOverflow.Ellipsis, fontWeight = FontWeight.SemiBold) },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null)
}
},
actions = actions,
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.background,
),
)
},
content = content,
)
}
@@ -0,0 +1,443 @@
/*
* Copyright (C) 2026 SHX VPN
*
* This program is free software; you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the
* Free Software Foundation; either version 2 of the License, or (at your
* option) any later version.
*/
package org.strongswan.android.ui.compose
import android.content.Context
import android.content.Intent
import android.os.Build
import android.provider.Settings
import android.widget.Toast
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.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.BatteryChargingFull
import androidx.compose.material.icons.rounded.Cached
import androidx.compose.material.icons.rounded.Description
import androidx.compose.material.icons.rounded.FileDownload
import androidx.compose.material.icons.rounded.History
import androidx.compose.material.icons.rounded.Key
import androidx.compose.material.icons.rounded.Security
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.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.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.preference.PreferenceManager
import org.strongswan.android.R
import org.strongswan.android.data.VpnProfile
import org.strongswan.android.logic.StrongSwanApplication
import org.strongswan.android.ui.LogActivity
import org.strongswan.android.ui.TrustedCertificatesActivity
import org.strongswan.android.ui.TrustedNetworksActivity
import org.strongswan.android.ui.VpnProfileImportActivity
import org.strongswan.android.ui.compose.theme.ScreenPadding
import org.strongswan.android.utils.Constants
@OptIn(androidx.compose.material3.ExperimentalMaterial3Api::class)
@Composable
fun SettingsScreen(profiles: List<VpnProfile>, showTitle: Boolean = true)
{
val context = LocalContext.current
val prefs = remember { PreferenceManager.getDefaultSharedPreferences(context) }
val managed = remember { StrongSwanApplication.getInstance().managedConfigurationService.managedConfiguration }
var defaultUuid by remember {
mutableStateOf(prefs.getString(Constants.PREF_DEFAULT_VPN_PROFILE, Constants.PREF_DEFAULT_VPN_PROFILE_MRU)
?: Constants.PREF_DEFAULT_VPN_PROFILE_MRU)
}
var ignorePower by remember { mutableStateOf(prefs.getBoolean(Constants.PREF_IGNORE_POWER_WHITELIST, false)) }
var showDefaultPicker by remember { mutableStateOf(false) }
var showCrl by remember { mutableStateOf(false) }
val versionName = remember {
try
{
context.packageManager.getPackageInfo(context.packageName, 0).versionName ?: ""
}
catch (_: Exception)
{
""
}
}
val defaultLabel = if (defaultUuid == Constants.PREF_DEFAULT_VPN_PROFILE_MRU)
stringResource(R.string.pref_default_vpn_profile_mru)
else profiles.firstOrNull { it.getUUID().toString() == defaultUuid }?.name
?: stringResource(R.string.profile_not_found)
Column(
Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(bottom = 24.dp),
)
{
if (showTitle)
{
TopAppBar(
title = { Text(stringResource(R.string.pref_title), fontWeight = FontWeight.SemiBold) },
colors = androidx.compose.material3.TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.background,
),
)
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && managed.isAllowSettingsAccess)
{
SettingsSection(title = stringResource(R.string.settings_section_general))
{
SettingsRow(
icon = Icons.Rounded.History,
title = stringResource(R.string.pref_default_vpn_profile),
subtitle = defaultLabel,
onClick = { showDefaultPicker = true },
)
SettingsDivider()
SettingsToggleRow(
icon = Icons.Rounded.BatteryChargingFull,
title = stringResource(R.string.pref_power_whitelist_title),
subtitle = stringResource(R.string.pref_power_whitelist_summary),
checked = ignorePower,
onCheckedChange = {
ignorePower = it
prefs.edit().putBoolean(Constants.PREF_IGNORE_POWER_WHITELIST, it).apply()
},
)
}
}
SettingsSection(title = stringResource(R.string.settings_section_automation))
{
SettingsRow(
icon = Icons.Rounded.Wifi,
title = stringResource(R.string.auto_connect_title),
subtitle = stringResource(R.string.auto_connect_summary),
onClick = {
context.startActivity(Intent(context, TrustedNetworksActivity::class.java))
},
)
}
SettingsSection(title = stringResource(R.string.settings_section_security))
{
val lockdownActive = remember { context.isVpnLockdownEnabled() }
SettingsRow(
icon = Icons.Rounded.Security,
title = stringResource(R.string.kill_switch_title),
subtitle = stringResource(
if (lockdownActive) R.string.kill_switch_summary_on
else R.string.kill_switch_summary_off
),
onClick = {
context.startActivity(Intent(Settings.ACTION_VPN_SETTINGS))
},
)
}
SettingsSection(title = stringResource(R.string.settings_section_certs))
{
SettingsRow(
icon = Icons.Rounded.VerifiedUser,
title = stringResource(R.string.trusted_certs_title),
onClick = {
context.startActivity(Intent(context, TrustedCertificatesActivity::class.java))
},
)
SettingsDivider()
SettingsRow(
icon = Icons.Rounded.Cached,
title = stringResource(R.string.crl_cache),
onClick = { showCrl = true },
)
}
if (managed.isAllowProfileImport)
{
SettingsSection(title = stringResource(R.string.settings_section_profiles))
{
SettingsRow(
icon = Icons.Rounded.FileDownload,
title = stringResource(R.string.profile_import),
onClick = {
context.startActivity(Intent(context, VpnProfileImportActivity::class.java))
},
)
}
}
SettingsSection(title = stringResource(R.string.settings_section_support))
{
SettingsRow(
icon = Icons.Rounded.Description,
title = stringResource(R.string.show_log),
onClick = {
context.startActivity(Intent(context, LogActivity::class.java))
},
)
}
Text(
text = stringResource(R.string.app_version_fmt, versionName),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier
.fillMaxWidth()
.padding(top = 16.dp),
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
)
}
if (showDefaultPicker)
{
val options = listOf(Constants.PREF_DEFAULT_VPN_PROFILE_MRU to context.getString(R.string.pref_default_vpn_profile_mru)) +
profiles.map { it.getUUID().toString() to (it.name ?: "") }
AlertDialog(
onDismissRequest = { showDefaultPicker = false },
title = { Text(stringResource(R.string.pref_default_vpn_profile)) },
text = {
Column {
options.forEach { (value, label) ->
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.clickable {
defaultUuid = value
prefs.edit().putString(Constants.PREF_DEFAULT_VPN_PROFILE, value).apply()
showDefaultPicker = false
}
.padding(vertical = 10.dp),
)
{
RadioButton(selected = defaultUuid == value, onClick = null)
Text(label, modifier = Modifier.padding(start = 12.dp))
}
}
}
},
confirmButton = {
TextButton(onClick = { showDefaultPicker = false }) {
Text(stringResource(android.R.string.cancel))
}
},
)
}
if (showCrl)
{
val files = remember(showCrl) { context.fileList().filter { it.startsWith("crl-") } }
AlertDialog(
onDismissRequest = { showCrl = false },
title = { Text(stringResource(R.string.clear_crl_cache_title)) },
text = {
Text(
if (files.isEmpty()) stringResource(R.string.clear_crl_cache_msg_none)
else stringResource(R.string.clear_crl_cache_title)
)
},
confirmButton = {
TextButton(onClick = {
if (files.isEmpty())
{
Toast.makeText(context, R.string.clear_crl_cache_msg_none, Toast.LENGTH_SHORT).show()
}
else
{
files.forEach { context.deleteFile(it) }
}
showCrl = false
}) { Text(stringResource(R.string.clear)) }
},
dismissButton = {
TextButton(onClick = { showCrl = false }) {
Text(stringResource(android.R.string.cancel))
}
},
)
}
}
@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),
)
{
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),
)
}
}
@Composable
private fun SettingsRow(
icon: ImageVector,
title: String,
subtitle: String? = null,
onClick: () -> Unit,
)
{
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.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)
{
Text(
subtitle,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
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)
{
Text(
subtitle,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
Switch(checked = checked, onCheckedChange = onCheckedChange)
}
}
/**
* Checks whether the system-level lockdown ("Block connections without VPN") is
* active for our package. Returns false if not determinable.
*/
private fun Context.isVpnLockdownEnabled(): Boolean
{
return try
{
Settings.Global.getInt(
contentResolver,
"vpn_lockdown_$packageName",
0
) != 0
}
catch (_: Exception)
{
false
}
}
@@ -0,0 +1,180 @@
/*
* Copyright (C) 2026 SHX VPN
*
* This program is free software; you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the
* Free Software Foundation; either version 2 of the License, or (at your
* option) any later version.
*/
package org.strongswan.android.ui.compose
import android.content.Intent
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
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.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
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.Home
import androidx.compose.material.icons.outlined.Settings
import androidx.compose.material.icons.outlined.Storage
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.NavigationBarItemDefaults
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.compositeOver
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
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import org.strongswan.android.R
import org.strongswan.android.data.VpnProfile
import org.strongswan.android.data.VpnProfileDataSource
import org.strongswan.android.logic.StrongSwanApplication
import org.strongswan.android.logic.TrustedCertificateManager
import org.strongswan.android.ui.LogActivity
import org.strongswan.android.ui.VpnProfileDetailActivity
import org.strongswan.android.ui.compose.theme.ShxTheme
fun ComponentActivity.installShxUi()
{
enableEdgeToEdge()
(application as StrongSwanApplication).executor.execute {
TrustedCertificateManager.getInstance().load()
}
setContent {
ShxTheme {
Surface(Modifier.fillMaxSize()) {
ShxApp()
}
}
}
}
@Composable
fun ShxApp(viewModel: HomeViewModel = viewModel())
{
val state by viewModel.ui.collectAsStateWithLifecycle()
val context = LocalContext.current
val addProfile = {
context.startActivity(Intent(context, VpnProfileDetailActivity::class.java))
}
val editProfile: (VpnProfile) -> Unit = { profile ->
val intent = Intent(context, VpnProfileDetailActivity::class.java)
intent.putExtra(VpnProfileDataSource.KEY_UUID, profile.getUUID().toString())
context.startActivity(intent)
}
Scaffold(
bottomBar = {
ShxBottomBar(selected = state.tab, onSelect = viewModel::setTab)
},
containerColor = MaterialTheme.colorScheme.background,
) { padding ->
Surface(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.consumeWindowInsets(padding),
color = MaterialTheme.colorScheme.background,
) {
AnimatedContent(
targetState = state.tab,
transitionSpec = {
val forward = targetState.ordinal >= initialState.ordinal
val direction = if (forward) 1 else -1
(slideInHorizontally(tween(240)) { it / 6 * direction } + fadeIn(tween(240))) togetherWith
(slideOutHorizontally(tween(180)) { -it / 6 * direction } + fadeOut(tween(160)))
},
label = "tabContent",
) { tab ->
when (tab)
{
ShxTab.HOME -> HomeScreen(
state = state,
trafficFlow = viewModel.traffic,
viewModel = viewModel,
onAddProfile = addProfile,
onEditProfile = editProfile,
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,
onAdd = addProfile,
)
ShxTab.SETTINGS -> SettingsScreen(profiles = state.profiles)
}
}
}
}
}
@Composable
private fun ShxBottomBar(selected: ShxTab, onSelect: (ShxTab) -> Unit)
{
val bg = MaterialTheme.colorScheme.surfaceContainer
val indicator = MaterialTheme.colorScheme.primary.copy(alpha = 0.32f).compositeOver(bg)
NavigationBar(containerColor = bg, tonalElevation = 0.dp) {
ShxTab.entries.forEach { tab ->
NavigationBarItem(
selected = tab == selected,
onClick = { onSelect(tab) },
icon = {
Icon(
imageVector = tab.icon(selected = tab == selected),
contentDescription = null,
)
},
label = { Text(stringResource(tab.label())) },
colors = NavigationBarItemDefaults.colors(
selectedIconColor = MaterialTheme.colorScheme.primary,
unselectedIconColor = MaterialTheme.colorScheme.onSurfaceVariant,
selectedTextColor = MaterialTheme.colorScheme.primary,
unselectedTextColor = MaterialTheme.colorScheme.onSurfaceVariant,
indicatorColor = indicator,
),
)
}
}
}
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.SETTINGS -> if (selected) Icons.Filled.Settings else Icons.Outlined.Settings
}
private fun ShxTab.label(): Int = when (this)
{
ShxTab.HOME -> R.string.nav_connect
ShxTab.PROFILES -> R.string.nav_profiles
ShxTab.SETTINGS -> R.string.nav_settings
}
@@ -0,0 +1,571 @@
/*
* 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 android.Manifest
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build
import android.provider.Settings
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
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.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
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.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import org.strongswan.android.R
import org.strongswan.android.data.VpnProfile
import org.strongswan.android.logic.autoconnect.AutoConnectManager
import org.strongswan.android.ui.compose.theme.ScreenPadding
/**
* Trusted networks settings: when enabled, the VPN is started on cellular and untrusted
* Wi-Fi, and auto sessions are stopped on trusted Wi-Fi.
*/
@Composable
fun TrustedNetworksScreen(
profiles: List<VpnProfile>,
currentSsid: String?,
)
{
val context = LocalContext.current
val manager = remember { AutoConnectManager.getInstance(context) }
var config by remember { mutableStateOf(manager.currentConfig) }
var showProfilePicker by remember { mutableStateOf(false) }
var showAddSsid by remember { mutableStateOf(false) }
var pendingDeleteSsid by remember { mutableStateOf<String?>(null) }
var hasBackgroundLocation by remember { mutableStateOf(isBackgroundLocationGranted(context)) }
var askedBackground by remember { mutableStateOf(false) }
val lifecycleOwner = LocalLifecycleOwner.current
fun refreshPermissions()
{
hasBackgroundLocation = isBackgroundLocationGranted(context)
if (isFineLocationGranted(context))
{
manager.reloadConfig()
}
}
val requestBackground = rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission()
) { refreshPermissions() }
val requestForeground = rememberLauncherForActivityResult(
ActivityResultContracts.RequestMultiplePermissions()
) {
refreshPermissions()
if (isFineLocationGranted(context) && !isBackgroundLocationGranted(context) &&
Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q)
{
askedBackground = true
requestBackground.launch(Manifest.permission.ACCESS_BACKGROUND_LOCATION)
}
}
fun requestSsidPermissions()
{
if (!isFineLocationGranted(context))
{
requestForeground.launch(wifiSsidPermissions())
return
}
if (!isBackgroundLocationGranted(context) && Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q)
{
if (askedBackground)
{
openAppDetails(context)
return
}
askedBackground = true
requestBackground.launch(Manifest.permission.ACCESS_BACKGROUND_LOCATION)
}
}
DisposableEffect(lifecycleOwner)
{
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME)
{
refreshPermissions()
}
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
}
val selectedProfile = profiles.firstOrNull { it.getUUID().toString() == config.profileUuid }
val enabled = config.enabled
fun update(transform: (AutoConnectManager.Config) -> AutoConnectManager.Config)
{
val updated = transform(config)
manager.updateConfig { updated }
config = updated
}
Column(
Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(bottom = 24.dp),
)
{
TrustedSection(title = stringResource(R.string.settings_section_general))
{
TrustedToggleRow(
icon = Icons.Rounded.Wifi,
title = stringResource(R.string.auto_connect_title),
subtitle = stringResource(R.string.auto_connect_summary),
checked = enabled,
onCheckedChange = { checked ->
if (checked)
{
requestSsidPermissions()
}
update { it.copy(enabled = checked) }
},
)
TrustedDivider()
TrustedRow(
icon = Icons.Rounded.Language,
title = stringResource(R.string.auto_connect_profile),
subtitle = selectedProfile?.name
?: stringResource(R.string.auto_connect_profile_none),
enabled = enabled,
onClick = { showProfilePicker = true },
)
}
if (enabled && !hasBackgroundLocation)
{
Spacer(Modifier.size(12.dp))
Surface(
shape = MaterialTheme.shapes.large,
color = MaterialTheme.colorScheme.errorContainer,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = ScreenPadding)
.clickable { requestSsidPermissions() },
)
{
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
)
{
Text(
text = stringResource(R.string.auto_connect_location_banner),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onErrorContainer,
modifier = Modifier.weight(1f),
)
TextButton(onClick = { requestSsidPermissions() }) {
Text(stringResource(R.string.auto_connect_location_grant))
}
}
}
}
TrustedSection(title = stringResource(R.string.auto_connect_trusted_title))
{
Text(
text = stringResource(R.string.auto_connect_trusted_hint),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
)
if (config.trustedSsids.isEmpty())
{
Text(
text = stringResource(R.string.auto_connect_no_networks),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 14.dp),
)
}
else
{
val sortedSsids = remember(config.trustedSsids) { config.trustedSsids.sorted() }
sortedSsids.forEachIndexed { index, ssid ->
if (index > 0)
{
TrustedDivider()
}
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
)
{
Icon(
Icons.Rounded.Wifi,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(20.dp),
)
Text(
text = ssid,
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier
.weight(1f)
.padding(horizontal = 16.dp, vertical = 14.dp),
)
IconButton(onClick = { pendingDeleteSsid = ssid }) {
Icon(
Icons.Rounded.Delete,
contentDescription = stringResource(R.string.delete_profile),
tint = MaterialTheme.colorScheme.error,
)
}
}
}
}
TrustedDivider()
TrustedAddNetworkRow(
currentSsid = currentSsid,
onAddCurrent = { ssid -> update { it.copy(trustedSsids = it.trustedSsids + ssid) } },
onAddManual = { showAddSsid = true },
)
}
}
if (showProfilePicker)
{
AlertDialog(
onDismissRequest = { showProfilePicker = false },
title = { Text(stringResource(R.string.auto_connect_profile)) },
text = {
Column {
profiles.forEach { profile ->
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.clickable {
update { it.copy(profileUuid = profile.getUUID().toString()) }
showProfilePicker = false
}
.padding(vertical = 10.dp),
)
{
RadioButton(
selected = config.profileUuid == profile.getUUID().toString(),
onClick = null,
)
Text(profile.name ?: "", modifier = Modifier.padding(start = 12.dp))
}
}
}
},
confirmButton = {
TextButton(onClick = { showProfilePicker = false }) {
Text(stringResource(android.R.string.cancel))
}
},
)
}
if (showAddSsid)
{
var ssid by remember { mutableStateOf("") }
AlertDialog(
onDismissRequest = { showAddSsid = false },
title = { Text(stringResource(R.string.auto_connect_add_network)) },
text = {
OutlinedTextField(
value = ssid,
onValueChange = { ssid = it },
singleLine = true,
label = { Text(stringResource(R.string.auto_connect_ssid_label)) },
)
},
confirmButton = {
TextButton(
onClick = {
val trimmed = ssid.trim()
if (trimmed.isNotEmpty())
{
update { it.copy(trustedSsids = it.trustedSsids + trimmed) }
}
showAddSsid = false
},
enabled = ssid.isNotBlank(),
) { Text(stringResource(R.string.add_profile_cta)) }
},
dismissButton = {
TextButton(onClick = { showAddSsid = false }) {
Text(stringResource(android.R.string.cancel))
}
},
)
}
pendingDeleteSsid?.let { ssid ->
AlertDialog(
onDismissRequest = { pendingDeleteSsid = null },
title = { Text(stringResource(R.string.auto_connect_remove_network)) },
text = { Text(ssid) },
confirmButton = {
TextButton(onClick = {
update { it.copy(trustedSsids = it.trustedSsids - ssid) }
pendingDeleteSsid = null
}) { Text(stringResource(R.string.delete_profile), color = MaterialTheme.colorScheme.error) }
},
dismissButton = {
TextButton(onClick = { pendingDeleteSsid = null }) {
Text(stringResource(android.R.string.cancel))
}
},
)
}
}
@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?,
onAddCurrent: (String) -> Unit,
onAddManual: () -> Unit,
)
{
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = androidx.compose.foundation.layout.Arrangement.Center,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 6.dp),
)
{
if (currentSsid != null)
{
TextButton(onClick = { onAddCurrent(currentSsid) })
{
Icon(Icons.Rounded.Add, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(Modifier.size(8.dp))
Text(stringResource(R.string.auto_connect_add_current, currentSsid))
}
}
else
{
TextButton(onClick = onAddManual)
{
Icon(Icons.Rounded.Add, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(Modifier.size(8.dp))
Text(stringResource(R.string.auto_connect_add_network))
}
}
}
}
private fun isFineLocationGranted(context: Context): Boolean =
ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) ==
PackageManager.PERMISSION_GRANTED
private fun isBackgroundLocationGranted(context: Context): Boolean
{
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q)
{
return true
}
return ContextCompat.checkSelfPermission(
context, Manifest.permission.ACCESS_BACKGROUND_LOCATION
) == PackageManager.PERMISSION_GRANTED
}
private fun wifiSsidPermissions(): Array<String> = buildList {
add(Manifest.permission.ACCESS_FINE_LOCATION)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU)
{
add(Manifest.permission.NEARBY_WIFI_DEVICES)
}
}.toTypedArray()
private fun openAppDetails(context: Context)
{
context.startActivity(
Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
data = Uri.fromParts("package", context.packageName, null)
}
)
}
@@ -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.
*/
package org.strongswan.android.ui.compose.theme
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.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
val ConnectionCardRadius = 16.dp
val ScreenPadding = 16.dp
val ProtonPurpleLight = Color(0xFF6D4AFF)
val ProtonPurpleDark = Color(0xFF9C7CFF)
val SuccessGreenLight = Color(0xFF148A45)
val SuccessGreenDark = Color(0xFF28A671)
private val LightColors = lightColorScheme(
primary = ProtonPurpleLight,
onPrimary = Color.White,
primaryContainer = Color(0xFFEDE7FF),
onPrimaryContainer = Color(0xFF2E1A7A),
inversePrimary = ProtonPurpleDark,
secondary = Color(0xFF625B71),
onSecondary = Color.White,
secondaryContainer = Color(0xFFE8DEF8),
onSecondaryContainer = Color(0xFF1D192B),
tertiary = SuccessGreenLight,
onTertiary = Color.White,
tertiaryContainer = Color(0xFFC8F2DA),
onTertiaryContainer = Color(0xFF07391D),
background = Color(0xFFF7F7F8),
onBackground = Color(0xFF1B1B20),
surface = Color(0xFFF7F7F8),
onSurface = Color(0xFF1B1B20),
surfaceVariant = Color(0xFFEBEBEF),
onSurfaceVariant = Color(0xFF57575F),
surfaceDim = Color(0xFFDADAE0),
surfaceBright = Color(0xFFF7F7F8),
surfaceContainerLowest = Color.White,
surfaceContainerLow = Color(0xFFF2F2F4),
surfaceContainer = Color.White,
surfaceContainerHigh = Color(0xFFF2F2F4),
surfaceContainerHighest = Color(0xFFEBEBEF),
outline = Color(0xFF797981),
outlineVariant = Color(0xFFD0D0D6),
inverseSurface = Color(0xFF2F2F35),
inverseOnSurface = Color(0xFFF2F2F4),
error = Color(0xFFD8303C),
onError = Color.White,
errorContainer = Color(0xFFFFDAD6),
onErrorContainer = Color(0xFF410002),
scrim = Color.Black,
)
private val DarkColors = darkColorScheme(
primary = ProtonPurpleDark,
onPrimary = Color(0xFF150F35),
primaryContainer = Color(0xFF3B2C88),
onPrimaryContainer = Color(0xFFEDE7FF),
inversePrimary = ProtonPurpleLight,
secondary = Color(0xFFCCC2DC),
onSecondary = Color(0xFF332D41),
secondaryContainer = Color(0xFF4A4458),
onSecondaryContainer = Color(0xFFE8DEF8),
tertiary = SuccessGreenDark,
onTertiary = Color(0xFF00391B),
tertiaryContainer = Color(0xFF005228),
onTertiaryContainer = Color(0xFFC8F2DA),
background = Color(0xFF17171A),
onBackground = Color(0xFFE6E6E9),
surface = Color(0xFF17171A),
onSurface = Color(0xFFE6E6E9),
surfaceVariant = Color(0xFF2C2C31),
onSurfaceVariant = Color(0xFFC0C0C8),
surfaceDim = Color(0xFF17171A),
surfaceBright = Color(0xFF3B3B41),
surfaceContainerLowest = Color(0xFF121214),
surfaceContainerLow = Color(0xFF1F1F23),
surfaceContainer = Color(0xFF242428),
surfaceContainerHigh = Color(0xFF2C2C31),
surfaceContainerHighest = Color(0xFF36363B),
outline = Color(0xFF8B8B93),
outlineVariant = Color(0xFF3D3D43),
inverseSurface = Color(0xFFE6E6E9),
inverseOnSurface = Color(0xFF2C2C31),
error = Color(0xFFFF8A8C),
onError = Color(0xFF490005),
errorContainer = Color(0xFF68000E),
onErrorContainer = Color(0xFFFFDAD6),
scrim = Color.Black,
)
@Immutable
data class ShxStatusColors(
val connected: Color,
val connectedContainer: Color,
val onConnectedContainer: Color,
val connecting: Color,
val connectingContainer: Color,
val onConnectingContainer: Color,
val disconnected: Color,
val disconnectedContainer: Color,
val onDisconnectedContainer: Color,
)
private val LightStatusColors = ShxStatusColors(
connected = SuccessGreenLight,
connectedContainer = Color(0xFFDCF3E5),
onConnectedContainer = Color(0xFF07391D),
connecting = ProtonPurpleLight,
connectingContainer = Color(0xFFEDE7FF),
onConnectingContainer = Color(0xFF2E1A7A),
disconnected = Color(0xFFC0362C),
disconnectedContainer = Color(0xFFFFE1DE),
onDisconnectedContainer = Color(0xFF410001),
)
private val DarkStatusColors = ShxStatusColors(
connected = SuccessGreenDark,
connectedContainer = Color(0xFF0B3B24),
onConnectedContainer = Color(0xFFC8F2DA),
connecting = ProtonPurpleDark,
connectingContainer = Color(0xFF2E2360),
onConnectingContainer = Color(0xFFEDE7FF),
disconnected = Color(0xFFED6B62),
disconnectedContainer = Color(0xFF4A1712),
onDisconnectedContainer = Color(0xFFFFE1DE),
)
val LocalShxStatusColors = staticCompositionLocalOf { LightStatusColors }
private val ShxShapes = Shapes(
extraSmall = RoundedCornerShape(6.dp),
small = RoundedCornerShape(10.dp),
medium = RoundedCornerShape(14.dp),
large = RoundedCornerShape(16.dp),
extraLarge = RoundedCornerShape(22.dp),
)
@Composable
fun ShxTheme(darkTheme: Boolean = isSystemInDarkTheme(), content: @Composable () -> Unit)
{
val colors = if (darkTheme) DarkColors else LightColors
val statusColors = if (darkTheme) DarkStatusColors else LightStatusColors
CompositionLocalProvider(LocalShxStatusColors provides statusColors) {
MaterialTheme(
colorScheme = colors,
shapes = ShxShapes,
content = content,
)
}
}
@@ -21,17 +21,17 @@ public final class Constants
/**
* Intent action used to notify about changes to the VPN profiles
*/
public static final String VPN_PROFILES_CHANGED = "org.strongswan.android.VPN_PROFILES_CHANGED";
public static final String VPN_PROFILES_CHANGED = "one.shx.strongswan.ext.VPN_PROFILES_CHANGED";
/**
* Used in the intent above to notify about edits or inserts of a VPN profile (long)
*/
public static final String VPN_PROFILES_SINGLE = "org.strongswan.android.VPN_PROFILES_SINGLE";
public static final String VPN_PROFILES_SINGLE = "one.shx.strongswan.ext.VPN_PROFILES_SINGLE";
/**
* Used in the intent above to notify about the deletion of multiple VPN profiles (array of longs)
*/
public static final String VPN_PROFILES_MULTIPLE = "org.strongswan.android.VPN_PROFILES_MULTIPLE";
public static final String VPN_PROFILES_MULTIPLE = "one.shx.strongswan.ext.VPN_PROFILES_MULTIPLE";
/**
* Limits for MTU
@@ -1,2 +0,0 @@
APP_PLATFORM := android-21
APP_SUPPORT_FLEXIBLE_PAGE_SIZES := true
@@ -38,8 +38,10 @@
#endif
#include <daemon.h>
#include <ipsec.h>
#include <sa/ike_sa.h>
#include <sa/child_sa.h>
#include <library.h>
#include <ipsec.h>
#include <threading/thread.h>
#define ANDROID_DEBUG_LEVEL 1
@@ -49,6 +51,8 @@
#define ANDROID_KEEPALIVE_INTERVAL 45
#define ANDROID_KEEPALIVE_DPD_MARGIN 20
static volatile bool charon_running = FALSE;
typedef struct private_charonservice_t private_charonservice_t;
/**
@@ -754,6 +758,7 @@ JNI_METHOD(CharonVpnService, initializeCharon, jboolean,
/* start daemon (i.e. the threads in the thread-pool) */
charon->start(charon);
charon_running = TRUE;
return TRUE;
}
@@ -762,6 +767,7 @@ JNI_METHOD(CharonVpnService, initializeCharon, jboolean,
*/
JNI_METHOD(CharonVpnService, deinitializeCharon, void)
{
charon_running = FALSE;
/* deinitialize charon before we destroy our own objects */
libcharon_deinit();
charonservice_deinit(env);
@@ -785,6 +791,44 @@ JNI_METHOD(CharonVpnService, initiate, void,
initiate(settings);
}
/**
* Sum inbound/outbound CHILD_SA bytes. Static so it can be polled from the UI.
*/
JNI_METHOD(CharonVpnService, queryTraffic, jlongArray)
{
jlong values[2] = { 0, 0 };
jlongArray result;
enumerator_t *isas, *children;
ike_sa_t *ike_sa;
child_sa_t *child_sa;
uint64_t bytes;
result = (*env)->NewLongArray(env, 2);
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))
{
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);
}
isas->destroy(isas);
(*env)->SetLongArrayRegion(env, result, 0, 2, values);
return result;
}
/**
* Utility function to verify proposal strings (static, so `this` is the class)
*/
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="@color/reui_destructive_bg" />
<corners android:radius="999dp" />
</shape>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="@color/reui_success_bg" />
<corners android:radius="999dp" />
</shape>
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FF000000"
android:pathData="M4,1h16c1.1,0 2,0.9 2,2v4c0,1.1 -0.9,2 -2,2H4C2.9,9 2,8.1 2,7V3c0,-1.1 0.9,-2 2,-2zM4,11h16c1.1,0 2,0.9 2,2v4c0,1.1 -0.9,2 -2,2H4c-1.1,0 -2,-0.9 -2,-2v-4c0,-1.1 0.9,-2 2,-2zM6,5h2v2H6V5zM6,15h2v2H6v-2z" />
</vector>
@@ -1,69 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
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.
-->
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@drawable/state_background"
android:orientation="vertical" >
<LinearLayout
android:id="@+id/imc_state_button"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?android:attr/selectableItemBackground"
android:orientation="vertical" >
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="20dp"
android:layout_marginEnd="20dp"
android:layout_marginTop="10dp"
android:orientation="horizontal" >
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="5dp"
android:text="@string/imc_state_label"
android:textColor="?android:textColorPrimary"
android:textSize="20sp" />
<TextView
android:id="@+id/imc_state"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text=""
android:textColor="?android:textColorSecondary"
android:textSize="20sp" />
</LinearLayout>
<TextView
android:id="@+id/action"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="10dp"
android:layout_marginStart="20dp"
android:layout_marginEnd="20dp"
android:text="@string/show_remediation_instructions"
android:textAppearance="?android:attr/textAppearanceSmall"
android:textColor="?android:attr/textColorSecondary" />
</LinearLayout>
</LinearLayout>
@@ -1,42 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright (C) 2012-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.
-->
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:id="@+id/layout" >
<fragment
class="org.strongswan.android.ui.VpnStateFragment"
android:id="@+id/vpn_state_frag"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
<fragment
class="org.strongswan.android.ui.ImcStateFragment"
android:id="@+id/imc_state_frag"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
<fragment
class="org.strongswan.android.ui.VpnProfileListFragment"
android:id="@+id/profile_list_frag"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1" />
</LinearLayout>
@@ -1,41 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
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.
-->
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:paddingBottom="0dp"
android:paddingTop="10dp"
android:paddingStart="5dp"
android:paddingEnd="5dp" >
<ListView
android:id="@+id/profile_list"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:dividerHeight="1dp"
android:divider="?android:attr/listDivider"
android:overScrollFooter="@android:color/transparent"
android:scrollbarAlwaysDrawVerticalTrack="true"
android:clipToPadding="false" />
<TextView android:id="@+id/profile_list_empty"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_marginStart="15dp"
android:text="@string/no_profiles"/>
</FrameLayout>
@@ -1,76 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright (C) 2012 Tobias Brunner
Copyright (C) 2012 Giuliano Grassi
Copyright (C) 2012 Ralf Sager
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.
-->
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?android:attr/activatedBackgroundIndicator"
android:orientation="vertical"
android:paddingBottom="6dip"
android:paddingTop="4dip">
<TextView
android:id="@+id/profile_item_name"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="15dp"
android:textAppearance="?android:attr/textAppearanceMedium"
tools:text="Profile name" />
<TextView
android:id="@+id/profile_item_managed"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="15dp"
android:text="@string/profile_managed"
android:textAppearance="?android:attr/textAppearanceSmall"
android:textColor="@color/success_text"
android:visibility="gone"
tools:visibility="visible" />
<TextView
android:id="@+id/profile_item_gateway"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="15dp"
android:textAppearance="?android:attr/textAppearanceSmall"
android:textColor="?android:textColorSecondary"
tools:text="Server: vpn.example.com" />
<TextView
android:id="@+id/profile_item_username"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="15dp"
android:textAppearance="?android:attr/textAppearanceSmall"
android:textColor="?android:textColorSecondary"
tools:text="Username" />
<TextView
android:id="@+id/profile_item_certificate"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="15dp"
android:ellipsize="end"
android:singleLine="true"
android:textAppearance="?android:attr/textAppearanceSmall"
android:textColor="?android:textColorSecondary"
tools:text="Certificate" />
</LinearLayout>
@@ -1,156 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright (C) 2012-2016 Tobias Brunner
Copyright (C) 2012 Giuliano Grassi
Copyright (C) 2012 Ralf Sager
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.
-->
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:animateLayoutChanges="true" >
<LinearLayout
android:id="@+id/vpn_error"
android:visibility="gone"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@drawable/error_background"
android:orientation="vertical" >
<TextView
android:id="@+id/vpn_error_text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="20dp"
android:layout_marginEnd="20dp"
android:layout_marginTop="24dp"
android:layout_marginBottom="12dp"
android:text="Failed to establish VPN: Server is unreachable"
android:textColor="@color/primary_dark"
android:textSize="16sp" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_marginBottom="8dp"
android:orientation="horizontal"
android:gravity="end" >
<Button
android:id="@+id/show_log"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:text="@string/show_log"
android:textColor="@color/primary"
android:textSize="14sp"
android:textStyle="bold"
style="?android:attr/borderlessButtonStyle" >
</Button>
<Button
android:id="@+id/retry"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/retry"
android:textColor="@color/primary"
android:textSize="14sp"
android:textStyle="bold"
style="?android:attr/borderlessButtonStyle" >
</Button>
</LinearLayout>
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@drawable/state_background"
android:orientation="vertical" >
<GridLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="10dp"
android:layout_marginStart="20dp"
android:layout_marginEnd="20dp"
android:layout_marginTop="10dp"
android:columnCount="2"
android:rowCount="2" >
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="5dp"
android:gravity="top"
android:text="@string/state_label"
android:textColor="?android:textColorPrimary"
android:textSize="20sp" />
<TextView
android:id="@+id/vpn_state"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:gravity="top"
android:text="@string/state_disabled"
android:textColor="?android:textColorSecondary"
android:textSize="20sp" />
<TextView
android:id="@+id/vpn_profile_label"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="5dp"
android:gravity="top"
android:text="@string/profile_label"
android:textColor="?android:textColorPrimary"
android:textSize="20sp"
android:visibility="gone" >
</TextView>
<TextView
android:id="@+id/vpn_profile_name"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:gravity="top"
android:textSize="20sp"
android:visibility="gone" >
</TextView>
</GridLayout>
<ProgressBar
android:id="@+id/progress"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="10dp"
android:layout_marginStart="20dp"
android:layout_marginEnd="20dp"
android:indeterminate="true"
android:visibility="gone"
style="@style/Widget.AppCompat.ProgressBar.Horizontal" />
<Button
android:id="@+id/action"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="10dp"
android:layout_marginStart="20dp"
android:layout_marginEnd="20dp"
android:text="@string/disconnect"
style="?android:attr/borderlessButtonStyle" >
</Button>
</LinearLayout>
</LinearLayout>
@@ -1,45 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright (C) 2012-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.
-->
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<item
android:id="@+id/menu_import_profile"
android:title="@string/profile_import"
app:showAsAction="withText" />
<item
android:id="@+id/menu_manage_certs"
android:title="@string/trusted_certs_title"
app:showAsAction="withText" />
<item
android:id="@+id/menu_crl_cache"
android:title="@string/crl_cache"
app:showAsAction="withText" />
<item
android:id="@+id/menu_show_log"
android:title="@string/show_log"
app:showAsAction="withText" />
<item
android:id="@+id/menu_settings"
android:title="@string/pref_title"
app:showAsAction="withText" />
</menu>
@@ -1,24 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
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.
-->
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<item android:id="@+id/add_profile"
android:title="@string/add_profile"
app:showAsAction="always|withText" />
</menu>
@@ -1,28 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright (C) 2012-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.
-->
<menu xmlns:android="http://schemas.android.com/apk/res/android" >
<item android:id="@+id/edit_profile"
android:title="@string/edit_profile" />
<item android:id="@+id/copy_profile"
android:title="@string/copy_profile" />
<item android:id="@+id/delete_profile"
android:title="@string/delete_profile" />
</menu>
@@ -19,8 +19,8 @@
<resources>
<!-- Application -->
<string name="app_name">strongSwan VPN Client</string>
<string name="main_activity_name">strongSwan</string>
<string name="app_name">SHX VPN</string>
<string name="main_activity_name">SHX VPN</string>
<string name="show_log">Log anzeigen</string>
<string name="search">Suchen</string>
<string name="vpn_not_supported_title">VPN nicht unterstützt</string>
@@ -29,7 +29,7 @@
<string name="vpn_not_supported_no_permission">Keine Berechtigung, um VPN Verbindungen zu erstellen. Entweder weil diese vom Benutzer verweigert wurde oder weil für eine andere VPN Anwendung der Always-On-Modus aktiviert ist.</string>
<string name="loading">Laden&#8230;</string>
<string name="profile_not_found">Profil nicht gefunden</string>
<string name="strongswan_shortcut">strongSwan-Verknüpfung</string>
<string name="strongswan_shortcut">SHX VPN-Verknüpfung</string>
<string name="permanent_notification_name">VPN Verbindungsstatus</string>
<string name="permanent_notification_description">Zeigt Informationen zum Verbindungsstatus der VPN Verbindung und dient als permanente Notification dazu, den VPN Dienst im Hintergrund am Laufen zu halten.</string>
@@ -0,0 +1 @@
@@ -21,8 +21,8 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<!-- Application -->
<string name="app_name">strongSwan klient VPN</string>
<string name="main_activity_name">strongSwan</string>
<string name="app_name">SHX VPN</string>
<string name="main_activity_name">SHX VPN</string>
<string name="show_log">Pokaż log</string>
<string name="search">Szukaj</string>
<string name="vpn_not_supported_title">Nie obsługiwany VPN</string>
@@ -31,7 +31,7 @@
<string name="vpn_not_supported_no_permission">Unable to get permission to create VPN connections. Either because it was denied by the user, or because a different VPN app has the always-on feature enabled.</string>
<string name="loading">Wczytywanie&#8230;</string>
<string name="profile_not_found">Nie znaleziono profilu</string>
<string name="strongswan_shortcut">Skrót strongSwan</string>
<string name="strongswan_shortcut">Skrót SHX VPN</string>
<string name="permanent_notification_name">VPN connection state</string>
<string name="permanent_notification_description">Provides information about the VPN connection state and serves as permanent notification to keep the VPN service running in the background.</string>
@@ -24,8 +24,8 @@
<!-- the order here must match the enum entries in VpnProfile.java -->
<string-array name="apps_handling">
<item>All applications use the VPN</item>
<item>Exclude selected applications from the VPN</item>
<item>Only selected applications use the VPN</item>
<item>Все приложения используют VPN</item>
<item>Исключить выбранные приложения из VPN</item>
<item>Только выбранные приложения используют VPN</item>
</string-array>
</resources>
@@ -15,8 +15,8 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<!-- Application -->
<string name="app_name">Клиент strongSwan VPN</string>
<string name="main_activity_name">strongSwan</string>
<string name="app_name">SHX VPN</string>
<string name="main_activity_name">SHX VPN</string>
<string name="show_log">Журнал</string>
<string name="search">Поиск</string>
<string name="vpn_not_supported_title">VPN не поддерживается</string>
@@ -25,16 +25,16 @@
<string name="vpn_not_supported_no_permission">Unable to get permission to create VPN connections. Either because it was denied by the user, or because a different VPN app has the always-on feature enabled.</string>
<string name="loading">Загрузка&#8230;</string>
<string name="profile_not_found">Профиль не найден</string>
<string name="strongswan_shortcut">Ссылка на strongSwan</string>
<string name="strongswan_shortcut">Ярлык SHX VPN</string>
<string name="permanent_notification_name">VPN connection state</string>
<string name="permanent_notification_description">Provides information about the VPN connection state and serves as permanent notification to keep the VPN service running in the background.</string>
<!-- Settings -->
<string name="pref_title">Settings</string>
<string name="pref_default_vpn_profile">Default VPN profile</string>
<string name="pref_default_vpn_profile_mru">Connect to most recently used profile</string>
<string name="pref_power_whitelist_title">Ignore battery optimizations</string>
<string name="pref_power_whitelist_summary">Don\'t show a warning if the app is not on the device\'s power whitelist</string>
<string name="pref_title">Настройки</string>
<string name="pref_default_vpn_profile">Профиль по умолчанию</string>
<string name="pref_default_vpn_profile_mru">Последний использованный профиль</string>
<string name="pref_power_whitelist_title">Игнорировать оптимизацию батареи</string>
<string name="pref_power_whitelist_summary">Не предупреждать, если приложение не в белом списке энергосбережения</string>
<!-- Log view -->
<string name="log_title">Журнал</string>
@@ -57,36 +57,36 @@
<!-- VPN profile details -->
<string name="profile_edit_save">Сохранить</string>
<string name="profile_edit_import">Import</string>
<string name="profile_edit_import">Импорт</string>
<string name="profile_edit_cancel">Отмена</string>
<string name="profile_name_label">Название профиля (необязательный)</string>
<string name="profile_name_label_simple">Название профиля</string>
<string name="profile_name_hint">Defaults to the configured server</string>
<string name="profile_name_hint_gateway">Defaults to \"%1$s\"</string>
<string name="profile_name_hint">По умолчанию — адрес сервера</string>
<string name="profile_name_hint_gateway">По умолчанию «%1$s»</string>
<string name="profile_gateway_label">Сервер</string>
<string name="profile_gateway_hint">IP address or hostname of the VPN server</string>
<string name="profile_gateway_hint">IP-адрес или имя хоста VPN-сервера</string>
<string name="profile_vpn_type_label">VPN Тип</string>
<string name="profile_username_label">Логин</string>
<string name="profile_password_label">Пароль (необязательный)</string>
<string name="profile_password_hint">Leave blank to get prompted on demand</string>
<string name="profile_password_hint">Оставьте пустым, чтобы спросить при подключении</string>
<string name="profile_user_certificate_label">Сертификат пользователя</string>
<string name="profile_user_select_certificate_label">Выбрать сертификат пользователя</string>
<string name="profile_user_select_certificate">Выбрать сертификат пользователя</string>
<string name="profile_user_certificate_install">Install user certificate</string>
<string name="profile_user_certificate_install">Установить сертификат пользователя</string>
<string name="profile_ca_label">Сертификат CA</string>
<string name="profile_ca_auto_label">Выбрать автоматически</string>
<string name="profile_ca_select_certificate_label">Выбрать сертификат CA</string>
<string name="profile_ca_select_certificate">Выбрать CA сертификат</string>
<string name="profile_advanced_label">Advanced settings</string>
<string name="profile_show_advanced_label">Show advanced settings</string>
<string name="profile_remote_id_label">Server identity</string>
<string name="profile_remote_id_hint">Defaults to the configured server. Custom values are explicitly sent to the server and enforced during authentication</string>
<string name="profile_remote_id_hint_gateway">Defaults to \"%1$s\". Custom values are explicitly sent to the server and enforced during authentication</string>
<string name="profile_local_id_label">Client identity</string>
<string name="profile_local_id_hint_user">Defaults to the configured username. Custom values may be used if expected/required by the server</string>
<string name="profile_local_id_hint_cert">Defaults to the certificate\'s subject identity. Custom values may be used if expected/required by the server. Note that these usually must be confirmed by the certificate (auto-completion is provided for the certificate\'s alternative identities, if any)</string>
<string name="profile_dns_servers_label">DNS servers</string>
<string name="profile_dns_servers_hint">Custom DNS servers to use when connected to the VPN (separated by spaces, e.g. \"8.8.8.8 2001:4860:4860::8888\"), defaults to those received from the VPN server</string>
<string name="profile_advanced_label">Дополнительно</string>
<string name="profile_show_advanced_label">Показать дополнительные настройки</string>
<string name="profile_remote_id_label">Идентификатор сервера</string>
<string name="profile_remote_id_hint">По умолчанию — настроенный сервер. Своё значение явно отправляется и проверяется при аутентификации</string>
<string name="profile_remote_id_hint_gateway">По умолчанию «%1$s». Своё значение явно отправляется и проверяется при аутентификации</string>
<string name="profile_local_id_label">Идентификатор клиента</string>
<string name="profile_local_id_hint_user">По умолчанию — логин. Своё значение, если его ждёт сервер</string>
<string name="profile_local_id_hint_cert">По умолчанию — subject сертификата. Своё значение должно подтверждаться сертификатом</string>
<string name="profile_dns_servers_label">DNS-серверы</string>
<string name="profile_dns_servers_hint">Свои DNS при подключении (через пробел, например 8.8.8.8). Иначе — с VPN-сервера</string>
<string name="profile_mtu_label">MTU of the VPN tunnel device</string>
<string name="profile_mtu_hint">In case the default value is unsuitable for a particular network</string>
<string name="profile_port_label">Server port</string>
@@ -105,10 +105,11 @@
<string name="profile_rsa_pss_hint">Use the stronger PSS encoding instead of the classic PKCS#1 encoding for RSA signatures. Authentication will fail if the server does not support such signatures.</string>
<string name="profile_ipv6_transport_label">Use IPv6 transport addresses</string>
<string name="profile_ipv6_transport_hint">Use IPv6 for outer transport addresses if available. Can only be enabled if UDP encapsulation for IPv6 is supported by the server. Note that the Linux kernel only supports this since version 5.8, so many servers will not support it yet.</string>
<string name="profile_split_tunneling_label">Split tunneling</string>
<string name="profile_split_tunneling_intro">By default, the client will route all network traffic through the VPN, unless the server narrows the subnets when the connection is established, in which case only traffic the server allows will be routed via VPN (by default, all other traffic is routed as if there was no VPN).</string>
<string name="profile_split_tunnelingv4_title">Block IPv4 traffic not destined for the VPN</string>
<string name="profile_split_tunnelingv6_title">Block IPv6 traffic not destined for the VPN</string>
<string name="profile_split_tunneling_label">Раздельное туннелирование</string>
<string name="profile_split_tunneling_intro">По умолчанию клиент направляет весь трафик через VPN, если сервер при подключении не сузит подсети — тогда через туннель идёт только разрешённый сервером трафик, остальное идёт как без VPN.</string>
<string name="profile_split_tunnelingv4_title">Блокировать IPv4 вне VPN</string>
<string name="profile_split_tunnelingv6_title">Блокировать IPv6 вне VPN</string>
<string name="profile_split_tunnelingv6_hint">Если у сервера нет IPv6, это не даст IPv6 уйти мимо туннеля в dual-stack сети</string>
<string name="profile_included_subnets_label">Custom subnets</string>
<string name="profile_included_subnets_hint">Only route traffic to specific subnets via VPN, everything else is routed as if there was no VPN (separated by spaces, e.g. \"192.168.1.0/24 2001:db8::/64\")</string>
<string name="profile_excluded_subnets_label">Excluded subnets</string>
@@ -182,6 +183,8 @@
<!-- VPN state fragment -->
<string name="state_label">Статус:</string>
<string name="vpn_connection_title">Соединение</string>
<string name="vpn_profiles_title">Профили</string>
<string name="profile_label">Профиль:</string>
<string name="disconnect">Отключить</string>
<string name="state_connecting">Соединение&#8230;</string>
@@ -238,4 +241,53 @@
<string name="tile_connect">Connect VPN</string>
<string name="tile_disconnect">Disconnect VPN</string>
<string name="nav_connect">Подключение</string>
<string name="nav_profiles">Профили</string>
<string name="nav_settings">Настройки</string>
<string name="status_unprotected">Не защищено</string>
<string name="status_protected">Защищено</string>
<string name="status_connecting">Подключение&#8230;</string>
<string name="status_choose_profile">Выберите профиль</string>
<string name="badge_connected">Подключено</string>
<string name="connection_failed">Не удалось подключиться</string>
<string name="profiles_title">Профили</string>
<string name="search_profiles">Поиск профилей</string>
<string name="nothing_found">Ничего не найдено</string>
<string name="settings_section_general">Основные</string>
<string name="settings_section_automation">Автоматизация</string>
<string name="settings_section_certs">Сертификаты</string>
<string name="settings_section_profiles">Профили</string>
<string name="settings_section_support">Поддержка</string>
<string name="settings_section_security">Безопасность</string>
<string name="auto_connect_title">Доверенные сети</string>
<string name="auto_connect_summary">Подключаться в мобильной сети и незнакомом Wi-Fi</string>
<string name="auto_connect_profile">Профиль автоподключения</string>
<string name="auto_connect_profile_none">Не выбран</string>
<string name="auto_connect_trusted_title">Доверенные Wi-Fi сети</string>
<string name="auto_connect_trusted_hint">VPN автоматически останавливается в этих сетях. При переходе на мобильную сеть или другой Wi-Fi соединение поднимается снова.</string>
<string name="auto_connect_no_networks">Доверенных сетей пока нет</string>
<string name="auto_connect_add_network">Добавить сеть</string>
<string name="auto_connect_add_current">Добавить текущую (%1$s)</string>
<string name="auto_connect_ssid_label">Имя сети (SSID)</string>
<string name="auto_connect_remove_network">Удалить доверенную сеть</string>
<string name="auto_connect_location_banner">Чтобы VPN отключался в доверенном Wi-Fi в фоне, разрешите доступ к геолокации «всегда».</string>
<string name="auto_connect_location_grant">Разрешить</string>
<string name="kill_switch_title">Kill switch</string>
<string name="kill_switch_summary_on">Always-on VPN с блокировкой трафика активен</string>
<string name="kill_switch_summary_off">Не включён — трафик может уходить вне туннеля</string>
<string name="kill_switch_open_settings">Открыть настройки VPN системы</string>
<string name="profile_section_basic">Основное</string>
<string name="app_version_fmt">SHX VPN %1$s</string>
<string name="recents_title">Недавние</string>
<string name="add_profile_cta">Добавить профиль</string>
<string name="connection_details">Сведения о подключении</string>
<string name="traffic_down">Входящий</string>
<string name="traffic_up">Исходящий</string>
<string name="traffic_idle"></string>
<string name="duration_label">Длительность</string>
<string name="traffic_stat_down">Загружено %1$s, %2$s</string>
<string name="traffic_stat_up">Отправлено %1$s, %2$s</string>
<string name="profile_advanced_hint">Идентификаторы, DNS, MTU, split-туннель</string>
<string name="import_profile_title">Импорт профиля</string>
</resources>
@@ -1,242 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright (C) 2013 Pavel Kopchyk
Copyright (C) 2012 Dmitry Korzhevin
This program is free software; you can redistribute it and/or modify it
under the terms of the 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.
-->
<resources xmlns:tools="http://schemas.android.com/tools">
<!-- Application -->
<string name="app_name">strongSwan VPN клієнт</string>
<string name="main_activity_name">strongSwan</string>
<string name="show_log">Перегляд журналу</string>
<string name="search">Пошук</string>
<string name="vpn_not_supported_title">VPN не підтримуеться</string>
<string name="vpn_not_supported">Ваш пристрій не підтримує VPN.\nЗв\'яжіться з виробником.</string>
<string name="vpn_not_supported_during_lockdown">VPN connections are not supported if a built-in VPN has the always-on feature enabled.</string>
<string name="vpn_not_supported_no_permission">Unable to get permission to create VPN connections. Either because it was denied by the user, or because a different VPN app has the always-on feature enabled.</string>
<string name="loading">Завантаження&#8230;</string>
<string name="profile_not_found">Профіль не знайдено</string>
<string name="strongswan_shortcut">strongSwan посилання</string>
<string name="permanent_notification_name">VPN connection state</string>
<string name="permanent_notification_description">Provides information about the VPN connection state and serves as permanent notification to keep the VPN service running in the background.</string>
<!-- Settings -->
<string name="pref_title">Settings</string>
<string name="pref_default_vpn_profile">Default VPN profile</string>
<string name="pref_default_vpn_profile_mru">Connect to most recently used profile</string>
<string name="pref_power_whitelist_title">Ignore battery optimizations</string>
<string name="pref_power_whitelist_summary">Don\'t show a warning if the app is not on the device\'s power whitelist</string>
<!-- Log view -->
<string name="log_title">Журнал</string>
<string name="send_log">Відправити файл журналу</string>
<string name="empty_log">Журнал порожній</string>
<string name="log_mail_subject">strongSwan %1$s файл журналу</string>
<!-- VPN profile list -->
<string name="no_profiles">Немає VPN профілів</string>
<string name="add_profile">Додати VPN профіль</string>
<string name="edit_profile">Редагувати</string>
<string name="copy_profile">Copy</string>
<string name="copied_name">%1$s (Copy)</string>
<string name="delete_profile">Видалити</string>
<string name="select_profiles">Обрати профіль</string>
<string name="profiles_deleted">Обрані профілі видалено</string>
<string name="no_profile_selected">Профіль не обрано</string>
<string name="one_profile_selected">Один профіль обрано</string>
<string name="x_profiles_selected">%1$d профілів обрано</string>
<!-- VPN profile details -->
<string name="profile_edit_save">Зберегти</string>
<string name="profile_edit_import">Import</string>
<string name="profile_edit_cancel">Відміна</string>
<string name="profile_name_label">Назва профілю (необов\'язковий)</string>
<string name="profile_name_label_simple">Назва профілю</string>
<string name="profile_name_hint">Defaults to the configured server</string>
<string name="profile_name_hint_gateway">Defaults to \"%1$s\"</string>
<string name="profile_gateway_label">Сервер</string>
<string name="profile_gateway_hint">IP address or hostname of the VPN server</string>
<string name="profile_vpn_type_label">VPN Тип</string>
<string name="profile_username_label">Логін</string>
<string name="profile_password_label">Пароль (необов\'язковий)</string>
<string name="profile_password_hint">Leave blank to get prompted on demand</string>
<string name="profile_user_certificate_label">Сертифікат користувача</string>
<string name="profile_user_select_certificate_label">Виберіть сертифікат користувача</string>
<string name="profile_user_select_certificate">Вибрати спеціальний сертифікат користувача</string>
<string name="profile_user_certificate_install">Install user certificate</string>
<string name="profile_ca_label">Сертифікат CA</string>
<string name="profile_ca_auto_label">Вибрати автоматично</string>
<string name="profile_ca_select_certificate_label">Вибрати сертифікат CA</string>
<string name="profile_ca_select_certificate">Вибрати спеціальний сертифікат CA</string>
<string name="profile_advanced_label">Advanced settings</string>
<string name="profile_show_advanced_label">Show advanced settings</string>
<string name="profile_remote_id_label">Server identity</string>
<string name="profile_remote_id_hint">Defaults to the configured server. Custom values are explicitly sent to the server and enforced during authentication</string>
<string name="profile_remote_id_hint_gateway">Defaults to \"%1$s\". Custom values are explicitly sent to the server and enforced during authentication</string>
<string name="profile_local_id_label">Client identity</string>
<string name="profile_local_id_hint_user">Defaults to the configured username. Custom values may be used if expected/required by the server</string>
<string name="profile_local_id_hint_cert">Defaults to the certificate\'s subject identity. Custom values may be used if expected/required by the server. Note that these usually must be confirmed by the certificate (auto-completion is provided for the certificate\'s alternative identities, if any)</string>
<string name="profile_dns_servers_label">DNS servers</string>
<string name="profile_dns_servers_hint">Custom DNS servers to use when connected to the VPN (separated by spaces, e.g. \"8.8.8.8 2001:4860:4860::8888\"), defaults to those received from the VPN server</string>
<string name="profile_mtu_label">MTU of the VPN tunnel device</string>
<string name="profile_mtu_hint">In case the default value is unsuitable for a particular network</string>
<string name="profile_port_label">Server port</string>
<string name="profile_port_hint">UDP port to connect to, if different from the default</string>
<string name="profile_nat_keepalive_label">NAT-T keepalive interval</string>
<string name="profile_nat_keepalive_hint">Small packets are sent to keep mappings on NAT routers alive if there is no other traffic. In order to save energy the default interval is 45 seconds. Behind NAT routers that remove mappings early this might be too high, try 20 seconds or less in that case.</string>
<string name="profile_cert_req_label">Send certificate requests</string>
<string name="profile_cert_req_hint">Certificate requests are sent for all available or selected CA certificates. To reduce the size of the IKE_AUTH message this can be disabled. However, this only works if the server sends its certificate even if it didn\'t receive any certificate requests.</string>
<string name="profile_use_ocsp_label">Use OCSP to check certificate</string>
<string name="profile_use_ocsp_hint">Use the Online Certificate Status Protocol (OCSP), if available, to check that the server certificate has not been revoked.</string>
<string name="profile_use_crl_label">Use CRLs to check certificate</string>
<string name="profile_use_crl_hint">Use Certificate Revocation Lists (CRL), if available, to check that the server certificate has not been revoked. CRLs are only used if OCSP doesn\'t yield a result.</string>
<string name="profile_strict_revocation_label">Use strict revocation checking</string>
<string name="profile_strict_revocation_hint">In strict mode the authentication will fail not only if the server certificate has been revoked but also if its status is unknown (e.g. because OCSP failed and no valid CRL was available).</string>
<string name="profile_rsa_pss_label">Use RSA/PSS signatures</string>
<string name="profile_rsa_pss_hint">Use the stronger PSS encoding instead of the classic PKCS#1 encoding for RSA signatures. Authentication will fail if the server does not support such signatures.</string>
<string name="profile_ipv6_transport_label">Use IPv6 transport addresses</string>
<string name="profile_ipv6_transport_hint">Use IPv6 for outer transport addresses if available. Can only be enabled if UDP encapsulation for IPv6 is supported by the server. Note that the Linux kernel only supports this since version 5.8, so many servers will not support it yet.</string>
<string name="profile_split_tunneling_label">Split tunneling</string>
<string name="profile_split_tunneling_intro">By default, the client will route all network traffic through the VPN, unless the server narrows the subnets when the connection is established, in which case only traffic the server allows will be routed via VPN (by default, all other traffic is routed as if there was no VPN).</string>
<string name="profile_split_tunnelingv4_title">Block IPv4 traffic not destined for the VPN</string>
<string name="profile_split_tunnelingv6_title">Block IPv6 traffic not destined for the VPN</string>
<string name="profile_included_subnets_label">Custom subnets</string>
<string name="profile_included_subnets_hint">Only route traffic to specific subnets via VPN, everything else is routed as if there was no VPN (separated by spaces, e.g. \"192.168.1.0/24 2001:db8::/64\")</string>
<string name="profile_excluded_subnets_label">Excluded subnets</string>
<string name="profile_excluded_subnets_hint">Traffic to these subnets will not be routed via VPN, but as if there was no VPN (separated by spaces, e.g. \"192.168.1.0/24 2001:db8::/64\")</string>
<string name="profile_select_apps_label">Applications</string>
<string name="profile_select_apps">Select applications</string>
<string name="profile_select_no_apps">No applications selected</string>
<string name="profile_select_one_app">One application selected</string>
<string name="profile_select_x_apps">%1$d applications selected</string>
<string name="profile_proposals_label">Algorithms</string>
<string name="profile_proposals_intro">Optionally configure specific algorithms to use for IKEv2 and/or IPsec/ESP instead of the defaults. Refer to our wiki for a <a href="https://docs.strongswan.org/docs/latest/config/IKEv2CipherSuites.html">list of algorithm identifiers</a> (note that not all are supported by this app). Both fields take a list of algorithms, each separated by a hyphen.</string>
<string name="profile_proposals_ike_label">IKEv2 Algorithms</string>
<string name="profile_proposals_ike_hint">For non-AEAD/classic encryption algorithms, an integrity algorithm, a pseudo random function (optional, defaults to one based on the integrity algorithm) and a Diffie-Hellman group are required (e.g. aes256-sha256-ecp256). For combined-mode/AEAD algorithms, the integrity algorithm is omitted but a PRF is required (e.g. aes256gcm16-prfsha256-ecp256).</string>
<string name="profile_proposals_esp_label">IPsec/ESP Algorithms</string>
<string name="profile_proposals_esp_hint">For non-AEAD/classic encryption algorithms, an integrity algorithm is required, a Diffie-Hellman group is optional (e.g. aes256-sha256 or aes256-sha256-ecp256). For combined-mode/AEAD algorithms, the integrity algorithm is omitted (e.g. aes256gcm16 or aes256gcm16-ecp256). If a DH group is specified IPsec SA rekeying will use a DH key exchange. However, DH groups specified here are not used when the connection is established initially because the keys there are derived from the IKE SA key material. Therefore, any configuration mismatch with the server will only cause errors later during rekeying.</string>
<string name="profile_proxy_server_label">HTTP proxy server</string>
<string name="profile_proxy_server_intro">Optional HTTP proxy server to use when connected to the VPN. This is only a recommendation and may be ignored by apps. Note that apps using the proxy will access all HTTP resources through it regardless of the destination, so split-tunneling settings might not have any effect. To avoid using the proxy server for specific hosts, use the exclusion list below.</string>
<string name="profile_proxy_host_label">Proxy host</string>
<string name="profile_proxy_host_hint">IP address or hostname of the HTTP proxy server to use when connected to the VPN</string>
<string name="profile_proxy_port_label">Proxy port</string>
<string name="profile_proxy_port_hint">Port to access the HTTP proxy server, defaults to 8080</string>
<string name="profile_proxy_exclusions_label">Proxy exclusion list</string>
<string name="profile_proxy_exclusions_hint">Optional list of hosts for which the HTTP proxy server is not used (separated by spaces, and wildcards are possible, e.g. \"direct.example.net *.example.com\")</string>
<string name="profile_import">Import VPN profile</string>
<string name="profile_import_failed">Failed to import VPN profile</string>
<string name="profile_import_failed_detail">Failed to import VPN profile: %1$s</string>
<string name="profile_import_failed_not_found">File not found</string>
<string name="profile_import_failed_host">Host unknown</string>
<string name="profile_import_failed_tls">TLS handshake failed</string>
<string name="profile_import_failed_value">Invalid value in \"%1$s\"</string>
<string name="profile_import_exists">This VPN profile already exists, its current settings will be replaced.</string>
<string name="profile_import_shared_secret">This file contains a cleartext password. Remember to delete it after importing.</string>
<string name="profile_cert_import">Import certificate from VPN profile</string>
<string name="profile_cert_alias">Certificate for \"%1$s\"</string>
<string name="profile_profile_id">Profile ID</string>
<string name="profile_managed">Managed profile</string>
<!-- Warnings/Notifications in the details view -->
<string name="alert_text_no_input_gateway">A value is required to initiate the connection</string>
<string name="alert_text_no_input_username">Введіть ім\'я користувача </string>
<string name="alert_text_nocertfound_title">Не вибрано сертифікат CA</string>
<string name="alert_text_nocertfound">Будь ласка виберіть один <i>Вибрати автоматично</i></string>
<string name="alert_text_out_of_range">Please enter a number in the range from %1$d - %2$d</string>
<string name="alert_text_no_subnets">Please enter valid subnets and/or IP addresses, separated by spaces</string>
<string name="alert_text_no_ips">Please enter valid IP addresses, separated by spaces</string>
<string name="alert_text_no_proposal">Please enter a valid list of algorithms, separated by hyphens</string>
<string name="alert_text_vpn_profile_read_only">This VPN profile is managed by your administrator and can\'t be modified. You can only change the password or user certificate</string>
<string name="tnc_notice_title">EAP-TNC may affect your privacy</string>
<string name="tnc_notice_subtitle">Device data is sent to the server operator</string>
<string name="tnc_notice_details"><![CDATA[<p>Trusted Network Connect (TNC) allows server operators to assess the health of a client device.</p><p>For that purpose the server operator may request data such as a unique identifier, a list of installed packages, system settings, or cryptographic checksums of files.</p><b>Any data will be sent only after verifying the server\'s identity.</b>]]></string>
<!-- Trusted certificate selection -->
<string name="trusted_certs_title">Сертифікати CA</string>
<string name="no_certificates">Немає сертифікатів</string>
<string name="reload_trusted_certs">Перезавантажити CA сертифікати</string>
<string name="system_tab">Система</string>
<string name="user_tab">Користувач</string>
<string name="local_tab">Imported</string>
<string name="delete_certificate_question">Delete certificate?</string>
<string name="delete_certificate">The certificate will be permanently removed!</string>
<string name="import_certificate">Import certificate</string>
<string name="cert_imported_successfully">Certificate successfully imported</string>
<string name="cert_import_failed">Failed to import certificate</string>
<string name="crl_cache">CRL cache</string>
<string name="clear_crl_cache_title">Clear CRL cache?</string>
<string name="clear_crl_cache_msg_none">The CRL cache is empty</string>
<plurals name="clear_crl_cache_msg" tools:ignore="MissingQuantity">
<item quantity="one">The CRL cache contains %1$d file (%2$s).</item>
<item quantity="other">The CRL cache contains %1$d files (%2$s).</item>
</plurals>
<string name="clear">Clear</string>
<!-- VPN state fragment -->
<string name="state_label">Статус:</string>
<string name="profile_label">Профіль:</string>
<string name="disconnect">Роз\'єднати</string>
<string name="state_connecting">Підключення&#8230;</string>
<string name="state_connected">Підключений</string>
<string name="state_disconnecting">Роз\'єднання&#8230;</string>
<string name="state_disabled">Немає активних VPN</string>
<string name="state_error">Помилка</string>
<string name="dismiss">Dismiss</string>
<!-- IMC state fragment -->
<string name="imc_state_label">Assessment:</string>
<string name="imc_state_isolate">Restricted</string>
<string name="imc_state_block">Failed</string>
<string name="show_remediation_instructions">View remediation instructions</string>
<!-- Remediation instructions -->
<string name="remediation_instructions_title">Remediation instructions</string>
<!-- Dialogs -->
<string name="login_title">Введіть пароль для з\'єднання</string>
<string name="login_username">Логін</string>
<string name="login_password">Пароль</string>
<string name="login_confirm">Підключити</string>
<string name="error_format">Помилка підлючення VPN: %1$s.</string>
<string name="error_lookup_failed">Помилка пошуку адреси сервер</string>
<string name="error_unreachable">Сервер зв\'язку зі шлюзом</string>
<string name="error_peer_auth_failed">Помилка перевірки данних аутентифікації сервер</string>
<string name="error_auth_failed">Помилка аутентифікації користувача</string>
<string name="error_assessment_failed">Security assessment failed</string>
<string name="error_generic">Невідома помилка під час підключення</string>
<string name="error_password_missing">Password unavailable</string>
<string name="error_certificate_unavailable">Client certificate unavailable</string>
<string name="vpn_connected">VPN підключено</string>
<string name="vpn_profile_connected">Цей VPN профіль зараз підключений!</string>
<string name="reconnect">Перепідключитися</string>
<string name="connect_profile_question">Підключити %1$s?</string>
<string name="replaces_active_connection">Ця дія замінить ваше поточне VPN з\'єднання!</string>
<string name="disconnect_question">Disconnect VPN?</string>
<string name="disconnect_active_connection">This will disconnect the active VPN connection!</string>
<string name="connect">Підключити</string>
<string name="retry">Retry</string>
<plurals name="retry_in" tools:ignore="MissingQuantity">
<item quantity="one">Retry in %1$d second</item>
<item quantity="other">Retry in %1$d seconds</item>
</plurals>
<string name="cancel_retry">Cancel retry</string>
<string name="power_whitelist_title">Disable battery optimizations</string>
<string name="power_whitelist_text">Please confirm the next dialog to add the app to the device\'s power whitelist so it can ignore battery optimizations and schedule NAT keep-alives and rekeyings accurately in order to constantly keep reachable while the VPN is established.</string>
<string name="certificate_required_title">User certificate required</string>
<string name="certificate_required_text">Please edit the VPN profile to select one.</string>
<!-- Quick Settings tile -->
<string name="tile_default">Toggle VPN</string>
<string name="tile_connect">Connect VPN</string>
<string name="tile_disconnect">Disconnect VPN</string>
</resources>
@@ -1,241 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright (C) 2016-2017 Yick Xie
This program is free software; you can redistribute it and/or modify it
under the terms of the 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.
-->
<resources>
<!-- Application -->
<string name="app_name">strongSwan VPN 客户端</string>
<string name="main_activity_name">strongSwan</string>
<string name="show_log">浏览日志</string>
<string name="search">搜索</string>
<string name="vpn_not_supported_title">无法支持VPN</string>
<string name="vpn_not_supported">您的设备无法支持VPN应用。\n请联系供应商。</string>
<string name="vpn_not_supported_during_lockdown">如果内置VPN启用了“始终开启”功能,则不支持VPN连接。</string>
<string name="vpn_not_supported_no_permission">无法获得创建VPN连接的权限。可能是因为用户拒绝了,或者是因为其他VPN应用程序启用了“始终打开”功能。</string>
<string name="loading">载入中&#8230;</string>
<string name="profile_not_found">未找到配置</string>
<string name="strongswan_shortcut">strongSwan快捷方式</string>
<string name="permanent_notification_name">VPN连接状态</string>
<string name="permanent_notification_description">提供有关VPN连接状态的信息,并作为永久通知,使VPN服务在后台运行。</string>
<!-- Settings -->
<string name="pref_title">设置</string>
<string name="pref_default_vpn_profile">默认VPN配置文件</string>
<string name="pref_default_vpn_profile_mru">连接到最近使用的配置文件</string>
<string name="pref_power_whitelist_title">忽略电池优化</string>
<string name="pref_power_whitelist_summary">如果应用不在设备的电源白名单上,则不显示警告</string>
<!-- Log view -->
<string name="log_title">日志</string>
<string name="send_log">发送日志文件</string>
<string name="empty_log">日志文件为空</string>
<string name="log_mail_subject">strongSwan %1$s 日志文件</string>
<!-- VPN profile list -->
<string name="no_profiles">无配置.</string>
<string name="add_profile">添加VPN配置</string>
<string name="edit_profile">编辑</string>
<string name="copy_profile">拷贝</string>
<string name="copied_name">%1$s (拷贝)</string>
<string name="delete_profile">删除</string>
<string name="select_profiles">选择配置</string>
<string name="profiles_deleted">所选配置已删除</string>
<string name="no_profile_selected">未选择配置</string>
<string name="one_profile_selected">已选择1项配置</string>
<string name="x_profiles_selected">已选择%1$d项配置</string>
<!-- VPN profile details -->
<string name="profile_edit_save">保存</string>
<string name="profile_edit_import">导入</string>
<string name="profile_edit_cancel">取消</string>
<string name="profile_name_label">配置名称 (可选)</string>
<string name="profile_name_label_simple">配置名称</string>
<string name="profile_name_hint">默认为已配置服务器地址</string>
<string name="profile_name_hint_gateway">默认为 \"%1$s\"</string>
<string name="profile_gateway_label">服务器地址</string>
<string name="profile_gateway_hint">IP地址或服务器域名</string>
<string name="profile_vpn_type_label">VPN类型</string>
<string name="profile_username_label">用户名</string>
<string name="profile_password_label">密码 (可选)</string>
<string name="profile_password_hint">留空则在要求时弹出</string>
<string name="profile_user_certificate_label">用户证书</string>
<string name="profile_user_select_certificate_label">选择用户证书</string>
<string name="profile_user_select_certificate">选择指定的用户证书</string>
<string name="profile_user_certificate_install">安装用户证书</string>
<string name="profile_ca_label">CA证书</string>
<string name="profile_ca_auto_label">自动选择</string>
<string name="profile_ca_select_certificate_label">选择CA证书</string>
<string name="profile_ca_select_certificate">选择一个指定的CA证书</string>
<string name="profile_advanced_label">高级设置</string>
<string name="profile_show_advanced_label">显示高级设置</string>
<string name="profile_remote_id_label">服务器ID</string>
<string name="profile_remote_id_hint">默认为已配置的服务器地址。自义定值将在鉴权期间被显式地发送至服务器</string>
<string name="profile_remote_id_hint_gateway">默认为 \"%1$s\"。自义定值将在鉴权期间被显式地发送至服务器</string>
<string name="profile_local_id_label">客户身份</string>
<string name="profile_local_id_hint_user">默认为配置的用户名。如果服务器期望/需要,可以使用自定义值</string>
<string name="profile_local_id_hint_cert">默认为证书的使用者标识。如果服务器期望/需要,可以使用自定义值。请注意,这些通常必须由证书确认(证书的替代身份(如果有)提供自动完成)</string>
<string name="profile_dns_servers_label">DNS服务器</string>
<string name="profile_dns_servers_hint">连接到VPN时要使用的自定义DNS服务器(用空格分隔,例如“8.8.8.8 2001:4860:4860:888”),默认为从VPN服务器接收的DNS服务器</string>
<string name="profile_mtu_label">VPN隧道设备的MTU值</string>
<string name="profile_mtu_hint">假如在某一网络下默认值不合适</string>
<string name="profile_port_label">服务器端口</string>
<string name="profile_port_hint">如不同于默认值,则所需连接的UDP端口</string>
<string name="profile_nat_keepalive_label">NAT-T保持间隔</string>
<string name="profile_nat_keepalive_hint">如果没有其他流量,则发送小数据包以保持NAT路由器上的映射处于活动状态。为了节省能源,默认间隔为45秒。在早期删除映射的NAT路由器后面,这可能太高,在这种情况下,尝试20秒或更短时间。</string>
<string name="profile_cert_req_label">发送证书请求</string>
<string name="profile_cert_req_hint">为所有可用或选定的CA证书发送证书请求。要减小IKE_AUTH消息的大小,可以禁用此选项。但是,这仅在服务器发送其证书时有效,即使它没有收到任何证书请求。</string>
<string name="profile_use_ocsp_label">使用OCSP检查证书</string>
<string name="profile_use_ocsp_hint">如果可用,请使用联机证书状态协议(OCSP)检查服务器证书是否未被吊销。</string>
<string name="profile_use_crl_label">使用CRLs检查证书</string>
<string name="profile_use_crl_hint">如果可用,请使用证书吊销列表(CRL)检查服务器证书是否已被吊销。CRL仅在OCSP不产生结果时使用。</string>
<string name="profile_strict_revocation_label">使用严格的撤销检查</string>
<string name="profile_strict_revocation_hint">在严格模式下,身份验证不仅在服务器证书已被吊销的情况下会失败,而且在其状态未知的情况下也会失败(例如,因为OCSP失败且没有有效的CRL可用)。</string>
<string name="profile_rsa_pss_label">使用RSA/PSS签名</string>
<string name="profile_rsa_pss_hint">对RSA签名使用更强的PSS编码,而不是经典的PKCS#1编码。如果服务器不支持此类签名,身份验证将失败。</string>
<string name="profile_ipv6_transport_label">使用IPv6传输地址</string>
<string name="profile_ipv6_transport_hint">使用IPv6作为外部传输地址(如果可用)。仅当服务器支持IPv6的UDP封装时才能启用。请注意,Linux内核仅从5.8版开始支持此功能,因此许多服务器还不支持它。</string>
<string name="profile_split_tunneling_label">拆分隧道</string>
<string name="profile_split_tunneling_intro">默认情况下,客户端将通过VPN路由所有网络流量,除非在建立连接时服务器缩小子网,在这种情况下,只有服务器允许的流量将通过VPN路由(默认情况下,所有其他流量的路由如同没有VPN一样)。</string>
<string name="profile_split_tunnelingv4_title">屏蔽不通过VPN的IPV4流量</string>
<string name="profile_split_tunnelingv6_title">屏蔽不通过VPN的IPV6流量</string>
<string name="profile_included_subnets_label">自定义子网</string>
<string name="profile_included_subnets_hint">仅通过VPN将流量路由到特定子网,其他所有内容的路由如同没有VPN一样(用空格分隔,例如“192.168.1.0/24 2001:db8:/64”)</string>
<string name="profile_excluded_subnets_label">排除子网</string>
<string name="profile_excluded_subnets_hint">到这些子网的流量将不会通过VPN路由,但就好像没有VPN一样(用空格分隔,例如“192.168.1.0/24 2001:db8:/64”)</string>
<string name="profile_select_apps_label">应用</string>
<string name="profile_select_apps">选择应用程序</string>
<string name="profile_select_no_apps">未选择任何应用程序</string>
<string name="profile_select_one_app">已选择一个应用程序</string>
<string name="profile_select_x_apps">%1$d 应用程序被选择</string>
<string name="profile_proposals_label">算法</string>
<string name="profile_proposals_intro">(可选)配置用于IKEv2和/或IPsec/ESP的特定算法,而不是默认算法。请参阅我们的wiki以了解<a href="https://docs.strongswan.org/docs/latest/config/IKEv2CipherSuites.html">算法标识符列表</a>(请注意,此应用程序并不支持所有标识符)。这两个字段都包含一个算法列表,每个算法用连字符分隔。</string>
<string name="profile_proposals_ike_label">IKEv2算法</string>
<string name="profile_proposals_ike_hint">对于非AEAD/经典加密算法,需要完整性算法、伪随机函数(可选,默认为基于完整性算法的函数)和Diffie-Hellman组(例如aes256-sha256-ecp256)。对于组合模式/AEAD算法,省略完整性算法,但需要PRF(例如aes256gcm16-prfsha256-ecp256)。</string>
<string name="profile_proposals_esp_label">IPsec/ESP 算法</string>
<string name="profile_proposals_esp_hint">对于非AEAD/经典加密算法,需要完整性算法,Diffie-Hellman组是可选的(例如aes256-sha256或aes256-sha256-ecp256)。对于组合模式/AEAD算法,省略完整性算法(例如aes256gcm16或aes256gcm16-ecp256)。如果指定了DH组,IPsec SA密钥更新将使用DH密钥交换。但是,在最初建立连接时,不使用此处指定的DH组,因为其中的密钥来自IKE SA密钥材料。因此,与服务器的任何配置不匹配只会在稍后重新设置密钥时导致错误。</string>
<string name="profile_proxy_server_label">HTTP proxy server</string>
<string name="profile_proxy_server_intro">Optional HTTP proxy server to use when connected to the VPN. This is only a recommendation and may be ignored by apps. Note that apps using the proxy will access all HTTP resources through it regardless of the destination, so split-tunneling settings might not have any effect. To avoid using the proxy server for specific hosts, use the exclusion list below.</string>
<string name="profile_proxy_host_label">Proxy host</string>
<string name="profile_proxy_host_hint">IP address or hostname of the HTTP proxy server to use when connected to the VPN</string>
<string name="profile_proxy_port_label">Proxy port</string>
<string name="profile_proxy_port_hint">Port to access the HTTP proxy server, defaults to 8080</string>
<string name="profile_proxy_exclusions_label">Proxy exclusion list</string>
<string name="profile_proxy_exclusions_hint">Optional list of hosts for which the HTTP proxy server is not used (separated by spaces, and wildcards are possible, e.g. \"direct.example.net *.example.com\")</string>
<string name="profile_import">导入VPN配置</string>
<string name="profile_import_failed">导入VPN配置失败</string>
<string name="profile_import_failed_detail">导入VPN配置失败: %1$s</string>
<string name="profile_import_failed_not_found">文件未找到</string>
<string name="profile_import_failed_host">未知主机</string>
<string name="profile_import_failed_tls">TLS握手失败</string>
<string name="profile_import_failed_value">无效的值: \"%1$s\"</string>
<string name="profile_import_exists">此VPN配置已经存在,当前设定将被覆盖。</string>
<string name="profile_import_shared_secret">This file contains a cleartext password. Remember to delete it after importing.</string>
<string name="profile_cert_import">从VPN配置导入证书</string>
<string name="profile_cert_alias">\"%1$s\" 所对应的证书</string>
<string name="profile_profile_id">配置文件ID</string>
<string name="profile_managed">Managed profile</string>
<!-- Warnings/Notifications in the details view -->
<string name="alert_text_no_input_gateway">必填信息以初始化连接</string>
<string name="alert_text_no_input_username">请输入您的用户名</string>
<string name="alert_text_nocertfound_title">未选择CA证书</string>
<string name="alert_text_nocertfound">请选择一项或激活 <i>自动选择</i></string>
<string name="alert_text_out_of_range">请输入一个数字范围从%1$d到%2$d</string>
<string name="alert_text_no_subnets">请输入有效的子网和/或IP地址,用空格分隔</string>
<string name="alert_text_no_ips">请输入有效的IP地址,以空格分隔</string>
<string name="alert_text_no_proposal">请输入用连字符分隔的有效算法列表</string>
<string name="alert_text_vpn_profile_read_only">This VPN profile is managed by your administrator and can\'t be modified. You can only change the password or user certificate</string>
<string name="tnc_notice_title">EAP-TNC可能会影响您的隐私</string>
<string name="tnc_notice_subtitle">设备数据已被发送至服务器管理员</string>
<string name="tnc_notice_details"><![CDATA[<p>可信网络连接t (TNC) 允许服务器管理员评定一个用户设备的状况。</p><p>出于此目的,服务器管理员可能要求以下数据如独立ID、已安装软件列表、系统设置、或加密过的文件校验值。</p><b>任何数据都仅将在验证过服务器的身份ID之后被发出。</b>]]></string>
<!-- Trusted certificate selection -->
<string name="trusted_certs_title">CA证书</string>
<string name="no_certificates">无证书</string>
<string name="reload_trusted_certs">重载CA证书</string>
<string name="system_tab">系统</string>
<string name="user_tab">用户</string>
<string name="local_tab">已导入</string>
<string name="delete_certificate_question">是否删除证书?</string>
<string name="delete_certificate">证书将被永久移除!</string>
<string name="import_certificate">导入证书</string>
<string name="cert_imported_successfully">证书已成功被导入</string>
<string name="cert_import_failed">证书导入失败</string>
<string name="crl_cache">CRL缓存</string>
<string name="clear_crl_cache_title">清除CRL缓存?</string>
<string name="clear_crl_cache_msg_none">CRL缓存为空</string>
<plurals name="clear_crl_cache_msg">
<item quantity="one">CRL缓存包含 %1$d 个文件 (%2$s).</item>
<item quantity="other">CRL缓存包含 %1$d 个文件 (%2$s).</item>
</plurals>
<string name="clear">清理</string>
<!-- VPN state fragment -->
<string name="state_label">状态:</string>
<string name="profile_label">配置:</string>
<string name="disconnect">断开链接</string>
<string name="state_connecting">连接中&#8230;</string>
<string name="state_connected">已连接</string>
<string name="state_disconnecting">断开连接中&#8230;</string>
<string name="state_disabled">无活跃VPN</string>
<string name="state_error">错误</string>
<string name="dismiss">不予考虑</string>
<!-- IMC state fragment -->
<string name="imc_state_label">评估详情:</string>
<string name="imc_state_isolate">受限的</string>
<string name="imc_state_block">失败的</string>
<string name="show_remediation_instructions">浏览修复指引</string>
<!-- Remediation instructions -->
<string name="remediation_instructions_title">修复指引</string>
<!-- Dialogs -->
<string name="login_title">输入密码用于连接</string>
<string name="login_username">用户名</string>
<string name="login_password">密码</string>
<string name="login_confirm">连接</string>
<string name="error_format">无法建立VPN%1$s。</string>
<string name="error_lookup_failed">服务器地址查找失败</string>
<string name="error_unreachable">服务器地址无法连接</string>
<string name="error_peer_auth_failed">核验服务器鉴权失败</string>
<string name="error_auth_failed">用户鉴权失败</string>
<string name="error_assessment_failed">可靠性评估失败</string>
<string name="error_generic">连接中遭遇未知失败</string>
<string name="error_password_missing">密码不可用</string>
<string name="error_certificate_unavailable">客户端证书不可用</string>
<string name="vpn_connected">VPN已连接</string>
<string name="vpn_profile_connected">此VPN配置目前已连接。</string>
<string name="reconnect">重连</string>
<string name="connect_profile_question">是否连接%1$s</string>
<string name="replaces_active_connection">这将覆盖您当前活跃的VPN连接!</string>
<string name="disconnect_question">断开VPN连接?</string>
<string name="disconnect_active_connection">这将断开活动VPN连接!</string>
<string name="connect">连接</string>
<string name="retry">重试</string>
<plurals name="retry_in">
<item quantity="one">%1$d s后重试</item>
<item quantity="other">%1$d s后重试</item>
</plurals>
<string name="cancel_retry">取消重试</string>
<string name="power_whitelist_title">禁用电池优化</string>
<string name="power_whitelist_text">请确认下一个对话框,将应用程序添加到设备的电源白名单中,这样它就可以忽略电池优化,并准确地安排NAT保持有效和重新键入,以便在建立VPN时始终保持可访问性。</string>
<string name="certificate_required_title">User certificate required</string>
<string name="certificate_required_text">Please edit the VPN profile to select one.</string>
<!-- Quick Settings tile -->
<string name="tile_default">切换VPN</string>
<string name="tile_connect">连接VPN</string>
<string name="tile_disconnect">断开VPN</string>
</resources>
@@ -15,8 +15,8 @@
<resources>
<!-- Application -->
<string name="app_name">strongSwan VPN 用戶端</string>
<string name="main_activity_name">strongSwan</string>
<string name="app_name">SHX VPN</string>
<string name="main_activity_name">SHX VPN</string>
<string name="show_log">觀看日誌</string>
<string name="search">搜尋</string>
<string name="vpn_not_supported_title">無法支援VPN</string>
@@ -25,7 +25,7 @@
<string name="vpn_not_supported_no_permission">Unable to get permission to create VPN connections. Either because it was denied by the user, or because a different VPN app has the always-on feature enabled.</string>
<string name="loading">載入中&#8230;</string>
<string name="profile_not_found">沒有找到設定檔</string>
<string name="strongswan_shortcut">strongSwan快速選單</string>
<string name="strongswan_shortcut">SHX VPN快速選單</string>
<string name="permanent_notification_name">VPN connection state</string>
<string name="permanent_notification_description">Provides information about the VPN connection state and serves as permanent notification to keep the VPN service running in the background.</string>
@@ -0,0 +1 @@
@@ -1,49 +1,31 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright (C) 2012-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.
-->
<resources>
<!-- ReUI-like semantic tokens (light). Aliases keep existing resource names. -->
<color name="reui_background">#FAFAFA</color>
<color name="reui_foreground">#0A0A0A</color>
<color name="reui_muted">#737373</color>
<color name="reui_muted_bg">#F5F5F5</color>
<color name="reui_border">#E5E5E5</color>
<color name="reui_frame">#FFFFFF</color>
<color name="reui_primary">#171717</color>
<color name="reui_on_primary">#FAFAFA</color>
<color name="reui_success">#15803D</color>
<color name="reui_success_bg">#DCFCE7</color>
<color name="reui_warning">#B45309</color>
<color name="reui_warning_bg">#FEF3C7</color>
<color name="reui_destructive">#DC2626</color>
<color name="reui_destructive_bg">#FEE2E2</color>
<color name="reui_info">#1D4ED8</color>
<color name="reui_info_bg">#DBEAFE</color>
<color
name="accent">#96BDC2</color>
<color
name="primary">#A2042C</color>
<color
name="primary_dark">#323232</color>
<color
name="error_text">#D9192C</color>
<color
name="warning_text">#FF9909</color>
<color
name="success_text">#99CC00</color>
<color
name="panel_background">#444444</color>
<color
name="panel_separator">#5a5a5a</color>
<color
name="checked">#4a4a4a</color>
<color
name="pressed">#5a5a5a</color>
<color name="accent">@color/reui_primary</color>
<color name="primary">@color/reui_primary</color>
<color name="primary_dark">@color/reui_foreground</color>
<color name="error_text">@color/reui_destructive</color>
<color name="warning_text">@color/reui_warning</color>
<color name="success_text">@color/reui_success</color>
<color name="panel_background">@color/reui_frame</color>
<color name="panel_separator">@color/reui_border</color>
<color name="checked">@color/reui_muted_bg</color>
<color name="pressed">@color/reui_muted_bg</color>
</resources>
@@ -19,8 +19,8 @@
<resources>
<!-- Application -->
<string name="app_name">strongSwan VPN Client</string>
<string name="main_activity_name">strongSwan</string>
<string name="app_name">SHX VPN</string>
<string name="main_activity_name">SHX VPN</string>
<string name="show_log">View log</string>
<string name="search">Search</string>
<string name="vpn_not_supported_title">VPN not supported</string>
@@ -29,7 +29,7 @@
<string name="vpn_not_supported_no_permission">Unable to get permission to create VPN connections. Either because it was denied by the user, or because a different VPN app has the always-on feature enabled.</string>
<string name="loading">Loading&#8230;</string>
<string name="profile_not_found">Profile not found</string>
<string name="strongswan_shortcut">strongSwan shortcut</string>
<string name="strongswan_shortcut">SHX VPN shortcut</string>
<string name="permanent_notification_name">VPN connection state</string>
<string name="permanent_notification_description">Provides information about the VPN connection state and serves as permanent notification to keep the VPN service running in the background.</string>
@@ -113,6 +113,7 @@
<string name="profile_split_tunneling_intro">By default, the client will route all network traffic through the VPN, unless the server narrows the subnets when the connection is established, in which case only traffic the server allows will be routed via VPN (by default, all other traffic is routed as if there was no VPN).</string>
<string name="profile_split_tunnelingv4_title">Block IPv4 traffic not destined for the VPN</string>
<string name="profile_split_tunnelingv6_title">Block IPv6 traffic not destined for the VPN</string>
<string name="profile_split_tunnelingv6_hint">If the server has no IPv6, this stops IPv6 from leaking outside the tunnel on dual-stack networks</string>
<string name="profile_included_subnets_label">Custom subnets</string>
<string name="profile_included_subnets_hint">Only route traffic to specific subnets via VPN, everything else is routed as if there was no VPN (separated by spaces, e.g. \"192.168.1.0/24 2001:db8::/64\")</string>
<string name="profile_excluded_subnets_label">Excluded subnets</string>
@@ -186,6 +187,8 @@
<!-- VPN state fragment -->
<string name="state_label">Status:</string>
<string name="vpn_connection_title">Connection</string>
<string name="vpn_profiles_title">Profiles</string>
<string name="profile_label">Profile:</string>
<string name="disconnect">Disconnect</string>
<string name="state_connecting">Connecting&#8230;</string>
@@ -242,4 +245,54 @@
<string name="tile_connect">Connect VPN</string>
<string name="tile_disconnect">Disconnect VPN</string>
<!-- Compose home -->
<string name="nav_connect">Connect</string>
<string name="nav_profiles">Profiles</string>
<string name="nav_settings">Settings</string>
<string name="status_unprotected">Unprotected</string>
<string name="status_protected">Protected</string>
<string name="status_connecting">Connecting&#8230;</string>
<string name="status_choose_profile">Choose a profile</string>
<string name="badge_connected">Connected</string>
<string name="connection_failed">Connection failed</string>
<string name="profiles_title">Profiles</string>
<string name="search_profiles">Search profiles</string>
<string name="nothing_found">Nothing found</string>
<string name="settings_section_general">General</string>
<string name="settings_section_automation">Automation</string>
<string name="settings_section_certs">Certificates</string>
<string name="settings_section_profiles">Profiles</string>
<string name="settings_section_support">Support</string>
<string name="settings_section_security">Security</string>
<string name="auto_connect_title">Trusted networks</string>
<string name="auto_connect_summary">Connect on cellular and untrusted Wi-Fi</string>
<string name="auto_connect_profile">Auto-connect profile</string>
<string name="auto_connect_profile_none">Not selected</string>
<string name="auto_connect_trusted_title">Trusted Wi-Fi networks</string>
<string name="auto_connect_trusted_hint">The VPN is stopped automatically on these networks. It starts again on cellular or other Wi-Fi.</string>
<string name="auto_connect_no_networks">No trusted networks yet</string>
<string name="auto_connect_add_network">Add network</string>
<string name="auto_connect_add_current">Add current (%1$s)</string>
<string name="auto_connect_ssid_label">Network name (SSID)</string>
<string name="auto_connect_remove_network">Remove trusted network</string>
<string name="auto_connect_location_banner">To pause VPN on trusted Wi-Fi in the background, allow location access all the time.</string>
<string name="auto_connect_location_grant">Allow</string>
<string name="kill_switch_title">Kill switch</string>
<string name="kill_switch_summary_on">Always-on VPN with lockdown is active</string>
<string name="kill_switch_summary_off">Not enabled — traffic may leak outside the tunnel</string>
<string name="kill_switch_open_settings">Open system VPN settings</string>
<string name="profile_section_basic">Basics</string>
<string name="app_version_fmt">SHX VPN %1$s</string>
<string name="recents_title">Recents</string>
<string name="add_profile_cta">Add profile</string>
<string name="connection_details">Connection details</string>
<string name="traffic_down">Down</string>
<string name="traffic_up">Up</string>
<string name="traffic_idle"></string>
<string name="duration_label">Duration</string>
<string name="traffic_stat_down">Downloaded %1$s, %2$s</string>
<string name="traffic_stat_up">Uploaded %1$s, %2$s</string>
<string name="profile_advanced_hint">Identifiers, DNS, MTU, split tunneling</string>
<string name="import_profile_title">Import profile</string>
</resources>
@@ -1,34 +1,43 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
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.
-->
<resources xmlns:android="http://schemas.android.com/apk/res/android">
<style name="ApplicationTheme" parent="Theme.AppCompat">
<item name="colorAccent">@color/accent</item>
<item name="colorPrimary">@color/primary</item>
<item name="colorPrimaryDark">@color/primary_dark</item>
<style name="ApplicationTheme" parent="Theme.Material3.DayNight.NoActionBar">
<item name="colorPrimary">@color/reui_primary</item>
<item name="colorOnPrimary">@color/reui_on_primary</item>
<item name="colorSecondary">@color/reui_muted</item>
<item name="colorOnSecondary">@color/reui_foreground</item>
<item name="colorSurface">@color/reui_background</item>
<item name="colorOnSurface">@color/reui_foreground</item>
<item name="colorError">@color/reui_destructive</item>
<item name="colorOnError">@color/reui_on_primary</item>
<item name="android:colorBackground">@color/reui_background</item>
<item name="colorOnBackground">@color/reui_foreground</item>
<item name="colorOutline">@color/reui_border</item>
<item name="android:statusBarColor">@color/reui_background</item>
<item name="android:navigationBarColor">@color/reui_background</item>
<item name="android:windowBackground">@color/reui_background</item>
<item name="colorAccent">@color/reui_primary</item>
<item name="colorPrimaryDark">@color/reui_foreground</item>
<item name="android:alertDialogTheme">@style/AlertDialogTheme</item>
<item name="preferenceTheme">@style/PreferenceThemeOverlay</item>
<item name="android:windowLightStatusBar">@bool/window_light_status_bar</item>
</style>
<style name="AlertDialogTheme" parent="Theme.AppCompat.Dialog.Alert">
<item name="colorAccent">@color/accent</item>
<style name="ApplicationTheme.ActionBar" parent="Theme.Material3.DayNight">
<item name="colorPrimary">@color/reui_primary</item>
<item name="colorOnPrimary">@color/reui_on_primary</item>
<item name="android:statusBarColor">@color/reui_background</item>
<item name="android:navigationBarColor">@color/reui_background</item>
<item name="android:windowBackground">@color/reui_background</item>
<item name="android:windowLightStatusBar">@bool/window_light_status_bar</item>
<item name="android:alertDialogTheme">@style/AlertDialogTheme</item>
</style>
<style name="TransparentActivity" parent="Theme.AppCompat.NoActionBar">
<style name="AlertDialogTheme" parent="ThemeOverlay.Material3.MaterialAlertDialog">
<item name="colorAccent">@color/reui_primary</item>
</style>
<style name="TransparentActivity" parent="Theme.Material3.DayNight.NoActionBar">
<item name="android:backgroundDimEnabled">false</item>
<item name="android:colorBackgroundCacheHint">@null</item>
<item name="android:windowAnimationStyle">@android:style/Animation</item>
@@ -38,4 +47,77 @@
<item name="android:windowNoTitle">true</item>
</style>
<style name="ReuiFrame">
<item name="android:background">@drawable/bg_reui_frame</item>
<item name="android:padding">16dp</item>
<item name="android:orientation">vertical</item>
</style>
<style name="ReuiFrame.Error">
<item name="android:background">@drawable/bg_reui_frame_error</item>
</style>
<style name="ReuiFrameTitle">
<item name="android:textColor">@color/reui_foreground</item>
<item name="android:textSize">16sp</item>
<item name="android:textStyle">bold</item>
</style>
<style name="ReuiMuted">
<item name="android:textColor">@color/reui_muted</item>
<item name="android:textSize">12sp</item>
</style>
<style name="ReuiValue">
<item name="android:textColor">@color/reui_foreground</item>
<item name="android:textSize">18sp</item>
<item name="android:textStyle">bold</item>
<item name="android:ellipsize">end</item>
<item name="android:maxLines">1</item>
</style>
<style name="ReuiIconTile">
<item name="android:layout_width">42dp</item>
<item name="android:layout_height">42dp</item>
<item name="android:background">@drawable/bg_reui_icon_tile_elevated</item>
<item name="android:padding">11dp</item>
<item name="android:scaleType">centerInside</item>
</style>
<style name="ReuiBadge">
<item name="android:paddingStart">8dp</item>
<item name="android:paddingEnd">8dp</item>
<item name="android:paddingTop">2dp</item>
<item name="android:paddingBottom">2dp</item>
<item name="android:textSize">11sp</item>
<item name="android:textStyle">bold</item>
<item name="android:maxLines">1</item>
<item name="android:ellipsize">end</item>
</style>
<style name="ReuiBadge.Outline">
<item name="android:background">@drawable/bg_reui_badge_outline</item>
<item name="android:textColor">@color/reui_muted</item>
</style>
<style name="ReuiBadge.Success">
<item name="android:background">@drawable/bg_reui_badge_success</item>
<item name="android:textColor">@color/reui_success</item>
</style>
<style name="ReuiBadge.Warning">
<item name="android:background">@drawable/bg_reui_badge_warning</item>
<item name="android:textColor">@color/reui_warning</item>
</style>
<style name="ReuiBadge.Destructive">
<item name="android:background">@drawable/bg_reui_badge_destructive</item>
<item name="android:textColor">@color/reui_destructive</item>
</style>
<style name="ReuiBadge.Muted">
<item name="android:background">@drawable/bg_reui_badge_muted</item>
<item name="android:textColor">@color/reui_muted</item>
</style>
</resources>
@@ -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.autoconnect
import org.assertj.core.api.Assertions.assertThat
import org.junit.Test
import org.strongswan.android.logic.autoconnect.AutoConnectManager.Action
import org.strongswan.android.logic.autoconnect.AutoConnectManager.Config
import org.strongswan.android.logic.autoconnect.AutoConnectManager.NetworkKind
class AutoConnectManagerTest
{
private val config = Config(
enabled = true,
profileUuid = "uuid-1",
trustedSsids = setOf("Home", "Office"),
)
@Test
fun cellularConnectsWhenVpnDown()
{
assertThat(AutoConnectManager.evaluateDecision(config, NetworkKind.CELLULAR, vpnActive = false, autoSession = false))
.isEqualTo(Action.CONNECT)
}
@Test
fun untrustedWifiConnectsWhenVpnDown()
{
assertThat(AutoConnectManager.evaluateDecision(config, NetworkKind.UNTRUSTED_WIFI, vpnActive = false, autoSession = false))
.isEqualTo(Action.CONNECT)
}
@Test
fun trustedWifiDisconnectsAutoSession()
{
assertThat(AutoConnectManager.evaluateDecision(config, NetworkKind.TRUSTED_WIFI, vpnActive = true, autoSession = true))
.isEqualTo(Action.DISCONNECT)
}
@Test
fun trustedWifiDisconnectsManualSession()
{
assertThat(AutoConnectManager.evaluateDecision(config, NetworkKind.TRUSTED_WIFI, vpnActive = true, autoSession = false))
.isEqualTo(Action.DISCONNECT)
}
@Test
fun unknownWifiKeepsExistingTunnel()
{
assertThat(AutoConnectManager.evaluateDecision(config, NetworkKind.UNKNOWN_WIFI, vpnActive = true, autoSession = true))
.isEqualTo(Action.IGNORE)
}
@Test
fun unknownWifiConnectsWhenVpnDown()
{
assertThat(AutoConnectManager.evaluateDecision(config, NetworkKind.UNKNOWN_WIFI, vpnActive = false, autoSession = false))
.isEqualTo(Action.CONNECT)
}
@Test
fun ignoresWhenVpnAlreadyActive()
{
assertThat(AutoConnectManager.evaluateDecision(config, NetworkKind.CELLULAR, vpnActive = true, autoSession = false))
.isEqualTo(Action.IGNORE)
}
@Test
fun ignoresWhenDisabled()
{
val disabled = config.copy(enabled = false)
assertThat(AutoConnectManager.evaluateDecision(disabled, NetworkKind.CELLULAR, vpnActive = false, autoSession = false))
.isEqualTo(Action.IGNORE)
}
@Test
fun ignoresWhenNoProfileSelected()
{
val noProfile = config.copy(profileUuid = null)
assertThat(AutoConnectManager.evaluateDecision(noProfile, NetworkKind.CELLULAR, vpnActive = false, autoSession = false))
.isEqualTo(Action.IGNORE)
}
@Test
fun ignoresWhenNetworkUnknown()
{
assertThat(AutoConnectManager.evaluateDecision(config, null, vpnActive = false, autoSession = false))
.isEqualTo(Action.IGNORE)
}
}
@@ -0,0 +1,37 @@
/*
* 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.autoconnect
import org.assertj.core.api.Assertions.assertThat
import org.junit.Test
class SsidReaderTest
{
@Test
fun stripsQuotes()
{
assertThat(SsidReader.normalize("\"Home\"")).isEqualTo("Home")
}
@Test
fun rejectsUnknownPlaceholder()
{
assertThat(SsidReader.normalize("<unknown ssid>")).isNull()
assertThat(SsidReader.normalize("\"<unknown ssid>\"")).isNull()
assertThat(SsidReader.normalize("0x")).isNull()
assertThat(SsidReader.normalize("")).isNull()
assertThat(SsidReader.normalize(null)).isNull()
}
}
+1
View File
@@ -0,0 +1 @@
+1
View File
@@ -0,0 +1 @@
+2
View File
@@ -5,6 +5,8 @@ buildscript {
}
dependencies {
classpath 'com.android.tools.build:gradle:8.13.0'
classpath 'org.jetbrains.kotlin:kotlin-gradle-plugin:2.1.20'
classpath 'org.jetbrains.kotlin:compose-compiler-gradle-plugin:2.1.20'
}
}
+1
View File
@@ -2,3 +2,4 @@ android.enableJetifier=false
android.nonFinalResIds=false
android.nonTransitiveRClass=false
android.useAndroidX=true
org.gradle.jvmargs=-Xmx2048m -XX:MaxMetaspaceSize=512m -Dfile.encoding=UTF-8
+1 -1
View File
@@ -34,7 +34,7 @@ fi
: ${TAG=strongswan-android-openssl-builder}
DIR=$(dirname `readlink -f $0`)
DIR=$(cd "$(dirname "$0")" && pwd)
: ${OUT=$DIR/../app/src/main/jni/openssl}
mkdir -p $OUT
-68
View File
@@ -1,68 +0,0 @@
#!/bin/bash
#
# Compile static versions of OpenSSL's libcrypto for use with strongSwan's
# Android app.
#
# Copies archives and header files to $OUT_DIR.
set -e
export PATH=${ANDROID_NDK_ROOT}/toolchains/llvm/prebuilt/linux-x86_64/bin:$PATH
# necessary for OpenSSL 1.1.1
export ANDROID_NDK_HOME=${ANDROID_NDK_ROOT}
# automatically determine the ABIs supported by the NDK
: ${ABIS=$(jq -r 'map_values(select(.default == true)) | keys | join(" ")' ${ANDROID_NDK_ROOT}/meta/abis.json)}
# this should match APP_PLATFORM
: ${MIN_SDK=21}
for ABI in ${ABIS}
do
echo "## Building OpenSSL's libcrypto for ${ABI}"
case ${ABI} in
armeabi-v7a)
OPTIONS="android-arm"
;;
arm64-v8a)
OPTIONS="android-arm64"
;;
x86)
OPTIONS="android-x86"
;;
x86_64)
OPTIONS="android-x86_64"
;;
*)
echo "!! Skipping unknown ABI '${ABI}'"
continue
;;
esac
OPTIONS="${OPTIONS} \
no-shared no-ct no-cast no-comp no-dgram no-dsa no-gost no-idea \
no-rmd160 no-seed no-sm2 no-sm3 no-sm4 no-sock no-srp no-srtp \
no-err no-engine no-dso no-hw no-stdio no-ui-console \
-fPIC -DOPENSSL_PIC \
-ffast-math -O3 -funroll-loops -Wno-macro-redefined \
-D__ANDROID_API__=${MIN_SDK} \
"
make distclean >/dev/null || true
./Configure ${OPTIONS}
make -j $(nproc) build_generated >/dev/null
make -j $(nproc) libcrypto.a >/dev/null
mkdir -p ${OUT_DIR}/${ABI}
cp libcrypto.a ${OUT_DIR}/${ABI}
done
# The only difference between ABIs is the config header (e.g. configuration.h
# for OpenSSL 3.0), which does define the size of BN_ULONG in bn.h.
# However, the only function we use that depends on it is BN_set_word() when
# generating RSA private keys, which isn't used in the Android app.
cp -R include/ ${OUT_DIR}