From e7b801d584938a7a11d7db827d819f91a4f17c85 Mon Sep 17 00:00:00 2001 From: Denozordec Date: Mon, 12 Oct 2026 15:22:53 +0700 Subject: [PATCH] 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 --- .../android/app/src/main/AndroidManifest.xml | 3 + .../android/logic/CharonVpnService.java | 1 + .../android/logic/StrongSwanApplication.java | 2 + .../android/logic/events/EventLog.kt | 2 + .../android/logic/profiles/ProfileExtras.kt | 206 +++++ .../android/logic/profiles/ProfileUpdater.kt | 292 +++++++ .../strongswan/android/ui/InboxActivity.kt | 1 + .../strongswan/android/ui/ServerActivity.kt | 781 ++++++++++++++++++ .../android/ui/VpnProfileDetailActivity.kt | 1 + .../android/ui/VpnProfileImportActivity.java | 6 + .../android/ui/compose/InboxScreen.kt | 1 + .../android/ui/compose/ProfileEditorPane.kt | 1 + .../strongswan/android/ui/compose/ShxApp.kt | 5 +- .../backend/android_service.c | 3 + .../app/src/main/res/values-ru/strings.xml | 72 ++ .../app/src/main/res/values/strings.xml | 72 ++ .../logic/profiles/ProfileUpdaterTest.kt | 66 ++ 17 files changed, 1512 insertions(+), 3 deletions(-) create mode 100644 src/frontends/android/app/src/main/java/org/strongswan/android/logic/profiles/ProfileExtras.kt create mode 100644 src/frontends/android/app/src/main/java/org/strongswan/android/logic/profiles/ProfileUpdater.kt create mode 100644 src/frontends/android/app/src/main/java/org/strongswan/android/ui/ServerActivity.kt create mode 100644 src/frontends/android/app/src/test/java/org/strongswan/android/logic/profiles/ProfileUpdaterTest.kt diff --git a/src/frontends/android/app/src/main/AndroidManifest.xml b/src/frontends/android/app/src/main/AndroidManifest.xml index 81391e181..c9e37ae66 100644 --- a/src/frontends/android/app/src/main/AndroidManifest.xml +++ b/src/frontends/android/app/src/main/AndroidManifest.xml @@ -79,6 +79,9 @@ + diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/logic/CharonVpnService.java b/src/frontends/android/app/src/main/java/org/strongswan/android/logic/CharonVpnService.java index abc592aae..62c6dc6e2 100644 --- a/src/frontends/android/app/src/main/java/org/strongswan/android/logic/CharonVpnService.java +++ b/src/frontends/android/app/src/main/java/org/strongswan/android/logic/CharonVpnService.java @@ -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()); diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/logic/StrongSwanApplication.java b/src/frontends/android/app/src/main/java/org/strongswan/android/logic/StrongSwanApplication.java index d40b96f7d..6fa5431fb 100644 --- a/src/frontends/android/app/src/main/java/org/strongswan/android/logic/StrongSwanApplication.java +++ b/src/frontends/android/app/src/main/java/org/strongswan/android/logic/StrongSwanApplication.java @@ -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() diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/logic/events/EventLog.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/logic/events/EventLog.kt index 4e94d9d5a..2b6c7e99c 100644 --- a/src/frontends/android/app/src/main/java/org/strongswan/android/logic/events/EventLog.kt +++ b/src/frontends/android/app/src/main/java/org/strongswan/android/logic/events/EventLog.kt @@ -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( diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/logic/profiles/ProfileExtras.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/logic/profiles/ProfileExtras.kt new file mode 100644 index 000000000..5fab80d02 --- /dev/null +++ b/src/frontends/android/app/src/main/java/org/strongswan/android/logic/profiles/ProfileExtras.kt @@ -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 . + * + * 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 = 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>(emptyMap()) + val all: StateFlow> = _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> + { + 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), + ) + } +} diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/logic/profiles/ProfileUpdater.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/logic/profiles/ProfileUpdater.kt new file mode 100644 index 000000000..3cad3f025 --- /dev/null +++ b/src/frontends/android/app/src/main/java/org/strongswan/android/logic/profiles/ProfileUpdater.kt @@ -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 . + * + * 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, + 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 +{ + val changes = ArrayList() + 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(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() + } +} diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/InboxActivity.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/InboxActivity.kt index e5e766909..9bcee5f20 100644 --- a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/InboxActivity.kt +++ b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/InboxActivity.kt @@ -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) diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/ServerActivity.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/ServerActivity.kt new file mode 100644 index 000000000..86b1185c4 --- /dev/null +++ b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/ServerActivity.kt @@ -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 +{ + 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?>(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?>(null) { + while (true) + { + val service = VpnStateService.getLiveInstance() + value = service?.let { it.state to it.profile?.getUUID()?.toString() } + delay(1500) + } + } + val rtt by produceState(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(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(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(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? +{ + 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)) + } + } + } +} diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/VpnProfileDetailActivity.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/VpnProfileDetailActivity.kt index 18a99e014..179088b02 100644 --- a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/VpnProfileDetailActivity.kt +++ b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/VpnProfileDetailActivity.kt @@ -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) diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/VpnProfileImportActivity.java b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/VpnProfileImportActivity.java index ea17cd040..ad4f54daa 100644 --- a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/VpnProfileImportActivity.java +++ b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/VpnProfileImportActivity.java @@ -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); diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/InboxScreen.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/InboxScreen.kt index cd7aad94f..c5b16bf4f 100644 --- a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/InboxScreen.kt +++ b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/InboxScreen.kt @@ -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 } diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/ProfileEditorPane.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/ProfileEditorPane.kt index bcbd938c8..25113b8c5 100644 --- a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/ProfileEditorPane.kt +++ b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/ProfileEditorPane.kt @@ -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) diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/ShxApp.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/ShxApp.kt index 653a2f507..269eadeb2 100644 --- a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/ShxApp.kt +++ b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/ShxApp.kt @@ -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(null) } val shareProfile: (VpnProfile) -> Unit = { pendingShare = it } diff --git a/src/frontends/android/app/src/main/jni/libandroidbridge/backend/android_service.c b/src/frontends/android/app/src/main/jni/libandroidbridge/backend/android_service.c index b7a7afbc5..c901f0657 100644 --- a/src/frontends/android/app/src/main/jni/libandroidbridge/backend/android_service.c +++ b/src/frontends/android/app/src/main/jni/libandroidbridge/backend/android_service.c @@ -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) diff --git a/src/frontends/android/app/src/main/res/values-ru/strings.xml b/src/frontends/android/app/src/main/res/values-ru/strings.xml index d4e8eaee8..b64b99cbe 100644 --- a/src/frontends/android/app/src/main/res/values-ru/strings.xml +++ b/src/frontends/android/app/src/main/res/values-ru/strings.xml @@ -1151,4 +1151,76 @@ Через неделю здесь будет видно, сколько трафика идёт через VPN и сколько напрямую, и какие приложения тратят больше всего. сегодня %1$s · %2$s сегодня + Адрес сервера + Порт + Имя сервера + Сертификат CA + до %1$s + В профиле «%1$s» есть изменения + Администратор обновил профиль по ссылке: изменений — %1$d. Примените их, чтобы подключение не пропало. + Показать изменения + Поделиться + Подключён + Подключён · %1$d мс + Подключается… + Отвечает · %1$d мс + Не отвечает + Источник профиля + Ссылка + Файл + QR-код + Вручную + добавлен %1$s + отсканирован %1$s + создан %1$s + добавлен ранее + Обновлять автоматически + Раз в сутки + проверено %1$s + изменений нет + изменений: %1$d + проверить не удалось + Обновлять автоматически нечего: у файла нет ссылки. Новый файл от администратора — «Заменить из файла», настройки ниже сохранятся. + Обновить можно, отсканировав новый QR — настройки ниже сохранятся. + Адрес, вход и сертификат правятся прямо в группе «Сервер» ниже. + Проверить сейчас + Проверяем… + Заменить из файла… + Заменить по QR… + Указать ссылку + Ссылка на профиль + https://…/profile.sswan + Сохранить + Сервер + Устойчивость + Сеть + Адрес + Вход + Логин + Сертификат + Оба + Запасные порты + По очереди, если основной не отвечает + Порты через запятую + Резервный сервер + Если не подключился за %1$d с + Подключимся к нему, когда этот сервер не отвечает. + Мост при белых списках + Мост + Переключаться на «%1$s» + Выберите сервер с российским адресом + Сервер с российским адресом работает, даже когда оператор включает белые списки. + Нет + Как в настройках + Авто + Фрагментация IKE + Экспорт .sswan + Изменений в профиле нет + Не удалось проверить ссылку + Профиль обновлён + В профиле есть изменения + %1$s · проверено только что по ссылке + Останутся без изменений: запасные порты, резервный сервер, мост, DNS, правила и приложения. + Применить и переподключить + только что diff --git a/src/frontends/android/app/src/main/res/values/strings.xml b/src/frontends/android/app/src/main/res/values/strings.xml index e9fa47784..9e1831f8b 100644 --- a/src/frontends/android/app/src/main/res/values/strings.xml +++ b/src/frontends/android/app/src/main/res/values/strings.xml @@ -1137,4 +1137,76 @@ In a week you will see how much traffic goes through the VPN and how much direct, and which apps use the most. today %1$s · %2$s today + Server address + Port + Server identity + CA certificate + until %1$s + The «%1$s» profile has changes + The admin updated the profile at its link: %1$d changes. Apply them to keep connecting. + Show changes + Share + Connected + Connected · %1$d ms + Connecting… + Answers · %1$d ms + No answer + Profile source + Link + File + QR code + Manual + added %1$s + scanned %1$s + created %1$s + added earlier + Update automatically + Once a day + checked %1$s + no changes + changes: %1$d + could not check + Nothing to update automatically: a file has no link. A new file from the admin — «Replace from file», the settings below stay. + Update it by scanning a new QR — the settings below stay. + Address, sign-in and certificate are edited right in the «Server» group below. + Check now + Checking… + Replace from file… + Replace by QR… + Set a link + Profile link + https://…/profile.sswan + Save + Server + Resilience + Network + Address + Sign-in + Login + Certificate + Both + Backup ports + In turn, if the main one does not answer + Ports, comma separated + Reserve server + If it does not connect in %1$d s + Connect to it when this server does not answer. + Bridge for whitelists + Bridge + Switch to «%1$s» + Pick a server with a Russian address + A server with a Russian address still works when the operator turns on whitelists. + None + As in settings + Auto + IKE fragmentation + Export .sswan + No changes in the profile + Could not check the link + Profile updated + The profile has changes + %1$s · checked just now at the link + Unchanged: backup ports, reserve server, bridge, DNS, rules and apps. + Apply and reconnect + just now diff --git a/src/frontends/android/app/src/test/java/org/strongswan/android/logic/profiles/ProfileUpdaterTest.kt b/src/frontends/android/app/src/test/java/org/strongswan/android/logic/profiles/ProfileUpdaterTest.kt new file mode 100644 index 000000000..524006e8b --- /dev/null +++ b/src/frontends/android/app/src/test/java/org/strongswan/android/logic/profiles/ProfileUpdaterTest.kt @@ -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() + } +}