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:
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" />
|
<action android:name="android.service.quicksettings.action.QS_TILE_PREFERENCES" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
|
<activity
|
||||||
|
android:name=".ui.ServerActivity"
|
||||||
|
android:exported="false" />
|
||||||
<activity
|
<activity
|
||||||
android:name=".ui.StatsActivity"
|
android:name=".ui.StatsActivity"
|
||||||
android:exported="false" />
|
android:exported="false" />
|
||||||
|
|||||||
+1
@@ -561,6 +561,7 @@ public class CharonVpnService extends VpnService implements Runnable, VpnStateSe
|
|||||||
writer.setValue("connection.local_id", mCurrentProfile.getLocalId());
|
writer.setValue("connection.local_id", mCurrentProfile.getLocalId());
|
||||||
writer.setValue("connection.remote_id", mCurrentProfile.getRemoteId());
|
writer.setValue("connection.remote_id", mCurrentProfile.getRemoteId());
|
||||||
writer.setValue("connection.certreq", (mCurrentProfile.getFlags() & VpnProfile.FLAGS_SUPPRESS_CERT_REQS) == 0);
|
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.strict_revocation", (mCurrentProfile.getFlags() & VpnProfile.FLAGS_STRICT_REVOCATION) != 0);
|
||||||
writer.setValue("connection.ike_proposal", mCurrentProfile.getIkeProposal());
|
writer.setValue("connection.ike_proposal", mCurrentProfile.getIkeProposal());
|
||||||
writer.setValue("connection.esp_proposal", mCurrentProfile.getEspProposal());
|
writer.setValue("connection.esp_proposal", mCurrentProfile.getEspProposal());
|
||||||
|
|||||||
+2
@@ -139,6 +139,8 @@ public class StrongSwanApplication extends Application
|
|||||||
|
|
||||||
/* checks the update storage for a newer version of the app (if enabled and due) */
|
/* checks the update storage for a newer version of the app (if enabled and due) */
|
||||||
UpdateManager.getInstance(this).start();
|
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()
|
private void reloadManagedConfigurationAndNotifyListeners()
|
||||||
|
|||||||
+2
@@ -66,6 +66,8 @@ enum class EventAction
|
|||||||
INSTALL_UPDATE,
|
INSTALL_UPDATE,
|
||||||
/** let the app in [ShxEvent.target] bypass the VPN */
|
/** let the app in [ShxEvent.target] bypass the VPN */
|
||||||
SET_APP_DIRECT,
|
SET_APP_DIRECT,
|
||||||
|
/** show the link update of the profile [ShxEvent.target] (F_ProfileUpdate) */
|
||||||
|
PROFILE_UPDATE,
|
||||||
}
|
}
|
||||||
|
|
||||||
data class ShxEvent(
|
data class ShxEvent(
|
||||||
|
|||||||
+206
@@ -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),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
+292
@@ -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.OPEN_ROUTING -> startActivity(main.putExtra(MainActivity.EXTRA_OPEN_ROUTING, true))
|
||||||
EventAction.INSTALL_UPDATE -> startActivity(main.putExtra(MainActivity.EXTRA_INSTALL_UPDATE, true))
|
EventAction.INSTALL_UPDATE -> startActivity(main.putExtra(MainActivity.EXTRA_INSTALL_UPDATE, true))
|
||||||
EventAction.OPEN_ROUTE_CHECK -> startActivity(Intent(this, RouteCheckActivity::class.java))
|
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))
|
EventAction.OPEN_LOG -> startActivity(Intent(this, LogActivity::class.java))
|
||||||
}
|
}
|
||||||
if (action == EventAction.OPEN_HOME || action == EventAction.OPEN_ROUTING || action == EventAction.INSTALL_UPDATE)
|
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
@@ -95,6 +95,7 @@ class VpnProfileDetailActivity : AppCompatActivity()
|
|||||||
val profile = checked.toProfile(existing)
|
val profile = checked.toProfile(existing)
|
||||||
if (existing != null) dataSource.updateVpnProfile(profile)
|
if (existing != null) dataSource.updateVpnProfile(profile)
|
||||||
else dataSource.insertProfile(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)
|
val intent = Intent(Constants.VPN_PROFILES_CHANGED)
|
||||||
intent.putExtra(Constants.VPN_PROFILES_SINGLE, profile.getUUID().toString())
|
intent.putExtra(Constants.VPN_PROFILES_SINGLE, profile.getUUID().toString())
|
||||||
LocalBroadcastManager.getInstance(this).sendBroadcast(intent)
|
LocalBroadcastManager.getInstance(this).sendBroadcast(intent)
|
||||||
|
|||||||
+6
@@ -34,6 +34,7 @@ import org.json.JSONArray;
|
|||||||
import org.json.JSONException;
|
import org.json.JSONException;
|
||||||
import org.json.JSONObject;
|
import org.json.JSONObject;
|
||||||
import org.strongswan.android.R;
|
import org.strongswan.android.R;
|
||||||
|
import org.strongswan.android.logic.profiles.ProfileExtras;
|
||||||
import org.strongswan.android.data.VpnProfile;
|
import org.strongswan.android.data.VpnProfile;
|
||||||
import org.strongswan.android.data.VpnProfile.SelectedAppsHandling;
|
import org.strongswan.android.data.VpnProfile.SelectedAppsHandling;
|
||||||
import org.strongswan.android.data.VpnProfileDataSource;
|
import org.strongswan.android.data.VpnProfileDataSource;
|
||||||
@@ -668,6 +669,11 @@ public class VpnProfileImportActivity extends AppCompatActivity
|
|||||||
e.printStackTrace();
|
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 intent = new Intent(Constants.VPN_PROFILES_CHANGED);
|
||||||
intent.putExtra(Constants.VPN_PROFILES_SINGLE, mProfile.getUUID().toString());
|
intent.putExtra(Constants.VPN_PROFILES_SINGLE, mProfile.getUUID().toString());
|
||||||
LocalBroadcastManager.getInstance(this).sendBroadcast(intent);
|
LocalBroadcastManager.getInstance(this).sendBroadcast(intent);
|
||||||
|
|||||||
+1
@@ -307,5 +307,6 @@ private fun EventAction.labelRes(): Int = when (this)
|
|||||||
EventAction.OPEN_LOG -> R.string.show_log
|
EventAction.OPEN_LOG -> R.string.show_log
|
||||||
EventAction.INSTALL_UPDATE -> R.string.events_action_update
|
EventAction.INSTALL_UPDATE -> R.string.events_action_update
|
||||||
EventAction.SET_APP_DIRECT -> R.string.events_action_direct
|
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
|
EventAction.NONE -> R.string.events_action_open
|
||||||
}
|
}
|
||||||
+1
@@ -70,6 +70,7 @@ fun ProfileEditorPane(
|
|||||||
val saved = checked.toProfile(profile)
|
val saved = checked.toProfile(profile)
|
||||||
if (profile != null) dataSource.updateVpnProfile(saved)
|
if (profile != null) dataSource.updateVpnProfile(saved)
|
||||||
else dataSource.insertProfile(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)
|
val intent = Intent(Constants.VPN_PROFILES_CHANGED)
|
||||||
intent.putExtra(Constants.VPN_PROFILES_SINGLE, saved.getUUID().toString())
|
intent.putExtra(Constants.VPN_PROFILES_SINGLE, saved.getUUID().toString())
|
||||||
LocalBroadcastManager.getInstance(context).sendBroadcast(intent)
|
LocalBroadcastManager.getInstance(context).sendBroadcast(intent)
|
||||||
|
|||||||
@@ -131,9 +131,8 @@ fun ShxApp(viewModel: HomeViewModel = viewModel())
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
val editProfile: (VpnProfile) -> Unit = { profile ->
|
val editProfile: (VpnProfile) -> Unit = { profile ->
|
||||||
val intent = Intent(context, VpnProfileDetailActivity::class.java)
|
/* F_Profile: the server page, editing goes from there */
|
||||||
intent.putExtra(VpnProfileDataSource.KEY_UUID, profile.getUUID().toString())
|
context.startActivity(org.strongswan.android.ui.ServerActivity.intent(context, profile.getUUID().toString()))
|
||||||
context.startActivity(intent)
|
|
||||||
}
|
}
|
||||||
var pendingShare by remember { mutableStateOf<VpnProfile?>(null) }
|
var pendingShare by remember { mutableStateOf<VpnProfile?>(null) }
|
||||||
val shareProfile: (VpnProfile) -> Unit = { pendingShare = it }
|
val shareProfile: (VpnProfile) -> Unit = { pendingShare = it }
|
||||||
|
|||||||
@@ -846,6 +846,9 @@ static job_requeue_t initiate(private_android_service_t *this)
|
|||||||
IKEV2_UDP_PORT);
|
IKEV2_UDP_PORT);
|
||||||
ike.no_certreq = !this->settings->get_bool(this->settings,
|
ike.no_certreq = !this->settings->get_bool(this->settings,
|
||||||
"connection.certreq", TRUE);
|
"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);
|
ike_cfg = ike_cfg_create(&ike);
|
||||||
proposal = parse_proposal(this, PROTO_IKE, "connection.ike_proposal");
|
proposal = parse_proposal(this, PROTO_IKE, "connection.ike_proposal");
|
||||||
if (proposal)
|
if (proposal)
|
||||||
|
|||||||
@@ -1151,4 +1151,76 @@
|
|||||||
<string name="stats_fresh_text">Через неделю здесь будет видно, сколько трафика идёт через VPN и сколько напрямую, и какие приложения тратят больше всего.</string>
|
<string name="stats_fresh_text">Через неделю здесь будет видно, сколько трафика идёт через VPN и сколько напрямую, и какие приложения тратят больше всего.</string>
|
||||||
<string name="home_traffic_today">сегодня</string>
|
<string name="home_traffic_today">сегодня</string>
|
||||||
<string name="widget_server_today">%1$s · %2$s сегодня</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>
|
</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="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="home_traffic_today">today</string>
|
||||||
<string name="widget_server_today">%1$s · %2$s 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>
|
</resources>
|
||||||
+66
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user