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 <noreply@anthropic.com>
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" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<activity
|
||||
android:name=".ui.ServerActivity"
|
||||
android:exported="false" />
|
||||
<activity
|
||||
android:name=".ui.StatsActivity"
|
||||
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.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());
|
||||
|
||||
+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) */
|
||||
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()
|
||||
|
||||
+2
@@ -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(
|
||||
|
||||
+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.INSTALL_UPDATE -> startActivity(main.putExtra(MainActivity.EXTRA_INSTALL_UPDATE, true))
|
||||
EventAction.OPEN_ROUTE_CHECK -> startActivity(Intent(this, RouteCheckActivity::class.java))
|
||||
EventAction.PROFILE_UPDATE -> event.target?.let { startActivity(ServerActivity.intent(this, it, checkUpdate = true)) }
|
||||
EventAction.OPEN_LOG -> startActivity(Intent(this, LogActivity::class.java))
|
||||
}
|
||||
if (action == EventAction.OPEN_HOME || action == EventAction.OPEN_ROUTING || action == EventAction.INSTALL_UPDATE)
|
||||
|
||||
@@ -0,0 +1,781 @@
|
||||
/*
|
||||
* Copyright (C) 2026 SHX VPN
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or modify it
|
||||
* under the terms of the GNU General Public License as published by the
|
||||
* Free Software Foundation; either version 2 of the License, or (at your
|
||||
* option) any later version.
|
||||
*/
|
||||
|
||||
package org.strongswan.android.ui
|
||||
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.text.format.DateUtils
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.statusBarsPadding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.localbroadcastmanager.content.LocalBroadcastManager
|
||||
import com.journeyapps.barcodescanner.ScanContract
|
||||
import com.journeyapps.barcodescanner.ScanOptions
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.strongswan.android.R
|
||||
import org.strongswan.android.data.VpnProfile
|
||||
import org.strongswan.android.data.VpnProfileDataSource
|
||||
import org.strongswan.android.data.VpnProfileSource
|
||||
import org.strongswan.android.data.VpnType
|
||||
import org.strongswan.android.logic.VpnStateService
|
||||
import org.strongswan.android.logic.connectivity.ConnectivityProbe
|
||||
import org.strongswan.android.logic.profiles.ProfileCheck
|
||||
import org.strongswan.android.logic.profiles.ProfileExtra
|
||||
import org.strongswan.android.logic.profiles.ProfileExtras
|
||||
import org.strongswan.android.logic.profiles.ProfileSourceType
|
||||
import org.strongswan.android.logic.profiles.ProfileUpdater
|
||||
import org.strongswan.android.logic.profiles.RESERVE_TIMEOUT_S
|
||||
import org.strongswan.android.ui.compose.ProfileQr
|
||||
import org.strongswan.android.ui.compose.ShareProfileScreen
|
||||
import org.strongswan.android.ui.compose.components.ShxSnackbarHost
|
||||
import org.strongswan.android.ui.compose.design.ShxAlertDialog
|
||||
import org.strongswan.android.ui.compose.design.ShxButton
|
||||
import org.strongswan.android.ui.compose.design.ShxButtonStyle
|
||||
import org.strongswan.android.ui.compose.design.ShxDialog
|
||||
import org.strongswan.android.ui.compose.design.ShxFormField
|
||||
import org.strongswan.android.ui.compose.design.ShxIcons
|
||||
import org.strongswan.android.ui.compose.design.ShxPanel
|
||||
import org.strongswan.android.ui.compose.design.ShxSectionHeader
|
||||
import org.strongswan.android.ui.compose.design.ShxSheet
|
||||
import org.strongswan.android.ui.compose.design.ShxSheetTitle
|
||||
import org.strongswan.android.ui.compose.design.ShxSwitch
|
||||
import org.strongswan.android.ui.compose.home.serverCode
|
||||
import org.strongswan.android.ui.compose.theme.LocalShxColors
|
||||
import org.strongswan.android.ui.compose.theme.ShxTheme
|
||||
import org.strongswan.android.ui.compose.theme.ShxType
|
||||
import org.strongswan.android.utils.Constants
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import java.util.UUID
|
||||
|
||||
/** One server (F_Profile): source and updates, server, resilience and network settings. */
|
||||
class ServerActivity : AppCompatActivity()
|
||||
{
|
||||
override fun onCreate(savedInstanceState: Bundle?)
|
||||
{
|
||||
super.onCreate(savedInstanceState)
|
||||
enableEdgeToEdge()
|
||||
val uuid = intent.getStringExtra(VpnProfileDataSource.KEY_UUID)
|
||||
if (uuid == null)
|
||||
{
|
||||
finish()
|
||||
return
|
||||
}
|
||||
val checkNow = intent.getBooleanExtra(EXTRA_CHECK_UPDATE, false)
|
||||
setContent {
|
||||
ShxTheme {
|
||||
ServerScreen(uuid, checkNow, onBack = { finish() })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object
|
||||
{
|
||||
private const val EXTRA_CHECK_UPDATE = "org.strongswan.android.extra.CHECK_UPDATE"
|
||||
|
||||
@JvmStatic
|
||||
@JvmOverloads
|
||||
fun intent(context: Context, uuid: String, checkUpdate: Boolean = false): Intent =
|
||||
Intent(context, ServerActivity::class.java)
|
||||
.putExtra(VpnProfileDataSource.KEY_UUID, uuid)
|
||||
.putExtra(EXTRA_CHECK_UPDATE, checkUpdate)
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadProfiles(context: Context): List<VpnProfile>
|
||||
{
|
||||
val source = VpnProfileSource(context)
|
||||
source.open()
|
||||
try
|
||||
{
|
||||
return source.allVpnProfiles
|
||||
}
|
||||
finally
|
||||
{
|
||||
source.close()
|
||||
}
|
||||
}
|
||||
|
||||
/* which picker sheet is open */
|
||||
private enum class Picker { RESERVE, BRIDGE }
|
||||
|
||||
@Composable
|
||||
private fun ServerScreen(uuid: String, checkOnOpen: Boolean, onBack: () -> Unit)
|
||||
{
|
||||
val c = LocalShxColors.current
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val snackbar = remember { SnackbarHostState() }
|
||||
var reloads by remember { mutableIntStateOf(0) }
|
||||
val owner = LocalLifecycleOwner.current
|
||||
DisposableEffect(owner) {
|
||||
val observer = LifecycleEventObserver { _, event -> if (event == Lifecycle.Event.ON_RESUME) reloads++ }
|
||||
owner.lifecycle.addObserver(observer)
|
||||
onDispose { owner.lifecycle.removeObserver(observer) }
|
||||
}
|
||||
val profiles by produceState<List<VpnProfile>?>(null, reloads) { value = withContext(Dispatchers.IO) { loadProfiles(context) } }
|
||||
val all = profiles ?: return
|
||||
val profile = all.firstOrNull { it.getUUID().toString() == uuid }
|
||||
if (profile == null)
|
||||
{
|
||||
LaunchedEffect(Unit) { onBack() }
|
||||
return
|
||||
}
|
||||
val extras by remember { ProfileExtras.flow(context) }.collectAsStateWithLifecycle()
|
||||
val extra = extras[uuid] ?: ProfileExtra(uuid)
|
||||
/* the live state of the tunnel and a direct ping of the server */
|
||||
val vpn by produceState<Pair<VpnStateService.State, String?>?>(null) {
|
||||
while (true)
|
||||
{
|
||||
val service = VpnStateService.getLiveInstance()
|
||||
value = service?.let { it.state to it.profile?.getUUID()?.toString() }
|
||||
delay(1500)
|
||||
}
|
||||
}
|
||||
val rtt by produceState<Long?>(null, profile.gateway) {
|
||||
while (true)
|
||||
{
|
||||
value = withContext(Dispatchers.IO) { ConnectivityProbe.pingServerDirect(context, profile.gateway, 1500)?.first }
|
||||
delay(30_000)
|
||||
}
|
||||
}
|
||||
val active = vpn?.second == uuid
|
||||
val connected = active && vpn?.first == VpnStateService.State.CONNECTED
|
||||
|
||||
var checking by remember { mutableStateOf(false) }
|
||||
var check by remember { mutableStateOf<ProfileCheck?>(null) }
|
||||
var showShare by remember { mutableStateOf(false) }
|
||||
var showDelete by remember { mutableStateOf(false) }
|
||||
var showLink by remember { mutableStateOf(false) }
|
||||
var showPorts by remember { mutableStateOf(false) }
|
||||
var picker by remember { mutableStateOf<Picker?>(null) }
|
||||
val noChanges = stringResource(R.string.profile_check_none)
|
||||
val failed = stringResource(R.string.profile_check_failed)
|
||||
val updated = stringResource(R.string.profile_updated)
|
||||
|
||||
fun runCheck()
|
||||
{
|
||||
if (checking) return
|
||||
checking = true
|
||||
scope.launch {
|
||||
val result = withContext(Dispatchers.IO) { runCatching { ProfileUpdater.check(context, uuid) } }
|
||||
checking = false
|
||||
result.onSuccess { if (it.changes.isEmpty()) snackbar.showSnackbar(noChanges) else check = it }
|
||||
result.onFailure {
|
||||
ProfileExtras.update(context, uuid) { e -> e.copy(checkedAt = System.currentTimeMillis(), lastChanges = -1) }
|
||||
snackbar.showSnackbar(failed)
|
||||
}
|
||||
}
|
||||
}
|
||||
LaunchedEffect(checkOnOpen) { if (checkOnOpen && extra.source == ProfileSourceType.LINK) runCheck() }
|
||||
|
||||
fun edit() = context.startActivity(Intent(context, VpnProfileDetailActivity::class.java).putExtra(VpnProfileDataSource.KEY_UUID, uuid))
|
||||
fun export() = scope.launch {
|
||||
val file = withContext(Dispatchers.IO) { org.strongswan.android.logic.VpnProfileExporter.exportProfile(context, profile, false) }
|
||||
if (file == null)
|
||||
{
|
||||
snackbar.showSnackbar(context.getString(R.string.share_profile_failed))
|
||||
return@launch
|
||||
}
|
||||
val send = Intent(Intent.ACTION_SEND).setType(org.strongswan.android.data.ProfileExportProvider.MIME_TYPE).putExtra(Intent.EXTRA_STREAM, file)
|
||||
try
|
||||
{
|
||||
context.startActivity(Intent.createChooser(send, context.getString(R.string.share_profile)))
|
||||
}
|
||||
catch (_: ActivityNotFoundException)
|
||||
{
|
||||
snackbar.showSnackbar(context.getString(R.string.share_profile_failed))
|
||||
}
|
||||
}
|
||||
val scanner = rememberLauncherForActivityResult(ScanContract()) { result ->
|
||||
result.contents?.let { text -> ProfileQr.importIntent(context, text)?.let { context.startActivity(it) } }
|
||||
}
|
||||
|
||||
Box(Modifier.fillMaxSize().background(c.background))
|
||||
{
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
modifier = Modifier.fillMaxSize().statusBarsPadding().verticalScroll(rememberScrollState()).navigationBarsPadding()
|
||||
.padding(bottom = 24.dp),
|
||||
)
|
||||
{
|
||||
/* header: back, «Share» pill */
|
||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().padding(start = 12.dp, end = 12.dp, top = 16.dp, bottom = 8.dp))
|
||||
{
|
||||
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(44.dp).clip(CircleShape).clickable(role = Role.Button, onClick = onBack))
|
||||
{
|
||||
Icon(ShxIcons.Back, contentDescription = stringResource(R.string.back), tint = c.text, modifier = Modifier.size(22.dp))
|
||||
}
|
||||
Spacer(Modifier.weight(1f))
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier.height(40.dp).clip(RoundedCornerShape(20.dp)).background(c.accentContainer)
|
||||
.clickable(role = Role.Button) { showShare = true }.padding(horizontal = 14.dp),
|
||||
)
|
||||
{
|
||||
Icon(ShxIcons.Scan, contentDescription = null, tint = c.onAccentContainer, modifier = Modifier.size(18.dp))
|
||||
Text(stringResource(R.string.server_share), style = ShxType.chip.copy(fontWeight = FontWeight.ExtraBold), color = c.onAccentContainer)
|
||||
}
|
||||
}
|
||||
Column(Modifier.padding(horizontal = 16.dp), verticalArrangement = Arrangement.spacedBy(12.dp))
|
||||
{
|
||||
/* who: avatar, name, state */
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(14.dp), modifier = Modifier.padding(start = 4.dp, end = 4.dp, bottom = 6.dp))
|
||||
{
|
||||
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(56.dp).clip(CircleShape).background(c.accent))
|
||||
{
|
||||
Text(serverCode(profile, null), style = ShxType.cardHeading.copy(fontSize = 18.sp), color = Color.White)
|
||||
}
|
||||
Column(verticalArrangement = Arrangement.spacedBy(3.dp))
|
||||
{
|
||||
Text(profile.name ?: profile.gateway, style = ShxType.screenTitle, color = c.text)
|
||||
val (dot, textColor, label) = when
|
||||
{
|
||||
connected -> Triple(c.success, c.onSuccess, rtt?.let { stringResource(R.string.server_state_connected_ms, it.toInt()) } ?: stringResource(R.string.server_state_connected))
|
||||
active && vpn?.first == VpnStateService.State.CONNECTING -> Triple(c.accent, c.accentText, stringResource(R.string.server_state_connecting))
|
||||
rtt != null -> Triple(c.textSub, c.textSub, stringResource(R.string.server_state_answers, rtt!!.toInt()))
|
||||
else -> Triple(c.warning, c.onWarningContainer, stringResource(R.string.server_state_no_answer))
|
||||
}
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp))
|
||||
{
|
||||
Box(Modifier.size(8.dp).clip(CircleShape).background(dot))
|
||||
Text(label, style = ShxType.chip, color = textColor)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SourceSection(
|
||||
extra = extra,
|
||||
checking = checking,
|
||||
onEditLink = { showLink = true },
|
||||
onAuto = { on -> ProfileExtras.update(context, uuid) { it.copy(autoUpdate = on) } },
|
||||
onCheck = ::runCheck,
|
||||
onFile = { context.startActivity(Intent(context, VpnProfileImportActivity::class.java)) },
|
||||
onQr = { scanner.launch(ScanOptions().setDesiredBarcodeFormats(ScanOptions.QR_CODE).setBeepEnabled(false).setOrientationLocked(true).setPrompt("")) },
|
||||
)
|
||||
|
||||
val others = all.filter { it.getUUID().toString() != uuid }
|
||||
fun nameOf(other: String?) = others.firstOrNull { it.getUUID().toString() == other }?.let { it.name ?: it.gateway }
|
||||
Group(stringResource(R.string.server_group_server))
|
||||
{
|
||||
ValueRow(stringResource(R.string.server_addr), profile.gateway + (profile.port?.let { ":$it" } ?: ""), onClick = ::edit, divider = true)
|
||||
ValueRow(stringResource(R.string.server_login), stringResource(loginLabel(profile.vpnType)), onClick = ::edit)
|
||||
}
|
||||
Group(stringResource(R.string.server_group_resilience))
|
||||
{
|
||||
ValueRow(
|
||||
stringResource(R.string.server_ports), extra.backupPorts.joinToString(", ").ifEmpty { stringResource(R.string.server_none) },
|
||||
sub = stringResource(R.string.server_ports_sub), onClick = { showPorts = true }, divider = true,
|
||||
)
|
||||
ValueRow(
|
||||
stringResource(R.string.server_reserve), nameOf(extra.reserveUuid) ?: stringResource(R.string.server_none),
|
||||
sub = stringResource(R.string.server_reserve_sub, RESERVE_TIMEOUT_S), onClick = { picker = Picker.RESERVE }, divider = true,
|
||||
)
|
||||
val bridgeName = nameOf(extra.bridgeUuid)
|
||||
ToggleRow(
|
||||
stringResource(R.string.server_bridge),
|
||||
if (bridgeName != null) stringResource(R.string.server_bridge_sub, bridgeName) else stringResource(R.string.server_bridge_pick),
|
||||
extra.bridgeEnabled && bridgeName != null,
|
||||
) { on ->
|
||||
if (on && bridgeName == null) picker = Picker.BRIDGE
|
||||
else ProfileExtras.update(context, uuid) { it.copy(bridgeEnabled = on) }
|
||||
}
|
||||
}
|
||||
Group(stringResource(R.string.server_group_network))
|
||||
{
|
||||
ValueRow("DNS", profile.dnsServers?.takeIf { it.isNotBlank() }?.replace(" ", ", ") ?: stringResource(R.string.server_dns_default), onClick = ::edit, divider = true)
|
||||
ValueRow("MTU", profile.mtu?.toString() ?: stringResource(R.string.server_auto_value), onClick = ::edit, divider = true)
|
||||
ToggleRow(stringResource(R.string.server_fragmentation), null, extra.ikeFragmentation) { on ->
|
||||
ProfileExtras.update(context, uuid) { it.copy(ikeFragmentation = on) }
|
||||
}
|
||||
}
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.padding(top = 4.dp))
|
||||
{
|
||||
ShxButton(stringResource(R.string.server_export), { export() }, style = ShxButtonStyle.OUTLINE, height = 52.dp, modifier = Modifier.weight(1f))
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier.weight(1f).height(52.dp).clip(RoundedCornerShape(18.dp)).border(1.dp, c.errorBorder, RoundedCornerShape(18.dp))
|
||||
.clickable(role = Role.Button) { showDelete = true },
|
||||
)
|
||||
{
|
||||
Text(stringResource(R.string.server_delete_confirm), style = ShxType.body.copy(fontWeight = FontWeight.Bold), color = c.error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
ShxSnackbarHost(snackbar, Modifier.align(Alignment.BottomCenter).navigationBarsPadding())
|
||||
}
|
||||
|
||||
if (showShare)
|
||||
{
|
||||
ShareProfileScreen(profile, onDismiss = { showShare = false }, onSendFile = { export() })
|
||||
}
|
||||
check?.let { found ->
|
||||
ProfileUpdateSheet(
|
||||
name = profile.name ?: profile.gateway,
|
||||
check = found,
|
||||
onLater = { check = null },
|
||||
onApply = {
|
||||
check = null
|
||||
scope.launch {
|
||||
withContext(Dispatchers.IO) { ProfileUpdater.apply(context, found) }
|
||||
reloads++
|
||||
snackbar.showSnackbar(updated)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
if (showDelete)
|
||||
{
|
||||
ShxDialog(
|
||||
title = stringResource(R.string.server_delete_title, profile.name ?: profile.gateway),
|
||||
paragraphs = listOfNotNull(
|
||||
if (active && vpn?.first != VpnStateService.State.DISABLED) stringResource(R.string.server_delete_current) else null,
|
||||
stringResource(R.string.server_delete_restore),
|
||||
),
|
||||
confirm = stringResource(R.string.server_delete_confirm),
|
||||
dismiss = stringResource(android.R.string.cancel),
|
||||
onConfirm = {
|
||||
showDelete = false
|
||||
scope.launch {
|
||||
withContext(Dispatchers.IO) {
|
||||
if (active) VpnStateService.getLiveInstance()?.disconnect()
|
||||
val source = VpnProfileSource(context)
|
||||
source.open()
|
||||
try { source.deleteVpnProfile(profile) } finally { source.close() }
|
||||
}
|
||||
val intent = Intent(Constants.VPN_PROFILES_CHANGED).putExtra(Constants.VPN_PROFILES_MULTIPLE, arrayOf(uuid))
|
||||
LocalBroadcastManager.getInstance(context).sendBroadcast(intent)
|
||||
onBack()
|
||||
}
|
||||
},
|
||||
onDismiss = { showDelete = false },
|
||||
icon = ShxIcons.Trash,
|
||||
danger = true,
|
||||
)
|
||||
}
|
||||
if (showLink)
|
||||
{
|
||||
TextDialog(
|
||||
title = stringResource(R.string.server_link_title),
|
||||
label = stringResource(R.string.server_link_label),
|
||||
initial = extra.sourceValue?.takeIf { extra.source == ProfileSourceType.LINK } ?: "https://",
|
||||
keyboard = KeyboardType.Uri,
|
||||
validate = { it.startsWith("https://") && it.length > 10 },
|
||||
onDone = { link ->
|
||||
showLink = false
|
||||
ProfileExtras.update(context, uuid) { it.copy(source = ProfileSourceType.LINK, sourceValue = link.trim(), autoUpdate = true) }
|
||||
runCheck()
|
||||
},
|
||||
onDismiss = { showLink = false },
|
||||
)
|
||||
}
|
||||
if (showPorts)
|
||||
{
|
||||
TextDialog(
|
||||
title = stringResource(R.string.server_ports),
|
||||
label = stringResource(R.string.server_ports_label),
|
||||
initial = extra.backupPorts.joinToString(", "),
|
||||
keyboard = KeyboardType.Number,
|
||||
validate = { text -> parsePorts(text) != null },
|
||||
onDone = { text ->
|
||||
showPorts = false
|
||||
ProfileExtras.update(context, uuid) { it.copy(backupPorts = parsePorts(text).orEmpty()) }
|
||||
},
|
||||
onDismiss = { showPorts = false },
|
||||
)
|
||||
}
|
||||
picker?.let { which ->
|
||||
val others = all.filter { it.getUUID().toString() != uuid }
|
||||
ShxSheet(onDismiss = { picker = null })
|
||||
{
|
||||
ShxSheetTitle(stringResource(if (which == Picker.RESERVE) R.string.server_reserve else R.string.server_bridge_title))
|
||||
Text(
|
||||
stringResource(if (which == Picker.RESERVE) R.string.server_reserve_pick_note else R.string.server_bridge_pick_note),
|
||||
style = ShxType.note.copy(fontWeight = FontWeight.Normal, lineHeight = 19.sp), color = c.textSoft, modifier = Modifier.padding(horizontal = 4.dp),
|
||||
)
|
||||
ShxPanel()
|
||||
{
|
||||
val current = if (which == Picker.RESERVE) extra.reserveUuid else extra.bridgeUuid
|
||||
val rows = listOf<VpnProfile?>(null) + others
|
||||
rows.forEachIndexed { i, other ->
|
||||
val id = other?.getUUID()?.toString()
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
modifier = Modifier.fillMaxWidth().heightIn(min = 56.dp).clickable(role = Role.Button) {
|
||||
picker = null
|
||||
ProfileExtras.update(context, uuid) {
|
||||
if (which == Picker.RESERVE) it.copy(reserveUuid = id) else it.copy(bridgeUuid = id, bridgeEnabled = id != null)
|
||||
}
|
||||
}.padding(horizontal = 16.dp),
|
||||
)
|
||||
{
|
||||
Text(other?.let { it.name ?: it.gateway } ?: stringResource(R.string.server_none), style = ShxType.rowTitle.copy(fontSize = 15.sp), color = c.text, modifier = Modifier.weight(1f))
|
||||
if (current == id)
|
||||
{
|
||||
Icon(ShxIcons.Check, contentDescription = null, tint = c.accent, modifier = Modifier.size(20.dp))
|
||||
}
|
||||
}
|
||||
if (i < rows.lastIndex)
|
||||
{
|
||||
Box(Modifier.fillMaxWidth().height(1.dp).background(c.tile))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** «443, 53» → [443, 53]; null when something is not a port. */
|
||||
internal fun parsePorts(text: String): List<Int>?
|
||||
{
|
||||
val parts = text.split(',', ' ', ';').map { it.trim() }.filter { it.isNotEmpty() }
|
||||
val ports = parts.map { it.toIntOrNull()?.takeIf { p -> p in 1..65535 } ?: return null }
|
||||
return ports.distinct()
|
||||
}
|
||||
|
||||
private fun loginLabel(type: VpnType): Int = when (type)
|
||||
{
|
||||
VpnType.IKEV2_CERT, VpnType.IKEV2_EAP_TLS -> R.string.server_login_cert
|
||||
VpnType.IKEV2_CERT_EAP -> R.string.server_login_both
|
||||
else -> R.string.server_login_password
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SourceSection(
|
||||
extra: ProfileExtra,
|
||||
checking: Boolean,
|
||||
onEditLink: () -> Unit,
|
||||
onAuto: (Boolean) -> Unit,
|
||||
onCheck: () -> Unit,
|
||||
onFile: () -> Unit,
|
||||
onQr: () -> Unit,
|
||||
)
|
||||
{
|
||||
val c = LocalShxColors.current
|
||||
val date = if (extra.addedAt > 0) SimpleDateFormat("d MMM", Locale.getDefault()).format(Date(extra.addedAt)) else null
|
||||
val type = if (extra.source == ProfileSourceType.UNKNOWN) ProfileSourceType.FILE else extra.source
|
||||
ShxSectionHeader(stringResource(R.string.server_source).uppercase(), Modifier.padding(start = 6.dp, end = 6.dp, top = 6.dp))
|
||||
ShxPanel()
|
||||
{
|
||||
val (icon, label, value) = when (type)
|
||||
{
|
||||
ProfileSourceType.LINK -> Triple(ShxIcons.Globe, stringResource(R.string.server_source_link), extra.sourceValue.orEmpty().removePrefix("https://"))
|
||||
ProfileSourceType.QR -> Triple(ShxIcons.Scan, stringResource(R.string.server_source_qr), date?.let { stringResource(R.string.server_source_scanned, it) } ?: "")
|
||||
ProfileSourceType.MANUAL -> Triple(ShxIcons.Sliders, stringResource(R.string.server_source_manual), date?.let { stringResource(R.string.server_source_created, it) } ?: "")
|
||||
else -> Triple(
|
||||
ShxIcons.Doc, stringResource(R.string.server_source_file),
|
||||
listOfNotNull(extra.sourceValue, date?.let { stringResource(R.string.server_source_added, it) }).joinToString(" · ")
|
||||
.ifEmpty { stringResource(R.string.server_source_earlier) },
|
||||
)
|
||||
}
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
.then(if (type == ProfileSourceType.LINK) Modifier.clickable(role = Role.Button, onClick = onEditLink) else Modifier)
|
||||
.padding(start = 16.dp, end = 14.dp, top = 12.dp, bottom = 12.dp),
|
||||
)
|
||||
{
|
||||
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(36.dp).clip(RoundedCornerShape(12.dp)).background(c.tile))
|
||||
{
|
||||
Icon(icon, contentDescription = null, tint = c.onAccentContainer, modifier = Modifier.size(18.dp))
|
||||
}
|
||||
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp))
|
||||
{
|
||||
Text(label, style = ShxType.chip, color = c.textSub)
|
||||
Text(
|
||||
value, maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||
style = ShxType.body.copy(fontWeight = FontWeight.Bold, fontFamily = if (type == ProfileSourceType.LINK) FontFamily.Monospace else ShxType.body.fontFamily),
|
||||
color = c.text,
|
||||
)
|
||||
}
|
||||
if (type == ProfileSourceType.LINK)
|
||||
{
|
||||
Icon(ShxIcons.Sliders, contentDescription = stringResource(R.string.server_link_title), tint = c.textSub, modifier = Modifier.size(18.dp))
|
||||
}
|
||||
}
|
||||
Divider()
|
||||
if (type == ProfileSourceType.LINK)
|
||||
{
|
||||
val now = System.currentTimeMillis()
|
||||
val checked = when
|
||||
{
|
||||
extra.checkedAt <= 0 -> null
|
||||
now - extra.checkedAt < DateUtils.MINUTE_IN_MILLIS -> stringResource(R.string.server_just_now)
|
||||
else -> DateUtils.getRelativeTimeSpanString(extra.checkedAt, now, DateUtils.MINUTE_IN_MILLIS).toString()
|
||||
}
|
||||
val result = when
|
||||
{
|
||||
extra.lastChanges < 0 -> stringResource(R.string.server_auto_failed)
|
||||
extra.lastChanges > 0 -> stringResource(R.string.server_auto_changes, extra.lastChanges)
|
||||
else -> stringResource(R.string.server_auto_same)
|
||||
}
|
||||
ToggleRow(
|
||||
stringResource(R.string.server_auto),
|
||||
listOfNotNull(stringResource(R.string.server_auto_daily), checked?.let { stringResource(R.string.server_auto_checked, it) }, if (checked != null) result else null).joinToString(" · "),
|
||||
extra.autoUpdate, onAuto,
|
||||
)
|
||||
Divider()
|
||||
}
|
||||
else
|
||||
{
|
||||
Text(
|
||||
stringResource(
|
||||
when (type)
|
||||
{
|
||||
ProfileSourceType.QR -> R.string.server_note_qr
|
||||
ProfileSourceType.MANUAL -> R.string.server_note_manual
|
||||
else -> R.string.server_note_file
|
||||
}
|
||||
),
|
||||
style = ShxType.meta.copy(fontWeight = FontWeight.Normal, lineHeight = 17.sp), color = c.textSub,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp),
|
||||
)
|
||||
if (type != ProfileSourceType.MANUAL)
|
||||
{
|
||||
Divider()
|
||||
}
|
||||
}
|
||||
if (type != ProfileSourceType.MANUAL)
|
||||
{
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.height(androidx.compose.foundation.layout.IntrinsicSize.Min).padding(horizontal = 14.dp, vertical = 12.dp))
|
||||
{
|
||||
when (type)
|
||||
{
|
||||
ProfileSourceType.LINK -> SoftButton(stringResource(if (checking) R.string.server_checking else R.string.server_check_now), onCheck, Modifier.weight(1f))
|
||||
ProfileSourceType.QR -> SoftButton(stringResource(R.string.server_replace_qr), onQr, Modifier.weight(1f))
|
||||
else ->
|
||||
{
|
||||
SoftButton(stringResource(R.string.server_replace_file), onFile, Modifier.weight(1f))
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier.weight(1f).heightIn(min = 44.dp).clip(RoundedCornerShape(14.dp)).border(1.dp, c.outline, RoundedCornerShape(14.dp))
|
||||
.clickable(role = Role.Button, onClick = onEditLink).padding(horizontal = 10.dp, vertical = 6.dp),
|
||||
)
|
||||
{
|
||||
Text(stringResource(R.string.server_set_link), style = ShxType.body.copy(fontWeight = FontWeight.Bold), color = c.text)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SoftButton(text: String, onClick: () -> Unit, modifier: Modifier)
|
||||
{
|
||||
val c = LocalShxColors.current
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = modifier.heightIn(min = 44.dp).clip(RoundedCornerShape(14.dp)).background(c.accentMuted).clickable(role = Role.Button, onClick = onClick)
|
||||
.padding(horizontal = 10.dp, vertical = 6.dp),
|
||||
)
|
||||
{
|
||||
Text(text, style = ShxType.body.copy(fontWeight = FontWeight.ExtraBold), color = c.textTonal, maxLines = 2, textAlign = androidx.compose.ui.text.style.TextAlign.Center)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Divider()
|
||||
{
|
||||
Box(Modifier.fillMaxWidth().height(1.dp).background(LocalShxColors.current.tile))
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Group(title: String, content: @Composable ColumnScope.() -> Unit)
|
||||
{
|
||||
Column(verticalArrangement = Arrangement.spacedBy(6.dp))
|
||||
{
|
||||
ShxSectionHeader(title.uppercase(), Modifier.padding(start = 6.dp, end = 6.dp, top = 6.dp))
|
||||
ShxPanel(content = content)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ValueRow(title: String, value: String, sub: String? = null, onClick: () -> Unit, divider: Boolean = false)
|
||||
{
|
||||
val c = LocalShxColors.current
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
modifier = Modifier.fillMaxWidth().heightIn(min = 58.dp).clickable(role = Role.Button, onClick = onClick).padding(start = 16.dp, end = 14.dp, top = 8.dp, bottom = 8.dp),
|
||||
)
|
||||
{
|
||||
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp))
|
||||
{
|
||||
Text(title, style = ShxType.rowTitle.copy(fontSize = 15.sp), color = c.text)
|
||||
sub?.let { Text(it, style = ShxType.meta.copy(fontWeight = FontWeight.Normal, lineHeight = 16.sp), color = c.textSub) }
|
||||
}
|
||||
Text(value, style = ShxType.body, color = c.textSub, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(0.8f, fill = false))
|
||||
Icon(ShxIcons.Chevron, contentDescription = null, tint = c.textSub, modifier = Modifier.size(18.dp))
|
||||
}
|
||||
if (divider)
|
||||
{
|
||||
Divider()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ToggleRow(title: String, sub: String?, checked: Boolean, onChange: (Boolean) -> Unit)
|
||||
{
|
||||
val c = LocalShxColors.current
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
modifier = Modifier.fillMaxWidth().heightIn(min = 58.dp).padding(start = 16.dp, end = 14.dp, top = 8.dp, bottom = 8.dp),
|
||||
)
|
||||
{
|
||||
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp))
|
||||
{
|
||||
Text(title, style = ShxType.rowTitle.copy(fontSize = 15.sp), color = c.text)
|
||||
sub?.let { Text(it, style = ShxType.meta.copy(fontWeight = FontWeight.Normal, lineHeight = 16.sp), color = c.textSub) }
|
||||
}
|
||||
ShxSwitch(checked, onChange)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TextDialog(
|
||||
title: String,
|
||||
label: String,
|
||||
initial: String,
|
||||
keyboard: KeyboardType,
|
||||
validate: (String) -> Boolean,
|
||||
onDone: (String) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
)
|
||||
{
|
||||
var text by remember { mutableStateOf(initial) }
|
||||
ShxAlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(title) },
|
||||
text = {
|
||||
ShxFormField(label = label, value = text, onValueChange = { text = it }, keyboardOptions = KeyboardOptions(keyboardType = keyboard))
|
||||
},
|
||||
confirmButton = { TextButton(onClick = { onDone(text) }, enabled = validate(text.trim())) { Text(stringResource(R.string.server_save)) } },
|
||||
dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(android.R.string.cancel)) } },
|
||||
)
|
||||
}
|
||||
|
||||
/** «The profile has changes» (F_ProfileUpdate): old → new per field, «Later» / «Apply and reconnect». */
|
||||
@Composable
|
||||
private fun ProfileUpdateSheet(name: String, check: ProfileCheck, onLater: () -> Unit, onApply: () -> Unit)
|
||||
{
|
||||
val c = LocalShxColors.current
|
||||
ShxSheet(onDismiss = onLater)
|
||||
{
|
||||
Column(verticalArrangement = Arrangement.spacedBy(14.dp))
|
||||
{
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp), modifier = Modifier.padding(horizontal = 4.dp))
|
||||
{
|
||||
Text(stringResource(R.string.profile_update_title), style = ShxType.sheetTitle, color = c.text)
|
||||
Text(stringResource(R.string.profile_update_sub, name), style = ShxType.body.copy(fontWeight = FontWeight.Normal), color = c.textSub)
|
||||
}
|
||||
ShxPanel()
|
||||
{
|
||||
check.changes.forEachIndexed { i, change ->
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp), modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp))
|
||||
{
|
||||
Text(change.label, style = ShxType.chip, color = c.textSub)
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp))
|
||||
{
|
||||
val mono = ShxType.body.copy(fontWeight = FontWeight.Bold, fontFamily = FontFamily.Monospace)
|
||||
Text(change.old, style = mono.copy(textDecoration = TextDecoration.LineThrough), color = c.iconMuted, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f, fill = false))
|
||||
Icon(ShxIcons.Chevron, contentDescription = null, tint = c.textSub, modifier = Modifier.size(16.dp))
|
||||
Text(change.new, style = mono, color = c.text, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f, fill = false))
|
||||
}
|
||||
}
|
||||
if (i < check.changes.lastIndex)
|
||||
{
|
||||
Divider()
|
||||
}
|
||||
}
|
||||
}
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(18.dp)).background(c.successContainer).padding(horizontal = 14.dp, vertical = 12.dp),
|
||||
)
|
||||
{
|
||||
Icon(ShxIcons.Shield, contentDescription = null, tint = c.onSuccess, modifier = Modifier.size(20.dp))
|
||||
Text(stringResource(R.string.profile_update_keep), style = ShxType.note.copy(lineHeight = 19.sp, fontWeight = FontWeight.Normal), color = c.onSuccess)
|
||||
}
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(10.dp))
|
||||
{
|
||||
ShxButton(stringResource(R.string.cert_later), onLater, style = ShxButtonStyle.OUTLINE, modifier = Modifier.weight(1f))
|
||||
ShxButton(stringResource(R.string.profile_update_apply), onApply, modifier = Modifier.weight(2f))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
@@ -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)
|
||||
|
||||
+6
@@ -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);
|
||||
|
||||
+1
@@ -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
|
||||
}
|
||||
+1
@@ -70,6 +70,7 @@ fun ProfileEditorPane(
|
||||
val saved = checked.toProfile(profile)
|
||||
if (profile != null) dataSource.updateVpnProfile(saved)
|
||||
else dataSource.insertProfile(saved)
|
||||
if (profile == null) org.strongswan.android.logic.profiles.ProfileExtras.recordManual(context, saved.getUUID().toString())
|
||||
val intent = Intent(Constants.VPN_PROFILES_CHANGED)
|
||||
intent.putExtra(Constants.VPN_PROFILES_SINGLE, saved.getUUID().toString())
|
||||
LocalBroadcastManager.getInstance(context).sendBroadcast(intent)
|
||||
|
||||
@@ -131,9 +131,8 @@ fun ShxApp(viewModel: HomeViewModel = viewModel())
|
||||
}
|
||||
}
|
||||
val editProfile: (VpnProfile) -> Unit = { profile ->
|
||||
val intent = Intent(context, VpnProfileDetailActivity::class.java)
|
||||
intent.putExtra(VpnProfileDataSource.KEY_UUID, profile.getUUID().toString())
|
||||
context.startActivity(intent)
|
||||
/* F_Profile: the server page, editing goes from there */
|
||||
context.startActivity(org.strongswan.android.ui.ServerActivity.intent(context, profile.getUUID().toString()))
|
||||
}
|
||||
var pendingShare by remember { mutableStateOf<VpnProfile?>(null) }
|
||||
val shareProfile: (VpnProfile) -> Unit = { pendingShare = it }
|
||||
|
||||
@@ -846,6 +846,9 @@ static job_requeue_t initiate(private_android_service_t *this)
|
||||
IKEV2_UDP_PORT);
|
||||
ike.no_certreq = !this->settings->get_bool(this->settings,
|
||||
"connection.certreq", TRUE);
|
||||
/* per-profile switch of the app (F_Profile «IKE fragmentation») */
|
||||
ike.fragmentation = this->settings->get_bool(this->settings,
|
||||
"connection.fragmentation", TRUE) ? FRAGMENTATION_YES : FRAGMENTATION_NO;
|
||||
ike_cfg = ike_cfg_create(&ike);
|
||||
proposal = parse_proposal(this, PROTO_IKE, "connection.ike_proposal");
|
||||
if (proposal)
|
||||
|
||||
@@ -1151,4 +1151,76 @@
|
||||
<string name="stats_fresh_text">Через неделю здесь будет видно, сколько трафика идёт через VPN и сколько напрямую, и какие приложения тратят больше всего.</string>
|
||||
<string name="home_traffic_today">сегодня</string>
|
||||
<string name="widget_server_today">%1$s · %2$s сегодня</string>
|
||||
<string name="profile_update_addr">Адрес сервера</string>
|
||||
<string name="profile_update_port">Порт</string>
|
||||
<string name="profile_update_id">Имя сервера</string>
|
||||
<string name="profile_update_ca">Сертификат CA</string>
|
||||
<string name="profile_update_until">до %1$s</string>
|
||||
<string name="event_profile_update_title">В профиле «%1$s» есть изменения</string>
|
||||
<string name="event_profile_update_text">Администратор обновил профиль по ссылке: изменений — %1$d. Примените их, чтобы подключение не пропало.</string>
|
||||
<string name="events_action_profile_update">Показать изменения</string>
|
||||
<string name="server_share">Поделиться</string>
|
||||
<string name="server_state_connected">Подключён</string>
|
||||
<string name="server_state_connected_ms">Подключён · %1$d мс</string>
|
||||
<string name="server_state_connecting">Подключается…</string>
|
||||
<string name="server_state_answers">Отвечает · %1$d мс</string>
|
||||
<string name="server_state_no_answer">Не отвечает</string>
|
||||
<string name="server_source">Источник профиля</string>
|
||||
<string name="server_source_link">Ссылка</string>
|
||||
<string name="server_source_file">Файл</string>
|
||||
<string name="server_source_qr">QR-код</string>
|
||||
<string name="server_source_manual">Вручную</string>
|
||||
<string name="server_source_added">добавлен %1$s</string>
|
||||
<string name="server_source_scanned">отсканирован %1$s</string>
|
||||
<string name="server_source_created">создан %1$s</string>
|
||||
<string name="server_source_earlier">добавлен ранее</string>
|
||||
<string name="server_auto">Обновлять автоматически</string>
|
||||
<string name="server_auto_daily">Раз в сутки</string>
|
||||
<string name="server_auto_checked">проверено %1$s</string>
|
||||
<string name="server_auto_same">изменений нет</string>
|
||||
<string name="server_auto_changes">изменений: %1$d</string>
|
||||
<string name="server_auto_failed">проверить не удалось</string>
|
||||
<string name="server_note_file">Обновлять автоматически нечего: у файла нет ссылки. Новый файл от администратора — «Заменить из файла», настройки ниже сохранятся.</string>
|
||||
<string name="server_note_qr">Обновить можно, отсканировав новый QR — настройки ниже сохранятся.</string>
|
||||
<string name="server_note_manual">Адрес, вход и сертификат правятся прямо в группе «Сервер» ниже.</string>
|
||||
<string name="server_check_now">Проверить сейчас</string>
|
||||
<string name="server_checking">Проверяем…</string>
|
||||
<string name="server_replace_file">Заменить из файла…</string>
|
||||
<string name="server_replace_qr">Заменить по QR…</string>
|
||||
<string name="server_set_link">Указать ссылку</string>
|
||||
<string name="server_link_title">Ссылка на профиль</string>
|
||||
<string name="server_link_label">https://…/profile.sswan</string>
|
||||
<string name="server_save">Сохранить</string>
|
||||
<string name="server_group_server">Сервер</string>
|
||||
<string name="server_group_resilience">Устойчивость</string>
|
||||
<string name="server_group_network">Сеть</string>
|
||||
<string name="server_addr">Адрес</string>
|
||||
<string name="server_login">Вход</string>
|
||||
<string name="server_login_password">Логин</string>
|
||||
<string name="server_login_cert">Сертификат</string>
|
||||
<string name="server_login_both">Оба</string>
|
||||
<string name="server_ports">Запасные порты</string>
|
||||
<string name="server_ports_sub">По очереди, если основной не отвечает</string>
|
||||
<string name="server_ports_label">Порты через запятую</string>
|
||||
<string name="server_reserve">Резервный сервер</string>
|
||||
<string name="server_reserve_sub">Если не подключился за %1$d с</string>
|
||||
<string name="server_reserve_pick_note">Подключимся к нему, когда этот сервер не отвечает.</string>
|
||||
<string name="server_bridge">Мост при белых списках</string>
|
||||
<string name="server_bridge_title">Мост</string>
|
||||
<string name="server_bridge_sub">Переключаться на «%1$s»</string>
|
||||
<string name="server_bridge_pick">Выберите сервер с российским адресом</string>
|
||||
<string name="server_bridge_pick_note">Сервер с российским адресом работает, даже когда оператор включает белые списки.</string>
|
||||
<string name="server_none">Нет</string>
|
||||
<string name="server_dns_default">Как в настройках</string>
|
||||
<string name="server_auto_value">Авто</string>
|
||||
<string name="server_fragmentation">Фрагментация IKE</string>
|
||||
<string name="server_export">Экспорт .sswan</string>
|
||||
<string name="profile_check_none">Изменений в профиле нет</string>
|
||||
<string name="profile_check_failed">Не удалось проверить ссылку</string>
|
||||
<string name="profile_updated">Профиль обновлён</string>
|
||||
<string name="profile_update_title">В профиле есть изменения</string>
|
||||
<string name="profile_update_sub">%1$s · проверено только что по ссылке</string>
|
||||
<string name="profile_update_keep">Останутся без изменений: запасные порты, резервный сервер, мост, DNS, правила и приложения.</string>
|
||||
<string name="profile_update_apply">Применить и переподключить</string>
|
||||
<string name="server_just_now">только что</string>
|
||||
</resources>
|
||||
@@ -1137,4 +1137,76 @@
|
||||
<string name="stats_fresh_text">In a week you will see how much traffic goes through the VPN and how much direct, and which apps use the most.</string>
|
||||
<string name="home_traffic_today">today</string>
|
||||
<string name="widget_server_today">%1$s · %2$s today</string>
|
||||
<string name="profile_update_addr">Server address</string>
|
||||
<string name="profile_update_port">Port</string>
|
||||
<string name="profile_update_id">Server identity</string>
|
||||
<string name="profile_update_ca">CA certificate</string>
|
||||
<string name="profile_update_until">until %1$s</string>
|
||||
<string name="event_profile_update_title">The «%1$s» profile has changes</string>
|
||||
<string name="event_profile_update_text">The admin updated the profile at its link: %1$d changes. Apply them to keep connecting.</string>
|
||||
<string name="events_action_profile_update">Show changes</string>
|
||||
<string name="server_share">Share</string>
|
||||
<string name="server_state_connected">Connected</string>
|
||||
<string name="server_state_connected_ms">Connected · %1$d ms</string>
|
||||
<string name="server_state_connecting">Connecting…</string>
|
||||
<string name="server_state_answers">Answers · %1$d ms</string>
|
||||
<string name="server_state_no_answer">No answer</string>
|
||||
<string name="server_source">Profile source</string>
|
||||
<string name="server_source_link">Link</string>
|
||||
<string name="server_source_file">File</string>
|
||||
<string name="server_source_qr">QR code</string>
|
||||
<string name="server_source_manual">Manual</string>
|
||||
<string name="server_source_added">added %1$s</string>
|
||||
<string name="server_source_scanned">scanned %1$s</string>
|
||||
<string name="server_source_created">created %1$s</string>
|
||||
<string name="server_source_earlier">added earlier</string>
|
||||
<string name="server_auto">Update automatically</string>
|
||||
<string name="server_auto_daily">Once a day</string>
|
||||
<string name="server_auto_checked">checked %1$s</string>
|
||||
<string name="server_auto_same">no changes</string>
|
||||
<string name="server_auto_changes">changes: %1$d</string>
|
||||
<string name="server_auto_failed">could not check</string>
|
||||
<string name="server_note_file">Nothing to update automatically: a file has no link. A new file from the admin — «Replace from file», the settings below stay.</string>
|
||||
<string name="server_note_qr">Update it by scanning a new QR — the settings below stay.</string>
|
||||
<string name="server_note_manual">Address, sign-in and certificate are edited right in the «Server» group below.</string>
|
||||
<string name="server_check_now">Check now</string>
|
||||
<string name="server_checking">Checking…</string>
|
||||
<string name="server_replace_file">Replace from file…</string>
|
||||
<string name="server_replace_qr">Replace by QR…</string>
|
||||
<string name="server_set_link">Set a link</string>
|
||||
<string name="server_link_title">Profile link</string>
|
||||
<string name="server_link_label">https://…/profile.sswan</string>
|
||||
<string name="server_save">Save</string>
|
||||
<string name="server_group_server">Server</string>
|
||||
<string name="server_group_resilience">Resilience</string>
|
||||
<string name="server_group_network">Network</string>
|
||||
<string name="server_addr">Address</string>
|
||||
<string name="server_login">Sign-in</string>
|
||||
<string name="server_login_password">Login</string>
|
||||
<string name="server_login_cert">Certificate</string>
|
||||
<string name="server_login_both">Both</string>
|
||||
<string name="server_ports">Backup ports</string>
|
||||
<string name="server_ports_sub">In turn, if the main one does not answer</string>
|
||||
<string name="server_ports_label">Ports, comma separated</string>
|
||||
<string name="server_reserve">Reserve server</string>
|
||||
<string name="server_reserve_sub">If it does not connect in %1$d s</string>
|
||||
<string name="server_reserve_pick_note">Connect to it when this server does not answer.</string>
|
||||
<string name="server_bridge">Bridge for whitelists</string>
|
||||
<string name="server_bridge_title">Bridge</string>
|
||||
<string name="server_bridge_sub">Switch to «%1$s»</string>
|
||||
<string name="server_bridge_pick">Pick a server with a Russian address</string>
|
||||
<string name="server_bridge_pick_note">A server with a Russian address still works when the operator turns on whitelists.</string>
|
||||
<string name="server_none">None</string>
|
||||
<string name="server_dns_default">As in settings</string>
|
||||
<string name="server_auto_value">Auto</string>
|
||||
<string name="server_fragmentation">IKE fragmentation</string>
|
||||
<string name="server_export">Export .sswan</string>
|
||||
<string name="profile_check_none">No changes in the profile</string>
|
||||
<string name="profile_check_failed">Could not check the link</string>
|
||||
<string name="profile_updated">Profile updated</string>
|
||||
<string name="profile_update_title">The profile has changes</string>
|
||||
<string name="profile_update_sub">%1$s · checked just now at the link</string>
|
||||
<string name="profile_update_keep">Unchanged: backup ports, reserve server, bridge, DNS, rules and apps.</string>
|
||||
<string name="profile_update_apply">Apply and reconnect</string>
|
||||
<string name="server_just_now">just now</string>
|
||||
</resources>
|
||||
+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