android: server page as in the Final 2026 mockup; profile source, link updates with a diff sheet and a daily check, per-profile IKE fragmentation

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
DenozordecandClaude Opus 5.5 committed 2026-10-12 15:22:53 +07:00
1 parent 638c58f0e4
commit e7b801d584
17 files changed
+1512 -3

No files matched your search

@@ -79,6 +79,9 @@
<action android:name="android.service.quicksettings.action.QS_TILE_PREFERENCES" />
</intent-filter>
</activity>
<activity
android:name=".ui.ServerActivity"
android:exported="false" />
<activity
android:name=".ui.StatsActivity"
android:exported="false" />
@@ -561,6 +561,7 @@ public class CharonVpnService extends VpnService implements Runnable, VpnStateSe
writer.setValue("connection.local_id", mCurrentProfile.getLocalId());
writer.setValue("connection.remote_id", mCurrentProfile.getRemoteId());
writer.setValue("connection.certreq", (mCurrentProfile.getFlags() & VpnProfile.FLAGS_SUPPRESS_CERT_REQS) == 0);
writer.setValue("connection.fragmentation", org.strongswan.android.logic.profiles.ProfileExtras.get(CharonVpnService.this, mCurrentProfile.getUUID().toString()).getIkeFragmentation());
writer.setValue("connection.strict_revocation", (mCurrentProfile.getFlags() & VpnProfile.FLAGS_STRICT_REVOCATION) != 0);
writer.setValue("connection.ike_proposal", mCurrentProfile.getIkeProposal());
writer.setValue("connection.esp_proposal", mCurrentProfile.getEspProposal());
@@ -139,6 +139,8 @@ public class StrongSwanApplication extends Application
/* checks the update storage for a newer version of the app (if enabled and due) */
UpdateManager.getInstance(this).start();
/* daily re-read of the profiles that came from a link */
org.strongswan.android.logic.profiles.ProfileUpdater.schedule(this);
}
private void reloadManagedConfigurationAndNotifyListeners()
@@ -66,6 +66,8 @@ enum class EventAction
INSTALL_UPDATE,
/** let the app in [ShxEvent.target] bypass the VPN */
SET_APP_DIRECT,
/** show the link update of the profile [ShxEvent.target] (F_ProfileUpdate) */
PROFILE_UPDATE,
}
data class ShxEvent(
@@ -0,0 +1,206 @@
/*
* Copyright (C) 2026 SHX VPN
*
* This program is free software; you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the
* Free Software Foundation; either version 2 of the License, or (at your
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
*
* This program is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
* for more details.
*/
package org.strongswan.android.logic.profiles
import android.content.Context
import android.util.Log
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import org.json.JSONArray
import org.json.JSONObject
import java.io.File
/** Where a profile came from (F_Profile «Profile source»). */
enum class ProfileSourceType { LINK, FILE, QR, MANUAL, UNKNOWN }
/**
* What the app keeps about a profile besides the strongSwan profile itself:
* its source (and the update link), and the resilience settings of F_Profile.
*/
data class ProfileExtra(
val uuid: String,
val source: ProfileSourceType = ProfileSourceType.UNKNOWN,
/** https link for LINK, the file name for FILE */
val sourceValue: String? = null,
val addedAt: Long = 0L,
val autoUpdate: Boolean = true,
val checkedAt: Long = 0L,
/** number of changes found by the last check, -1 when it failed */
val lastChanges: Int = 0,
/** ports tried in turn when the main one does not answer */
val backupPorts: List<Int> = emptyList(),
/** profile used when this one does not connect in [RESERVE_TIMEOUT_S] */
val reserveUuid: String? = null,
/** profile switched to while the operator whitelists are on */
val bridgeUuid: String? = null,
val bridgeEnabled: Boolean = false,
/** IKE fragmentation (charon «fragmentation = yes»), on unless switched off */
val ikeFragmentation: Boolean = true,
)
const val RESERVE_TIMEOUT_S = 15
/** Store of [ProfileExtra] (profile_extras.json), observable. */
object ProfileExtras
{
private const val TAG = "ProfileExtras"
private const val FILE_NAME = "profile_extras.json"
private val _all = MutableStateFlow<Map<String, ProfileExtra>>(emptyMap())
val all: StateFlow<Map<String, ProfileExtra>> = _all
@Volatile private var loaded = false
@JvmStatic
fun get(context: Context, uuid: String): ProfileExtra
{
load(context)
return _all.value[uuid] ?: ProfileExtra(uuid)
}
fun flow(context: Context): StateFlow<Map<String, ProfileExtra>>
{
load(context)
return all
}
@JvmStatic
fun update(context: Context, uuid: String, transform: (ProfileExtra) -> ProfileExtra)
{
load(context)
_all.update { it + (uuid to transform(it[uuid] ?: ProfileExtra(uuid))) }
save(context)
}
/**
* A profile was imported: remember where from. A re-import from the
* same kind of source keeps the user's settings (auto update, resilience).
*/
@JvmStatic
fun recordImport(context: Context, uuid: String, scheme: String?, uri: String?, qr: Boolean)
{
val type = when
{
qr -> ProfileSourceType.QR
scheme == "http" || scheme == "https" -> ProfileSourceType.LINK
else -> ProfileSourceType.FILE
}
val value = when (type)
{
ProfileSourceType.LINK -> uri
ProfileSourceType.FILE -> uri?.substringAfterLast('/')?.substringAfterLast("%2F")?.takeIf { it.isNotBlank() }
else -> null
}
update(context, uuid) {
/* a link stays the update source even when the new copy came from a file */
if (it.source == ProfileSourceType.LINK && type != ProfileSourceType.LINK) it.copy(addedAt = System.currentTimeMillis())
else it.copy(source = type, sourceValue = value, addedAt = System.currentTimeMillis(), checkedAt = if (type == ProfileSourceType.LINK) System.currentTimeMillis() else it.checkedAt, lastChanges = 0)
}
}
/** A profile created by hand (when nothing is recorded for it yet). */
@JvmStatic
fun recordManual(context: Context, uuid: String)
{
load(context)
if (_all.value[uuid]?.source.let { it == null || it == ProfileSourceType.UNKNOWN })
{
update(context, uuid) { it.copy(source = ProfileSourceType.MANUAL, addedAt = System.currentTimeMillis()) }
}
}
@JvmStatic
fun remove(context: Context, uuid: String)
{
load(context)
_all.update { it - uuid }
save(context)
}
private fun load(context: Context)
{
if (loaded)
{
return
}
synchronized(this)
{
if (loaded)
{
return
}
try
{
val file = File(context.filesDir, FILE_NAME)
if (file.exists())
{
val array = JSONArray(file.readText())
_all.value = (0 until array.length()).map { fromJson(array.getJSONObject(it)) }.associateBy { it.uuid }
}
}
catch (e: Exception)
{
Log.w(TAG, "Loading failed", e)
}
loaded = true
}
}
private fun save(context: Context)
{
try
{
val array = JSONArray()
_all.value.values.forEach { array.put(toJson(it)) }
val file = File(context.filesDir, FILE_NAME)
val tmp = File(file.path + ".tmp")
tmp.writeText(array.toString())
if (!tmp.renameTo(file))
{
file.writeText(array.toString())
tmp.delete()
}
}
catch (e: Exception)
{
Log.w(TAG, "Saving failed", e)
}
}
internal fun toJson(e: ProfileExtra): JSONObject = JSONObject()
.put("uuid", e.uuid).put("source", e.source.name).put("value", e.sourceValue ?: JSONObject.NULL)
.put("added", e.addedAt).put("auto", e.autoUpdate).put("checked", e.checkedAt).put("changes", e.lastChanges)
.put("ports", JSONArray(e.backupPorts)).put("reserve", e.reserveUuid ?: JSONObject.NULL)
.put("bridge", e.bridgeUuid ?: JSONObject.NULL).put("bridgeOn", e.bridgeEnabled).put("frag", e.ikeFragmentation)
internal fun fromJson(o: JSONObject): ProfileExtra
{
val ports = o.optJSONArray("ports")
return ProfileExtra(
uuid = o.getString("uuid"),
source = runCatching { ProfileSourceType.valueOf(o.optString("source")) }.getOrDefault(ProfileSourceType.UNKNOWN),
sourceValue = o.optString("value").takeIf { !o.isNull("value") && it.isNotEmpty() },
addedAt = o.optLong("added"),
autoUpdate = o.optBoolean("auto", true),
checkedAt = o.optLong("checked"),
lastChanges = o.optInt("changes"),
backupPorts = if (ports == null) emptyList() else (0 until ports.length()).map { ports.getInt(it) },
reserveUuid = o.optString("reserve").takeIf { !o.isNull("reserve") && it.isNotEmpty() },
bridgeUuid = o.optString("bridge").takeIf { !o.isNull("bridge") && it.isNotEmpty() },
bridgeEnabled = o.optBoolean("bridgeOn"),
ikeFragmentation = o.optBoolean("frag", true),
)
}
}
@@ -0,0 +1,292 @@
/*
* Copyright (C) 2026 SHX VPN
*
* This program is free software; you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the
* Free Software Foundation; either version 2 of the License, or (at your
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
*
* This program is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
* for more details.
*/
package org.strongswan.android.logic.profiles
import android.content.Context
import android.content.Intent
import android.util.Base64
import androidx.localbroadcastmanager.content.LocalBroadcastManager
import androidx.work.Constraints
import androidx.work.CoroutineWorker
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.NetworkType
import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkManager
import androidx.work.WorkerParameters
import org.json.JSONObject
import org.strongswan.android.R
import org.strongswan.android.data.VpnProfile
import org.strongswan.android.data.VpnProfileSource
import org.strongswan.android.logic.DiagLog
import org.strongswan.android.logic.TrustedCertificateManager
import org.strongswan.android.logic.VpnStateService
import org.strongswan.android.logic.events.EventAction
import org.strongswan.android.logic.events.EventCategory
import org.strongswan.android.logic.events.EventKind
import org.strongswan.android.logic.events.EventLog
import org.strongswan.android.utils.Constants
import java.io.ByteArrayInputStream
import java.net.HttpURLConnection
import java.net.URL
import java.security.KeyStore
import java.security.cert.CertificateFactory
import java.security.cert.X509Certificate
import java.text.SimpleDateFormat
import java.util.Locale
import java.util.UUID
import java.util.concurrent.TimeUnit
/** One changed field (F_ProfileUpdate): label, old and new value as shown. */
data class ProfileChange(val label: String, val old: String, val new: String)
/** The server's copy of a profile and how it differs from the local one. */
class ProfileCheck(
val uuid: String,
val changes: List<ProfileChange>,
internal val gateway: String,
internal val port: Int?,
internal val remoteId: String?,
internal val certificate: X509Certificate?,
)
internal enum class ProfileField { ADDRESS, PORT, REMOTE_ID, CA }
/** A raw difference: CA values are the expiry dates. */
internal data class FieldChange(val field: ProfileField, val old: Any?, val new: Any?)
/** The server-side fields of a downloaded .sswan. */
internal data class RemoteProfile(val gateway: String, val port: Int?, val remoteId: String?, val certificate: X509Certificate?)
internal fun parseRemote(text: String, decode: (String) -> ByteArray): RemoteProfile
{
val remote = JSONObject(text).getJSONObject("remote")
return RemoteProfile(
gateway = remote.getString("addr"),
port = remote.optInt("port").takeIf { it in 1..65535 },
remoteId = remote.optString("id").ifEmpty { null },
certificate = remote.optString("cert").ifEmpty { null }?.let {
CertificateFactory.getInstance("X.509").generateCertificate(ByteArrayInputStream(decode(it))) as X509Certificate
},
)
}
/** What changes when the local profile takes the [remote] fields (port 4500 when unset). */
internal fun diff(gateway: String?, port: Int?, remoteId: String?, currentCa: X509Certificate?, remote: RemoteProfile): List<FieldChange>
{
val changes = ArrayList<FieldChange>()
if (remote.gateway != gateway)
{
changes += FieldChange(ProfileField.ADDRESS, gateway, remote.gateway)
}
if ((remote.port ?: 4500) != (port ?: 4500))
{
changes += FieldChange(ProfileField.PORT, port ?: 4500, remote.port ?: 4500)
}
if (remote.remoteId != null && remote.remoteId != remoteId)
{
changes += FieldChange(ProfileField.REMOTE_ID, remoteId ?: gateway, remote.remoteId)
}
val cert = remote.certificate
if (cert != null && (currentCa == null || !currentCa.encoded.contentEquals(cert.encoded)))
{
changes += FieldChange(ProfileField.CA, currentCa?.notAfter, cert.notAfter)
}
return changes
}
/**
* Re-reads a profile from its link (F_Profile «Check now», the daily
* check) and applies the server-side fields: address, port, remote
* identity and CA certificate. The login, DNS, resilience settings,
* routing rules and apps stay as they are.
*/
object ProfileUpdater
{
private const val TAG = "ProfileUpdater"
private const val WORK_NAME = "profile-update-check"
/** Downloads and compares; throws on network or format errors. */
@JvmStatic
fun check(context: Context, uuid: String): ProfileCheck
{
val extra = ProfileExtras.get(context, uuid)
val link = extra.sourceValue?.takeIf { extra.source == ProfileSourceType.LINK } ?: throw IllegalStateException("no link")
val profile = loadProfile(context, uuid) ?: throw IllegalStateException("no profile")
val text = download(link)
val remote = parseRemote(text) { Base64.decode(it, Base64.DEFAULT) }
val currentCa = profile.certificateAlias?.let { TrustedCertificateManager.getInstance().load().getCACertificateFromAlias(it) }
val until = SimpleDateFormat("MM.yyyy", Locale.getDefault())
val changes = diff(profile.gateway, profile.port, profile.remoteId, currentCa, remote).map {
val label = context.getString(
when (it.field)
{
ProfileField.ADDRESS -> R.string.profile_update_addr
ProfileField.PORT -> R.string.profile_update_port
ProfileField.REMOTE_ID -> R.string.profile_update_id
ProfileField.CA -> R.string.profile_update_ca
}
)
fun show(value: Any?): String = when (value)
{
null -> "—"
is java.util.Date -> context.getString(R.string.profile_update_until, until.format(value))
else -> value.toString()
}
ProfileChange(label, show(it.old), show(it.new))
}
val gateway = remote.gateway
val port = remote.port
val remoteId = remote.remoteId
val certificate = remote.certificate
ProfileExtras.update(context, uuid) { it.copy(checkedAt = System.currentTimeMillis(), lastChanges = changes.size) }
return ProfileCheck(uuid, changes, gateway, port, remoteId, certificate)
}
/** Writes the server-side fields and reconnects when the profile is in use. */
@JvmStatic
fun apply(context: Context, check: ProfileCheck)
{
val source = VpnProfileSource(context)
source.open()
try
{
val profile = source.getVpnProfile(UUID.fromString(check.uuid)) ?: return
profile.gateway = check.gateway
profile.port = check.port
if (check.remoteId != null)
{
profile.remoteId = check.remoteId
}
check.certificate?.let { cert ->
val store = KeyStore.getInstance("LocalCertificateStore")
store.load(null, null)
store.setCertificateEntry(null, cert)
profile.certificateAlias = store.getCertificateAlias(cert)
TrustedCertificateManager.getInstance().reset()
}
source.updateVpnProfile(profile)
}
finally
{
source.close()
}
ProfileExtras.update(context, check.uuid) { it.copy(lastChanges = 0) }
val intent = Intent(Constants.VPN_PROFILES_CHANGED).putExtra(Constants.VPN_PROFILES_SINGLE, check.uuid)
LocalBroadcastManager.getInstance(context).sendBroadcast(intent)
EventLog.getInstance(context).resolve("profile-update:${check.uuid}")
DiagLog.i(TAG, "profile ${check.uuid} updated from its link (${check.changes.size} changes)")
/* the running tunnel still uses the old settings */
val service = VpnStateService.getLiveInstance()
if (service != null && service.profile?.getUUID()?.toString() == check.uuid && service.state != VpnStateService.State.DISABLED)
{
service.reconnect()
}
}
private fun loadProfile(context: Context, uuid: String): VpnProfile?
{
val source = VpnProfileSource(context)
source.open()
try
{
return source.getVpnProfile(UUID.fromString(uuid))
}
finally
{
source.close()
}
}
private fun download(link: String): String
{
val connection = URL(link).openConnection() as HttpURLConnection
connection.connectTimeout = 15_000
connection.readTimeout = 15_000
try
{
if (connection.responseCode !in 200..299)
{
throw IllegalStateException("HTTP ${connection.responseCode}")
}
/* a profile is a few KiB; anything huge is not one */
return connection.inputStream.use { String(it.readNBytesCompat(512 * 1024), Charsets.UTF_8) }
}
finally
{
connection.disconnect()
}
}
private fun java.io.InputStream.readNBytesCompat(max: Int): ByteArray
{
val out = java.io.ByteArrayOutputStream()
val buffer = ByteArray(8192)
while (out.size() < max)
{
val n = read(buffer)
if (n < 0)
{
break
}
out.write(buffer, 0, n)
}
return out.toByteArray()
}
/** Daily check of the profiles with a link and «Update automatically» on. */
@JvmStatic
fun schedule(context: Context)
{
val request = PeriodicWorkRequestBuilder<ProfileUpdateWorker>(24, TimeUnit.HOURS)
.setConstraints(Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build())
.build()
WorkManager.getInstance(context).enqueueUniquePeriodicWork(WORK_NAME, ExistingPeriodicWorkPolicy.KEEP, request)
}
internal fun checkAll(context: Context)
{
val due = ProfileExtras.flow(context).value.values.filter { it.source == ProfileSourceType.LINK && it.autoUpdate }
for (extra in due)
{
try
{
val check = check(context, extra.uuid)
if (check.changes.isNotEmpty())
{
val name = loadProfile(context, extra.uuid)?.name ?: extra.uuid
EventLog.getInstance(context).recordOnce(
EventKind.ASK, EventCategory.CONNECTION, context.getString(R.string.event_profile_update_title, name),
context.getString(R.string.event_profile_update_text, check.changes.size), EventAction.PROFILE_UPDATE,
"profile-update:${extra.uuid}", extra.uuid,
)
}
}
catch (e: Exception)
{
ProfileExtras.update(context, extra.uuid) { it.copy(checkedAt = System.currentTimeMillis(), lastChanges = -1) }
DiagLog.i(TAG, "profile check failed: $e")
}
}
}
}
class ProfileUpdateWorker(context: Context, params: WorkerParameters) : CoroutineWorker(context, params)
{
override suspend fun doWork(): Result
{
ProfileUpdater.checkAll(applicationContext)
return Result.success()
}
}
@@ -67,6 +67,7 @@ class InboxActivity : AppCompatActivity()
EventAction.OPEN_ROUTING -> startActivity(main.putExtra(MainActivity.EXTRA_OPEN_ROUTING, true))
EventAction.INSTALL_UPDATE -> startActivity(main.putExtra(MainActivity.EXTRA_INSTALL_UPDATE, true))
EventAction.OPEN_ROUTE_CHECK -> startActivity(Intent(this, RouteCheckActivity::class.java))
EventAction.PROFILE_UPDATE -> event.target?.let { startActivity(ServerActivity.intent(this, it, checkUpdate = true)) }
EventAction.OPEN_LOG -> startActivity(Intent(this, LogActivity::class.java))
}
if (action == EventAction.OPEN_HOME || action == EventAction.OPEN_ROUTING || action == EventAction.INSTALL_UPDATE)
@@ -0,0 +1,781 @@
/*
* Copyright (C) 2026 SHX VPN
*
* This program is free software; you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the
* Free Software Foundation; either version 2 of the License, or (at your
* option) any later version.
*/
package org.strongswan.android.ui
import android.content.ActivityNotFoundException
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.text.format.DateUtils
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Icon
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.localbroadcastmanager.content.LocalBroadcastManager
import com.journeyapps.barcodescanner.ScanContract
import com.journeyapps.barcodescanner.ScanOptions
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.strongswan.android.R
import org.strongswan.android.data.VpnProfile
import org.strongswan.android.data.VpnProfileDataSource
import org.strongswan.android.data.VpnProfileSource
import org.strongswan.android.data.VpnType
import org.strongswan.android.logic.VpnStateService
import org.strongswan.android.logic.connectivity.ConnectivityProbe
import org.strongswan.android.logic.profiles.ProfileCheck
import org.strongswan.android.logic.profiles.ProfileExtra
import org.strongswan.android.logic.profiles.ProfileExtras
import org.strongswan.android.logic.profiles.ProfileSourceType
import org.strongswan.android.logic.profiles.ProfileUpdater
import org.strongswan.android.logic.profiles.RESERVE_TIMEOUT_S
import org.strongswan.android.ui.compose.ProfileQr
import org.strongswan.android.ui.compose.ShareProfileScreen
import org.strongswan.android.ui.compose.components.ShxSnackbarHost
import org.strongswan.android.ui.compose.design.ShxAlertDialog
import org.strongswan.android.ui.compose.design.ShxButton
import org.strongswan.android.ui.compose.design.ShxButtonStyle
import org.strongswan.android.ui.compose.design.ShxDialog
import org.strongswan.android.ui.compose.design.ShxFormField
import org.strongswan.android.ui.compose.design.ShxIcons
import org.strongswan.android.ui.compose.design.ShxPanel
import org.strongswan.android.ui.compose.design.ShxSectionHeader
import org.strongswan.android.ui.compose.design.ShxSheet
import org.strongswan.android.ui.compose.design.ShxSheetTitle
import org.strongswan.android.ui.compose.design.ShxSwitch
import org.strongswan.android.ui.compose.home.serverCode
import org.strongswan.android.ui.compose.theme.LocalShxColors
import org.strongswan.android.ui.compose.theme.ShxTheme
import org.strongswan.android.ui.compose.theme.ShxType
import org.strongswan.android.utils.Constants
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.UUID
/** One server (F_Profile): source and updates, server, resilience and network settings. */
class ServerActivity : AppCompatActivity()
{
override fun onCreate(savedInstanceState: Bundle?)
{
super.onCreate(savedInstanceState)
enableEdgeToEdge()
val uuid = intent.getStringExtra(VpnProfileDataSource.KEY_UUID)
if (uuid == null)
{
finish()
return
}
val checkNow = intent.getBooleanExtra(EXTRA_CHECK_UPDATE, false)
setContent {
ShxTheme {
ServerScreen(uuid, checkNow, onBack = { finish() })
}
}
}
companion object
{
private const val EXTRA_CHECK_UPDATE = "org.strongswan.android.extra.CHECK_UPDATE"
@JvmStatic
@JvmOverloads
fun intent(context: Context, uuid: String, checkUpdate: Boolean = false): Intent =
Intent(context, ServerActivity::class.java)
.putExtra(VpnProfileDataSource.KEY_UUID, uuid)
.putExtra(EXTRA_CHECK_UPDATE, checkUpdate)
}
}
private fun loadProfiles(context: Context): List<VpnProfile>
{
val source = VpnProfileSource(context)
source.open()
try
{
return source.allVpnProfiles
}
finally
{
source.close()
}
}
/* which picker sheet is open */
private enum class Picker { RESERVE, BRIDGE }
@Composable
private fun ServerScreen(uuid: String, checkOnOpen: Boolean, onBack: () -> Unit)
{
val c = LocalShxColors.current
val context = LocalContext.current
val scope = rememberCoroutineScope()
val snackbar = remember { SnackbarHostState() }
var reloads by remember { mutableIntStateOf(0) }
val owner = LocalLifecycleOwner.current
DisposableEffect(owner) {
val observer = LifecycleEventObserver { _, event -> if (event == Lifecycle.Event.ON_RESUME) reloads++ }
owner.lifecycle.addObserver(observer)
onDispose { owner.lifecycle.removeObserver(observer) }
}
val profiles by produceState<List<VpnProfile>?>(null, reloads) { value = withContext(Dispatchers.IO) { loadProfiles(context) } }
val all = profiles ?: return
val profile = all.firstOrNull { it.getUUID().toString() == uuid }
if (profile == null)
{
LaunchedEffect(Unit) { onBack() }
return
}
val extras by remember { ProfileExtras.flow(context) }.collectAsStateWithLifecycle()
val extra = extras[uuid] ?: ProfileExtra(uuid)
/* the live state of the tunnel and a direct ping of the server */
val vpn by produceState<Pair<VpnStateService.State, String?>?>(null) {
while (true)
{
val service = VpnStateService.getLiveInstance()
value = service?.let { it.state to it.profile?.getUUID()?.toString() }
delay(1500)
}
}
val rtt by produceState<Long?>(null, profile.gateway) {
while (true)
{
value = withContext(Dispatchers.IO) { ConnectivityProbe.pingServerDirect(context, profile.gateway, 1500)?.first }
delay(30_000)
}
}
val active = vpn?.second == uuid
val connected = active && vpn?.first == VpnStateService.State.CONNECTED
var checking by remember { mutableStateOf(false) }
var check by remember { mutableStateOf<ProfileCheck?>(null) }
var showShare by remember { mutableStateOf(false) }
var showDelete by remember { mutableStateOf(false) }
var showLink by remember { mutableStateOf(false) }
var showPorts by remember { mutableStateOf(false) }
var picker by remember { mutableStateOf<Picker?>(null) }
val noChanges = stringResource(R.string.profile_check_none)
val failed = stringResource(R.string.profile_check_failed)
val updated = stringResource(R.string.profile_updated)
fun runCheck()
{
if (checking) return
checking = true
scope.launch {
val result = withContext(Dispatchers.IO) { runCatching { ProfileUpdater.check(context, uuid) } }
checking = false
result.onSuccess { if (it.changes.isEmpty()) snackbar.showSnackbar(noChanges) else check = it }
result.onFailure {
ProfileExtras.update(context, uuid) { e -> e.copy(checkedAt = System.currentTimeMillis(), lastChanges = -1) }
snackbar.showSnackbar(failed)
}
}
}
LaunchedEffect(checkOnOpen) { if (checkOnOpen && extra.source == ProfileSourceType.LINK) runCheck() }
fun edit() = context.startActivity(Intent(context, VpnProfileDetailActivity::class.java).putExtra(VpnProfileDataSource.KEY_UUID, uuid))
fun export() = scope.launch {
val file = withContext(Dispatchers.IO) { org.strongswan.android.logic.VpnProfileExporter.exportProfile(context, profile, false) }
if (file == null)
{
snackbar.showSnackbar(context.getString(R.string.share_profile_failed))
return@launch
}
val send = Intent(Intent.ACTION_SEND).setType(org.strongswan.android.data.ProfileExportProvider.MIME_TYPE).putExtra(Intent.EXTRA_STREAM, file)
try
{
context.startActivity(Intent.createChooser(send, context.getString(R.string.share_profile)))
}
catch (_: ActivityNotFoundException)
{
snackbar.showSnackbar(context.getString(R.string.share_profile_failed))
}
}
val scanner = rememberLauncherForActivityResult(ScanContract()) { result ->
result.contents?.let { text -> ProfileQr.importIntent(context, text)?.let { context.startActivity(it) } }
}
Box(Modifier.fillMaxSize().background(c.background))
{
Column(
verticalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.fillMaxSize().statusBarsPadding().verticalScroll(rememberScrollState()).navigationBarsPadding()
.padding(bottom = 24.dp),
)
{
/* header: back, «Share» pill */
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().padding(start = 12.dp, end = 12.dp, top = 16.dp, bottom = 8.dp))
{
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(44.dp).clip(CircleShape).clickable(role = Role.Button, onClick = onBack))
{
Icon(ShxIcons.Back, contentDescription = stringResource(R.string.back), tint = c.text, modifier = Modifier.size(22.dp))
}
Spacer(Modifier.weight(1f))
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.height(40.dp).clip(RoundedCornerShape(20.dp)).background(c.accentContainer)
.clickable(role = Role.Button) { showShare = true }.padding(horizontal = 14.dp),
)
{
Icon(ShxIcons.Scan, contentDescription = null, tint = c.onAccentContainer, modifier = Modifier.size(18.dp))
Text(stringResource(R.string.server_share), style = ShxType.chip.copy(fontWeight = FontWeight.ExtraBold), color = c.onAccentContainer)
}
}
Column(Modifier.padding(horizontal = 16.dp), verticalArrangement = Arrangement.spacedBy(12.dp))
{
/* who: avatar, name, state */
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(14.dp), modifier = Modifier.padding(start = 4.dp, end = 4.dp, bottom = 6.dp))
{
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(56.dp).clip(CircleShape).background(c.accent))
{
Text(serverCode(profile, null), style = ShxType.cardHeading.copy(fontSize = 18.sp), color = Color.White)
}
Column(verticalArrangement = Arrangement.spacedBy(3.dp))
{
Text(profile.name ?: profile.gateway, style = ShxType.screenTitle, color = c.text)
val (dot, textColor, label) = when
{
connected -> Triple(c.success, c.onSuccess, rtt?.let { stringResource(R.string.server_state_connected_ms, it.toInt()) } ?: stringResource(R.string.server_state_connected))
active && vpn?.first == VpnStateService.State.CONNECTING -> Triple(c.accent, c.accentText, stringResource(R.string.server_state_connecting))
rtt != null -> Triple(c.textSub, c.textSub, stringResource(R.string.server_state_answers, rtt!!.toInt()))
else -> Triple(c.warning, c.onWarningContainer, stringResource(R.string.server_state_no_answer))
}
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp))
{
Box(Modifier.size(8.dp).clip(CircleShape).background(dot))
Text(label, style = ShxType.chip, color = textColor)
}
}
}
SourceSection(
extra = extra,
checking = checking,
onEditLink = { showLink = true },
onAuto = { on -> ProfileExtras.update(context, uuid) { it.copy(autoUpdate = on) } },
onCheck = ::runCheck,
onFile = { context.startActivity(Intent(context, VpnProfileImportActivity::class.java)) },
onQr = { scanner.launch(ScanOptions().setDesiredBarcodeFormats(ScanOptions.QR_CODE).setBeepEnabled(false).setOrientationLocked(true).setPrompt("")) },
)
val others = all.filter { it.getUUID().toString() != uuid }
fun nameOf(other: String?) = others.firstOrNull { it.getUUID().toString() == other }?.let { it.name ?: it.gateway }
Group(stringResource(R.string.server_group_server))
{
ValueRow(stringResource(R.string.server_addr), profile.gateway + (profile.port?.let { ":$it" } ?: ""), onClick = ::edit, divider = true)
ValueRow(stringResource(R.string.server_login), stringResource(loginLabel(profile.vpnType)), onClick = ::edit)
}
Group(stringResource(R.string.server_group_resilience))
{
ValueRow(
stringResource(R.string.server_ports), extra.backupPorts.joinToString(", ").ifEmpty { stringResource(R.string.server_none) },
sub = stringResource(R.string.server_ports_sub), onClick = { showPorts = true }, divider = true,
)
ValueRow(
stringResource(R.string.server_reserve), nameOf(extra.reserveUuid) ?: stringResource(R.string.server_none),
sub = stringResource(R.string.server_reserve_sub, RESERVE_TIMEOUT_S), onClick = { picker = Picker.RESERVE }, divider = true,
)
val bridgeName = nameOf(extra.bridgeUuid)
ToggleRow(
stringResource(R.string.server_bridge),
if (bridgeName != null) stringResource(R.string.server_bridge_sub, bridgeName) else stringResource(R.string.server_bridge_pick),
extra.bridgeEnabled && bridgeName != null,
) { on ->
if (on && bridgeName == null) picker = Picker.BRIDGE
else ProfileExtras.update(context, uuid) { it.copy(bridgeEnabled = on) }
}
}
Group(stringResource(R.string.server_group_network))
{
ValueRow("DNS", profile.dnsServers?.takeIf { it.isNotBlank() }?.replace(" ", ", ") ?: stringResource(R.string.server_dns_default), onClick = ::edit, divider = true)
ValueRow("MTU", profile.mtu?.toString() ?: stringResource(R.string.server_auto_value), onClick = ::edit, divider = true)
ToggleRow(stringResource(R.string.server_fragmentation), null, extra.ikeFragmentation) { on ->
ProfileExtras.update(context, uuid) { it.copy(ikeFragmentation = on) }
}
}
Row(horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.padding(top = 4.dp))
{
ShxButton(stringResource(R.string.server_export), { export() }, style = ShxButtonStyle.OUTLINE, height = 52.dp, modifier = Modifier.weight(1f))
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.weight(1f).height(52.dp).clip(RoundedCornerShape(18.dp)).border(1.dp, c.errorBorder, RoundedCornerShape(18.dp))
.clickable(role = Role.Button) { showDelete = true },
)
{
Text(stringResource(R.string.server_delete_confirm), style = ShxType.body.copy(fontWeight = FontWeight.Bold), color = c.error)
}
}
}
}
ShxSnackbarHost(snackbar, Modifier.align(Alignment.BottomCenter).navigationBarsPadding())
}
if (showShare)
{
ShareProfileScreen(profile, onDismiss = { showShare = false }, onSendFile = { export() })
}
check?.let { found ->
ProfileUpdateSheet(
name = profile.name ?: profile.gateway,
check = found,
onLater = { check = null },
onApply = {
check = null
scope.launch {
withContext(Dispatchers.IO) { ProfileUpdater.apply(context, found) }
reloads++
snackbar.showSnackbar(updated)
}
},
)
}
if (showDelete)
{
ShxDialog(
title = stringResource(R.string.server_delete_title, profile.name ?: profile.gateway),
paragraphs = listOfNotNull(
if (active && vpn?.first != VpnStateService.State.DISABLED) stringResource(R.string.server_delete_current) else null,
stringResource(R.string.server_delete_restore),
),
confirm = stringResource(R.string.server_delete_confirm),
dismiss = stringResource(android.R.string.cancel),
onConfirm = {
showDelete = false
scope.launch {
withContext(Dispatchers.IO) {
if (active) VpnStateService.getLiveInstance()?.disconnect()
val source = VpnProfileSource(context)
source.open()
try { source.deleteVpnProfile(profile) } finally { source.close() }
}
val intent = Intent(Constants.VPN_PROFILES_CHANGED).putExtra(Constants.VPN_PROFILES_MULTIPLE, arrayOf(uuid))
LocalBroadcastManager.getInstance(context).sendBroadcast(intent)
onBack()
}
},
onDismiss = { showDelete = false },
icon = ShxIcons.Trash,
danger = true,
)
}
if (showLink)
{
TextDialog(
title = stringResource(R.string.server_link_title),
label = stringResource(R.string.server_link_label),
initial = extra.sourceValue?.takeIf { extra.source == ProfileSourceType.LINK } ?: "https://",
keyboard = KeyboardType.Uri,
validate = { it.startsWith("https://") && it.length > 10 },
onDone = { link ->
showLink = false
ProfileExtras.update(context, uuid) { it.copy(source = ProfileSourceType.LINK, sourceValue = link.trim(), autoUpdate = true) }
runCheck()
},
onDismiss = { showLink = false },
)
}
if (showPorts)
{
TextDialog(
title = stringResource(R.string.server_ports),
label = stringResource(R.string.server_ports_label),
initial = extra.backupPorts.joinToString(", "),
keyboard = KeyboardType.Number,
validate = { text -> parsePorts(text) != null },
onDone = { text ->
showPorts = false
ProfileExtras.update(context, uuid) { it.copy(backupPorts = parsePorts(text).orEmpty()) }
},
onDismiss = { showPorts = false },
)
}
picker?.let { which ->
val others = all.filter { it.getUUID().toString() != uuid }
ShxSheet(onDismiss = { picker = null })
{
ShxSheetTitle(stringResource(if (which == Picker.RESERVE) R.string.server_reserve else R.string.server_bridge_title))
Text(
stringResource(if (which == Picker.RESERVE) R.string.server_reserve_pick_note else R.string.server_bridge_pick_note),
style = ShxType.note.copy(fontWeight = FontWeight.Normal, lineHeight = 19.sp), color = c.textSoft, modifier = Modifier.padding(horizontal = 4.dp),
)
ShxPanel()
{
val current = if (which == Picker.RESERVE) extra.reserveUuid else extra.bridgeUuid
val rows = listOf<VpnProfile?>(null) + others
rows.forEachIndexed { i, other ->
val id = other?.getUUID()?.toString()
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.fillMaxWidth().heightIn(min = 56.dp).clickable(role = Role.Button) {
picker = null
ProfileExtras.update(context, uuid) {
if (which == Picker.RESERVE) it.copy(reserveUuid = id) else it.copy(bridgeUuid = id, bridgeEnabled = id != null)
}
}.padding(horizontal = 16.dp),
)
{
Text(other?.let { it.name ?: it.gateway } ?: stringResource(R.string.server_none), style = ShxType.rowTitle.copy(fontSize = 15.sp), color = c.text, modifier = Modifier.weight(1f))
if (current == id)
{
Icon(ShxIcons.Check, contentDescription = null, tint = c.accent, modifier = Modifier.size(20.dp))
}
}
if (i < rows.lastIndex)
{
Box(Modifier.fillMaxWidth().height(1.dp).background(c.tile))
}
}
}
}
}
}
/** «443, 53» → [443, 53]; null when something is not a port. */
internal fun parsePorts(text: String): List<Int>?
{
val parts = text.split(',', ' ', ';').map { it.trim() }.filter { it.isNotEmpty() }
val ports = parts.map { it.toIntOrNull()?.takeIf { p -> p in 1..65535 } ?: return null }
return ports.distinct()
}
private fun loginLabel(type: VpnType): Int = when (type)
{
VpnType.IKEV2_CERT, VpnType.IKEV2_EAP_TLS -> R.string.server_login_cert
VpnType.IKEV2_CERT_EAP -> R.string.server_login_both
else -> R.string.server_login_password
}
@Composable
private fun SourceSection(
extra: ProfileExtra,
checking: Boolean,
onEditLink: () -> Unit,
onAuto: (Boolean) -> Unit,
onCheck: () -> Unit,
onFile: () -> Unit,
onQr: () -> Unit,
)
{
val c = LocalShxColors.current
val date = if (extra.addedAt > 0) SimpleDateFormat("d MMM", Locale.getDefault()).format(Date(extra.addedAt)) else null
val type = if (extra.source == ProfileSourceType.UNKNOWN) ProfileSourceType.FILE else extra.source
ShxSectionHeader(stringResource(R.string.server_source).uppercase(), Modifier.padding(start = 6.dp, end = 6.dp, top = 6.dp))
ShxPanel()
{
val (icon, label, value) = when (type)
{
ProfileSourceType.LINK -> Triple(ShxIcons.Globe, stringResource(R.string.server_source_link), extra.sourceValue.orEmpty().removePrefix("https://"))
ProfileSourceType.QR -> Triple(ShxIcons.Scan, stringResource(R.string.server_source_qr), date?.let { stringResource(R.string.server_source_scanned, it) } ?: "")
ProfileSourceType.MANUAL -> Triple(ShxIcons.Sliders, stringResource(R.string.server_source_manual), date?.let { stringResource(R.string.server_source_created, it) } ?: "")
else -> Triple(
ShxIcons.Doc, stringResource(R.string.server_source_file),
listOfNotNull(extra.sourceValue, date?.let { stringResource(R.string.server_source_added, it) }).joinToString(" · ")
.ifEmpty { stringResource(R.string.server_source_earlier) },
)
}
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.fillMaxWidth()
.then(if (type == ProfileSourceType.LINK) Modifier.clickable(role = Role.Button, onClick = onEditLink) else Modifier)
.padding(start = 16.dp, end = 14.dp, top = 12.dp, bottom = 12.dp),
)
{
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(36.dp).clip(RoundedCornerShape(12.dp)).background(c.tile))
{
Icon(icon, contentDescription = null, tint = c.onAccentContainer, modifier = Modifier.size(18.dp))
}
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp))
{
Text(label, style = ShxType.chip, color = c.textSub)
Text(
value, maxLines = 1, overflow = TextOverflow.Ellipsis,
style = ShxType.body.copy(fontWeight = FontWeight.Bold, fontFamily = if (type == ProfileSourceType.LINK) FontFamily.Monospace else ShxType.body.fontFamily),
color = c.text,
)
}
if (type == ProfileSourceType.LINK)
{
Icon(ShxIcons.Sliders, contentDescription = stringResource(R.string.server_link_title), tint = c.textSub, modifier = Modifier.size(18.dp))
}
}
Divider()
if (type == ProfileSourceType.LINK)
{
val now = System.currentTimeMillis()
val checked = when
{
extra.checkedAt <= 0 -> null
now - extra.checkedAt < DateUtils.MINUTE_IN_MILLIS -> stringResource(R.string.server_just_now)
else -> DateUtils.getRelativeTimeSpanString(extra.checkedAt, now, DateUtils.MINUTE_IN_MILLIS).toString()
}
val result = when
{
extra.lastChanges < 0 -> stringResource(R.string.server_auto_failed)
extra.lastChanges > 0 -> stringResource(R.string.server_auto_changes, extra.lastChanges)
else -> stringResource(R.string.server_auto_same)
}
ToggleRow(
stringResource(R.string.server_auto),
listOfNotNull(stringResource(R.string.server_auto_daily), checked?.let { stringResource(R.string.server_auto_checked, it) }, if (checked != null) result else null).joinToString(" · "),
extra.autoUpdate, onAuto,
)
Divider()
}
else
{
Text(
stringResource(
when (type)
{
ProfileSourceType.QR -> R.string.server_note_qr
ProfileSourceType.MANUAL -> R.string.server_note_manual
else -> R.string.server_note_file
}
),
style = ShxType.meta.copy(fontWeight = FontWeight.Normal, lineHeight = 17.sp), color = c.textSub,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp),
)
if (type != ProfileSourceType.MANUAL)
{
Divider()
}
}
if (type != ProfileSourceType.MANUAL)
{
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.height(androidx.compose.foundation.layout.IntrinsicSize.Min).padding(horizontal = 14.dp, vertical = 12.dp))
{
when (type)
{
ProfileSourceType.LINK -> SoftButton(stringResource(if (checking) R.string.server_checking else R.string.server_check_now), onCheck, Modifier.weight(1f))
ProfileSourceType.QR -> SoftButton(stringResource(R.string.server_replace_qr), onQr, Modifier.weight(1f))
else ->
{
SoftButton(stringResource(R.string.server_replace_file), onFile, Modifier.weight(1f))
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.weight(1f).heightIn(min = 44.dp).clip(RoundedCornerShape(14.dp)).border(1.dp, c.outline, RoundedCornerShape(14.dp))
.clickable(role = Role.Button, onClick = onEditLink).padding(horizontal = 10.dp, vertical = 6.dp),
)
{
Text(stringResource(R.string.server_set_link), style = ShxType.body.copy(fontWeight = FontWeight.Bold), color = c.text)
}
}
}
}
}
}
}
@Composable
private fun SoftButton(text: String, onClick: () -> Unit, modifier: Modifier)
{
val c = LocalShxColors.current
Box(
contentAlignment = Alignment.Center,
modifier = modifier.heightIn(min = 44.dp).clip(RoundedCornerShape(14.dp)).background(c.accentMuted).clickable(role = Role.Button, onClick = onClick)
.padding(horizontal = 10.dp, vertical = 6.dp),
)
{
Text(text, style = ShxType.body.copy(fontWeight = FontWeight.ExtraBold), color = c.textTonal, maxLines = 2, textAlign = androidx.compose.ui.text.style.TextAlign.Center)
}
}
@Composable
private fun Divider()
{
Box(Modifier.fillMaxWidth().height(1.dp).background(LocalShxColors.current.tile))
}
@Composable
private fun Group(title: String, content: @Composable ColumnScope.() -> Unit)
{
Column(verticalArrangement = Arrangement.spacedBy(6.dp))
{
ShxSectionHeader(title.uppercase(), Modifier.padding(start = 6.dp, end = 6.dp, top = 6.dp))
ShxPanel(content = content)
}
}
@Composable
private fun ValueRow(title: String, value: String, sub: String? = null, onClick: () -> Unit, divider: Boolean = false)
{
val c = LocalShxColors.current
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
modifier = Modifier.fillMaxWidth().heightIn(min = 58.dp).clickable(role = Role.Button, onClick = onClick).padding(start = 16.dp, end = 14.dp, top = 8.dp, bottom = 8.dp),
)
{
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp))
{
Text(title, style = ShxType.rowTitle.copy(fontSize = 15.sp), color = c.text)
sub?.let { Text(it, style = ShxType.meta.copy(fontWeight = FontWeight.Normal, lineHeight = 16.sp), color = c.textSub) }
}
Text(value, style = ShxType.body, color = c.textSub, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(0.8f, fill = false))
Icon(ShxIcons.Chevron, contentDescription = null, tint = c.textSub, modifier = Modifier.size(18.dp))
}
if (divider)
{
Divider()
}
}
@Composable
private fun ToggleRow(title: String, sub: String?, checked: Boolean, onChange: (Boolean) -> Unit)
{
val c = LocalShxColors.current
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
modifier = Modifier.fillMaxWidth().heightIn(min = 58.dp).padding(start = 16.dp, end = 14.dp, top = 8.dp, bottom = 8.dp),
)
{
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp))
{
Text(title, style = ShxType.rowTitle.copy(fontSize = 15.sp), color = c.text)
sub?.let { Text(it, style = ShxType.meta.copy(fontWeight = FontWeight.Normal, lineHeight = 16.sp), color = c.textSub) }
}
ShxSwitch(checked, onChange)
}
}
@Composable
private fun TextDialog(
title: String,
label: String,
initial: String,
keyboard: KeyboardType,
validate: (String) -> Boolean,
onDone: (String) -> Unit,
onDismiss: () -> Unit,
)
{
var text by remember { mutableStateOf(initial) }
ShxAlertDialog(
onDismissRequest = onDismiss,
title = { Text(title) },
text = {
ShxFormField(label = label, value = text, onValueChange = { text = it }, keyboardOptions = KeyboardOptions(keyboardType = keyboard))
},
confirmButton = { TextButton(onClick = { onDone(text) }, enabled = validate(text.trim())) { Text(stringResource(R.string.server_save)) } },
dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(android.R.string.cancel)) } },
)
}
/** «The profile has changes» (F_ProfileUpdate): old → new per field, «Later» / «Apply and reconnect». */
@Composable
private fun ProfileUpdateSheet(name: String, check: ProfileCheck, onLater: () -> Unit, onApply: () -> Unit)
{
val c = LocalShxColors.current
ShxSheet(onDismiss = onLater)
{
Column(verticalArrangement = Arrangement.spacedBy(14.dp))
{
Column(verticalArrangement = Arrangement.spacedBy(4.dp), modifier = Modifier.padding(horizontal = 4.dp))
{
Text(stringResource(R.string.profile_update_title), style = ShxType.sheetTitle, color = c.text)
Text(stringResource(R.string.profile_update_sub, name), style = ShxType.body.copy(fontWeight = FontWeight.Normal), color = c.textSub)
}
ShxPanel()
{
check.changes.forEachIndexed { i, change ->
Column(verticalArrangement = Arrangement.spacedBy(4.dp), modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp))
{
Text(change.label, style = ShxType.chip, color = c.textSub)
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp))
{
val mono = ShxType.body.copy(fontWeight = FontWeight.Bold, fontFamily = FontFamily.Monospace)
Text(change.old, style = mono.copy(textDecoration = TextDecoration.LineThrough), color = c.iconMuted, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f, fill = false))
Icon(ShxIcons.Chevron, contentDescription = null, tint = c.textSub, modifier = Modifier.size(16.dp))
Text(change.new, style = mono, color = c.text, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f, fill = false))
}
}
if (i < check.changes.lastIndex)
{
Divider()
}
}
}
Row(
horizontalArrangement = Arrangement.spacedBy(10.dp),
modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(18.dp)).background(c.successContainer).padding(horizontal = 14.dp, vertical = 12.dp),
)
{
Icon(ShxIcons.Shield, contentDescription = null, tint = c.onSuccess, modifier = Modifier.size(20.dp))
Text(stringResource(R.string.profile_update_keep), style = ShxType.note.copy(lineHeight = 19.sp, fontWeight = FontWeight.Normal), color = c.onSuccess)
}
Row(horizontalArrangement = Arrangement.spacedBy(10.dp))
{
ShxButton(stringResource(R.string.cert_later), onLater, style = ShxButtonStyle.OUTLINE, modifier = Modifier.weight(1f))
ShxButton(stringResource(R.string.profile_update_apply), onApply, modifier = Modifier.weight(2f))
}
}
}
}
@@ -95,6 +95,7 @@ class VpnProfileDetailActivity : AppCompatActivity()
val profile = checked.toProfile(existing)
if (existing != null) dataSource.updateVpnProfile(profile)
else dataSource.insertProfile(profile)
if (existing == null) org.strongswan.android.logic.profiles.ProfileExtras.recordManual(this@VpnProfileDetailActivity, profile.getUUID().toString())
val intent = Intent(Constants.VPN_PROFILES_CHANGED)
intent.putExtra(Constants.VPN_PROFILES_SINGLE, profile.getUUID().toString())
LocalBroadcastManager.getInstance(this).sendBroadcast(intent)
@@ -34,6 +34,7 @@ import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;
import org.strongswan.android.R;
import org.strongswan.android.logic.profiles.ProfileExtras;
import org.strongswan.android.data.VpnProfile;
import org.strongswan.android.data.VpnProfile.SelectedAppsHandling;
import org.strongswan.android.data.VpnProfileDataSource;
@@ -668,6 +669,11 @@ public class VpnProfileImportActivity extends AppCompatActivity
e.printStackTrace();
}
}
/* remember where it came from (F_Profile «Profile source») */
ProfileExtras.recordImport(this, mProfile.getUUID().toString(),
mLastUri != null ? mLastUri.getScheme() : null,
mLastUri != null ? mLastUri.toString() : null,
mLastUri != null && mLastUri.getPath() != null && mLastUri.getPath().contains("/qr/"));
Intent intent = new Intent(Constants.VPN_PROFILES_CHANGED);
intent.putExtra(Constants.VPN_PROFILES_SINGLE, mProfile.getUUID().toString());
LocalBroadcastManager.getInstance(this).sendBroadcast(intent);
@@ -307,5 +307,6 @@ private fun EventAction.labelRes(): Int = when (this)
EventAction.OPEN_LOG -> R.string.show_log
EventAction.INSTALL_UPDATE -> R.string.events_action_update
EventAction.SET_APP_DIRECT -> R.string.events_action_direct
EventAction.PROFILE_UPDATE -> R.string.events_action_profile_update
EventAction.NONE -> R.string.events_action_open
}
@@ -70,6 +70,7 @@ fun ProfileEditorPane(
val saved = checked.toProfile(profile)
if (profile != null) dataSource.updateVpnProfile(saved)
else dataSource.insertProfile(saved)
if (profile == null) org.strongswan.android.logic.profiles.ProfileExtras.recordManual(context, saved.getUUID().toString())
val intent = Intent(Constants.VPN_PROFILES_CHANGED)
intent.putExtra(Constants.VPN_PROFILES_SINGLE, saved.getUUID().toString())
LocalBroadcastManager.getInstance(context).sendBroadcast(intent)
@@ -131,9 +131,8 @@ fun ShxApp(viewModel: HomeViewModel = viewModel())
}
}
val editProfile: (VpnProfile) -> Unit = { profile ->
val intent = Intent(context, VpnProfileDetailActivity::class.java)
intent.putExtra(VpnProfileDataSource.KEY_UUID, profile.getUUID().toString())
context.startActivity(intent)
/* F_Profile: the server page, editing goes from there */
context.startActivity(org.strongswan.android.ui.ServerActivity.intent(context, profile.getUUID().toString()))
}
var pendingShare by remember { mutableStateOf<VpnProfile?>(null) }
val shareProfile: (VpnProfile) -> Unit = { pendingShare = it }
@@ -846,6 +846,9 @@ static job_requeue_t initiate(private_android_service_t *this)
IKEV2_UDP_PORT);
ike.no_certreq = !this->settings->get_bool(this->settings,
"connection.certreq", TRUE);
/* per-profile switch of the app (F_Profile «IKE fragmentation») */
ike.fragmentation = this->settings->get_bool(this->settings,
"connection.fragmentation", TRUE) ? FRAGMENTATION_YES : FRAGMENTATION_NO;
ike_cfg = ike_cfg_create(&ike);
proposal = parse_proposal(this, PROTO_IKE, "connection.ike_proposal");
if (proposal)
@@ -1151,4 +1151,76 @@
<string name="stats_fresh_text">Через неделю здесь будет видно, сколько трафика идёт через VPN и сколько напрямую, и какие приложения тратят больше всего.</string>
<string name="home_traffic_today">сегодня</string>
<string name="widget_server_today">%1$s · %2$s сегодня</string>
<string name="profile_update_addr">Адрес сервера</string>
<string name="profile_update_port">Порт</string>
<string name="profile_update_id">Имя сервера</string>
<string name="profile_update_ca">Сертификат CA</string>
<string name="profile_update_until">до %1$s</string>
<string name="event_profile_update_title">В профиле «%1$s» есть изменения</string>
<string name="event_profile_update_text">Администратор обновил профиль по ссылке: изменений — %1$d. Примените их, чтобы подключение не пропало.</string>
<string name="events_action_profile_update">Показать изменения</string>
<string name="server_share">Поделиться</string>
<string name="server_state_connected">Подключён</string>
<string name="server_state_connected_ms">Подключён · %1$d мс</string>
<string name="server_state_connecting">Подключается…</string>
<string name="server_state_answers">Отвечает · %1$d мс</string>
<string name="server_state_no_answer">Не отвечает</string>
<string name="server_source">Источник профиля</string>
<string name="server_source_link">Ссылка</string>
<string name="server_source_file">Файл</string>
<string name="server_source_qr">QR-код</string>
<string name="server_source_manual">Вручную</string>
<string name="server_source_added">добавлен %1$s</string>
<string name="server_source_scanned">отсканирован %1$s</string>
<string name="server_source_created">создан %1$s</string>
<string name="server_source_earlier">добавлен ранее</string>
<string name="server_auto">Обновлять автоматически</string>
<string name="server_auto_daily">Раз в сутки</string>
<string name="server_auto_checked">проверено %1$s</string>
<string name="server_auto_same">изменений нет</string>
<string name="server_auto_changes">изменений: %1$d</string>
<string name="server_auto_failed">проверить не удалось</string>
<string name="server_note_file">Обновлять автоматически нечего: у файла нет ссылки. Новый файл от администратора — «Заменить из файла», настройки ниже сохранятся.</string>
<string name="server_note_qr">Обновить можно, отсканировав новый QR — настройки ниже сохранятся.</string>
<string name="server_note_manual">Адрес, вход и сертификат правятся прямо в группе «Сервер» ниже.</string>
<string name="server_check_now">Проверить сейчас</string>
<string name="server_checking">Проверяем…</string>
<string name="server_replace_file">Заменить из файла…</string>
<string name="server_replace_qr">Заменить по QR…</string>
<string name="server_set_link">Указать ссылку</string>
<string name="server_link_title">Ссылка на профиль</string>
<string name="server_link_label">https://…/profile.sswan</string>
<string name="server_save">Сохранить</string>
<string name="server_group_server">Сервер</string>
<string name="server_group_resilience">Устойчивость</string>
<string name="server_group_network">Сеть</string>
<string name="server_addr">Адрес</string>
<string name="server_login">Вход</string>
<string name="server_login_password">Логин</string>
<string name="server_login_cert">Сертификат</string>
<string name="server_login_both">Оба</string>
<string name="server_ports">Запасные порты</string>
<string name="server_ports_sub">По очереди, если основной не отвечает</string>
<string name="server_ports_label">Порты через запятую</string>
<string name="server_reserve">Резервный сервер</string>
<string name="server_reserve_sub">Если не подключился за %1$d с</string>
<string name="server_reserve_pick_note">Подключимся к нему, когда этот сервер не отвечает.</string>
<string name="server_bridge">Мост при белых списках</string>
<string name="server_bridge_title">Мост</string>
<string name="server_bridge_sub">Переключаться на «%1$s»</string>
<string name="server_bridge_pick">Выберите сервер с российским адресом</string>
<string name="server_bridge_pick_note">Сервер с российским адресом работает, даже когда оператор включает белые списки.</string>
<string name="server_none">Нет</string>
<string name="server_dns_default">Как в настройках</string>
<string name="server_auto_value">Авто</string>
<string name="server_fragmentation">Фрагментация IKE</string>
<string name="server_export">Экспорт .sswan</string>
<string name="profile_check_none">Изменений в профиле нет</string>
<string name="profile_check_failed">Не удалось проверить ссылку</string>
<string name="profile_updated">Профиль обновлён</string>
<string name="profile_update_title">В профиле есть изменения</string>
<string name="profile_update_sub">%1$s · проверено только что по ссылке</string>
<string name="profile_update_keep">Останутся без изменений: запасные порты, резервный сервер, мост, DNS, правила и приложения.</string>
<string name="profile_update_apply">Применить и переподключить</string>
<string name="server_just_now">только что</string>
</resources>
@@ -1137,4 +1137,76 @@
<string name="stats_fresh_text">In a week you will see how much traffic goes through the VPN and how much direct, and which apps use the most.</string>
<string name="home_traffic_today">today</string>
<string name="widget_server_today">%1$s · %2$s today</string>
<string name="profile_update_addr">Server address</string>
<string name="profile_update_port">Port</string>
<string name="profile_update_id">Server identity</string>
<string name="profile_update_ca">CA certificate</string>
<string name="profile_update_until">until %1$s</string>
<string name="event_profile_update_title">The «%1$s» profile has changes</string>
<string name="event_profile_update_text">The admin updated the profile at its link: %1$d changes. Apply them to keep connecting.</string>
<string name="events_action_profile_update">Show changes</string>
<string name="server_share">Share</string>
<string name="server_state_connected">Connected</string>
<string name="server_state_connected_ms">Connected · %1$d ms</string>
<string name="server_state_connecting">Connecting…</string>
<string name="server_state_answers">Answers · %1$d ms</string>
<string name="server_state_no_answer">No answer</string>
<string name="server_source">Profile source</string>
<string name="server_source_link">Link</string>
<string name="server_source_file">File</string>
<string name="server_source_qr">QR code</string>
<string name="server_source_manual">Manual</string>
<string name="server_source_added">added %1$s</string>
<string name="server_source_scanned">scanned %1$s</string>
<string name="server_source_created">created %1$s</string>
<string name="server_source_earlier">added earlier</string>
<string name="server_auto">Update automatically</string>
<string name="server_auto_daily">Once a day</string>
<string name="server_auto_checked">checked %1$s</string>
<string name="server_auto_same">no changes</string>
<string name="server_auto_changes">changes: %1$d</string>
<string name="server_auto_failed">could not check</string>
<string name="server_note_file">Nothing to update automatically: a file has no link. A new file from the admin — «Replace from file», the settings below stay.</string>
<string name="server_note_qr">Update it by scanning a new QR — the settings below stay.</string>
<string name="server_note_manual">Address, sign-in and certificate are edited right in the «Server» group below.</string>
<string name="server_check_now">Check now</string>
<string name="server_checking">Checking…</string>
<string name="server_replace_file">Replace from file…</string>
<string name="server_replace_qr">Replace by QR…</string>
<string name="server_set_link">Set a link</string>
<string name="server_link_title">Profile link</string>
<string name="server_link_label">https://…/profile.sswan</string>
<string name="server_save">Save</string>
<string name="server_group_server">Server</string>
<string name="server_group_resilience">Resilience</string>
<string name="server_group_network">Network</string>
<string name="server_addr">Address</string>
<string name="server_login">Sign-in</string>
<string name="server_login_password">Login</string>
<string name="server_login_cert">Certificate</string>
<string name="server_login_both">Both</string>
<string name="server_ports">Backup ports</string>
<string name="server_ports_sub">In turn, if the main one does not answer</string>
<string name="server_ports_label">Ports, comma separated</string>
<string name="server_reserve">Reserve server</string>
<string name="server_reserve_sub">If it does not connect in %1$d s</string>
<string name="server_reserve_pick_note">Connect to it when this server does not answer.</string>
<string name="server_bridge">Bridge for whitelists</string>
<string name="server_bridge_title">Bridge</string>
<string name="server_bridge_sub">Switch to «%1$s»</string>
<string name="server_bridge_pick">Pick a server with a Russian address</string>
<string name="server_bridge_pick_note">A server with a Russian address still works when the operator turns on whitelists.</string>
<string name="server_none">None</string>
<string name="server_dns_default">As in settings</string>
<string name="server_auto_value">Auto</string>
<string name="server_fragmentation">IKE fragmentation</string>
<string name="server_export">Export .sswan</string>
<string name="profile_check_none">No changes in the profile</string>
<string name="profile_check_failed">Could not check the link</string>
<string name="profile_updated">Profile updated</string>
<string name="profile_update_title">The profile has changes</string>
<string name="profile_update_sub">%1$s · checked just now at the link</string>
<string name="profile_update_keep">Unchanged: backup ports, reserve server, bridge, DNS, rules and apps.</string>
<string name="profile_update_apply">Apply and reconnect</string>
<string name="server_just_now">just now</string>
</resources>
@@ -0,0 +1,66 @@
/*
* 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.logic.profiles
import org.assertj.core.api.Assertions.assertThat
import org.junit.Test
import org.strongswan.android.ui.parsePorts
import java.util.Base64
class ProfileUpdaterTest
{
/* a self-signed test CA («SHX Test CA») */
private val ca = "MIIDDTCCAfWgAwIBAgIUPhMzuE77durbJE618AE1gMPK9P4wDQYJKoZIhvcNAQELBQAwFjEUMBIGA1UEAwwLU0hYIFRlc3QgQ0EwHhcNMjYxMDEyMDM1NDE0WhcNMjYxMDE5MDM1NDE0WjAWMRQwEgYDVQQDDAtTSFggVGVzdCBDQTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBANjEffIeshH+b+zh9ZxP/NFS2Q2QTPxEtJFJphkMcIfqlxFrmu4Wk7oudLSAeitxb5NpBl2C3HBS5JK5IcPAxy7sHt9ehK9AtV5bBVLLxd9co7fqn1E18ne4u/YRykn4k11mPlPt02PwjxmDyLt5zxl/Doj9sliDvvoGSV/LOo+OaBPHWu8kS5ODJNAM0CQCUVLhpBS2MEUMAnepo0YjOmruKrnAH89ubNYBnyFUxFuI2WvA9+W1tXreVrJYU0Ys4QpR8wWmizPBxUSSxbp6BNfZTBPHvxr6+GCIiU7RtT6n62y0wcmLT02AZ+hXemqN6QTiiq++o7Sewogs/1qebEMCAwEAAaNTMFEwHQYDVR0OBBYEFCtcYQI/y8ERMNq9Tlkh37Ux3MD3MB8GA1UdIwQYMBaAFCtcYQI/y8ERMNq9Tlkh37Ux3MD3MA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZIhvcNAQELBQADggEBAE0vcNErDBdCcTa38yHygXbY4V0+juPC0EcbeU2OSpUTFdgjRisP4YSj8kFS9Kp0sTzQs3l1qAqpv6HoHpYnhuwl8vXgmhaLjMJ0Tpzc2WpYcJLLsEt9sWlwy9jgU84rqYacRibqvBqcGmKzDYOuS1czdXH6vLEdjCHxBgIrGskPgxGi31ITh6f5hLY11+ormIQracsh8sdzC8i+wDCGCjN5Hh0YKh1WTHuGD85gw87eJy9+Y3ZnMNr3KgOMIaQsdBohuI6oG/I5Y6WwjSpaJrU4MtXxz6XulUUytmXnVlF7YVBSrTjpHcqkXZ4qZCS07Fgnnl216NTgq8L256FziqM="
private val cert = java.security.cert.CertificateFactory.getInstance("X.509")
.generateCertificate(java.io.ByteArrayInputStream(Base64.getMimeDecoder().decode(ca))) as java.security.cert.X509Certificate
@Test
fun sameProfileHasNoChanges()
{
val remote = RemoteProfile("nl1.example", 4500, null, null)
assertThat(diff("nl1.example", null, null, null, remote)).isEmpty()
}
@Test
fun addressPortAndCaChanges()
{
val remote = RemoteProfile("nl2.example", 443, null, cert)
val changes = diff("nl1.example", null, null, null, remote)
assertThat(changes.map { it.field }).containsExactly(ProfileField.ADDRESS, ProfileField.PORT, ProfileField.CA)
assertThat(changes[0].old).isEqualTo("nl1.example")
assertThat(changes[0].new).isEqualTo("nl2.example")
assertThat(changes[1].old).isEqualTo(4500)
assertThat(changes[1].new).isEqualTo(443)
assertThat(changes[2].old).isNull()
}
@Test
fun sameCaIsNoChange()
{
val remote = RemoteProfile("nl1.example", null, null, cert)
assertThat(diff("nl1.example", 4500, null, remote.certificate, remote)).isEmpty()
}
@Test
fun remoteIdOnlyWhenGiven()
{
val remote = RemoteProfile("nl1.example", null, "vpn.example", null)
assertThat(diff("nl1.example", null, null, null, remote).single().field).isEqualTo(ProfileField.REMOTE_ID)
}
@Test
fun portsParse()
{
assertThat(parsePorts("443, 53")).containsExactly(443, 53)
assertThat(parsePorts("")).isEmpty()
assertThat(parsePorts("443, abc")).isNull()
assertThat(parsePorts("70000")).isNull()
}
}