android: routing phase 3 and the Final 2026 design system (WIP)

- Routing: site and direct-address rules (sites resolved to addresses,
  refreshed on connect and every 30 min), package patterns can be
  switched off; unified Rules screen with add sheet and empty state;
  "How traffic goes" for a site, IP or app with reasons, reachability and
  one-tap rules; Recent apps (usage access) with failing apps; new-app
  "go direct?" suggestions in Events.
- Diagnostics screen (all-in-one check), "Fastest" server, pause until
  morning / until the network changes.
- Design system of the mockup: Manrope font, mockup palette for light and
  dark, ShxKit components (cards, rows, switch, buttons, chips, tabs,
  fields, sheets), mockup line icons; Home, nav bar, Routing, Rules,
  Recent, Events, Diagnostics and route check rebuilt on it; shared
  components restyled so the remaining screens pick up the look.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
DenozordecandClaude Opus 5.5 committed 2026-10-11 22:46:27 +07:00
1 parent 60252437ac
commit 4ff5ed11bb
42 files changed
+6161 -1741

No files matched your search

@@ -30,6 +30,10 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<!-- «Recent apps»: per-app traffic totals from the system network statistics;
granted by the user in Settings > Usage access, never requested silently -->
<uses-permission android:name="android.permission.PACKAGE_USAGE_STATS"
tools:ignore="ProtectedPermissions" />
<uses-permission android:name="android.permission.POST_PROMOTED_NOTIFICATIONS"/>
<!-- required to install downloaded updates of the app itself -->
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
@@ -133,6 +137,10 @@
android:name=".ui.InboxActivity"
android:label="@string/events_title">
</activity>
<activity
android:name=".ui.DiagnosticsActivity"
android:label="@string/diag_title">
</activity>
<activity
android:name=".ui.RemediationInstructionsActivity"
android:label="@string/remediation_instructions_title" >
@@ -1170,6 +1170,29 @@ public class CharonVpnService extends VpnService implements Runnable, VpnStateSe
RuIpListManager list = RuIpListManager.getInstance(context);
List<IPRange> v4 = enabled ? list.getExcludedV4() : Collections.emptyList();
List<IPRange> v6 = enabled ? list.getExcludedV6() : Collections.emptyList();
if (enabled)
{ /* user rules: addresses and resolved sites that always go direct */
List<String> direct = org.strongswan.android.logic.routing.RoutingManager.getInstance(context).directCidrs();
if (!direct.isEmpty())
{
ArrayList<IPRange> d4 = new ArrayList<>(v4);
ArrayList<IPRange> d6 = new ArrayList<>(v6);
for (String cidr : direct)
{
try
{
IPRange range = new IPRange(cidr);
(range.getFrom() instanceof Inet4Address ? d4 : d6).add(range);
}
catch (Exception ignored)
{ /* stored prefixes are validated; skip anything stale */
}
}
/* the engine expects sorted, non-overlapping ranges */
v4 = RuIpListManager.Companion.mergeRanges(d4);
v6 = RuIpListManager.Companion.mergeRanges(d6);
}
}
List<IPRange> force = new ArrayList<>();
if (enabled)
@@ -40,6 +40,8 @@ object PauseManager
private const val PREF_UNTIL = "pref_pause_until"
private const val PREF_PROFILE = "pref_pause_profile"
private const val REQUEST_CODE = 45
/* safety net of "until the network changes" */
private const val NETWORK_PAUSE_MAX_MS = 12L * 60 * 60 * 1000
/* wall-clock end of the running pause, 0 if none; read by the Home screen */
private val _until = MutableStateFlow(0L)
@@ -74,11 +76,108 @@ object PauseManager
/** Disconnects [service] and schedules the reconnect of [profileUuid] in [minutes]. */
@JvmStatic
fun pause(context: Context, service: VpnStateService, profileUuid: String, minutes: Int)
{
pauseUntil(context, service, profileUuid, System.currentTimeMillis() + minutes * 60_000L)
}
/** Disconnects [service] until the wall-clock time [until]. */
@JvmStatic
fun pauseUntil(context: Context, service: VpnStateService, profileUuid: String, until: Long)
{
val ctx = context.applicationContext
unwatchNetwork(ctx)
service.disconnect()
store(ctx, System.currentTimeMillis() + minutes * 60_000L, profileUuid)
DiagLog.i(TAG, "VPN paused for $minutes min")
store(ctx, until, profileUuid)
DiagLog.i(TAG, "VPN paused until $until")
}
/**
* Disconnects until the device moves to another network (e.g. leaves this
* Wi-Fi). A [NETWORK_PAUSE_MAX_MS] alarm is the safety net in case the
* process dies meanwhile and the change is never seen.
*/
@JvmStatic
fun pauseUntilNetworkChange(context: Context, service: VpnStateService, profileUuid: String)
{
val ctx = context.applicationContext
pauseUntil(ctx, service, profileUuid, System.currentTimeMillis() + NETWORK_PAUSE_MAX_MS)
watchNetwork(ctx)
}
/** Next 07:00 after [now], local time. */
@JvmStatic
fun nextMorning(now: Long): Long
{
val c = java.util.Calendar.getInstance()
c.timeInMillis = now
c.set(java.util.Calendar.HOUR_OF_DAY, 7)
c.set(java.util.Calendar.MINUTE, 0)
c.set(java.util.Calendar.SECOND, 0)
c.set(java.util.Calendar.MILLISECOND, 0)
if (c.timeInMillis <= now)
{
c.add(java.util.Calendar.DAY_OF_YEAR, 1)
}
return c.timeInMillis
}
private var networkCallback: android.net.ConnectivityManager.NetworkCallback? = null
private fun watchNetwork(ctx: Context)
{
val cm = ctx.getSystemService(Context.CONNECTIVITY_SERVICE) as? android.net.ConnectivityManager ?: return
var first: android.net.Network? = null
val callback = object : android.net.ConnectivityManager.NetworkCallback()
{
override fun onAvailable(network: android.net.Network)
{
/* right after the disconnect the default network may still be the
* tunnel itself: only real uplinks count */
val caps = try
{
cm.getNetworkCapabilities(network)
}
catch (_: Exception)
{
null
}
if (caps == null || caps.hasTransport(android.net.NetworkCapabilities.TRANSPORT_VPN))
{
return
}
/* the first uplink reported is the current one; any other one is a change */
if (first == null)
{
first = network
}
else if (network != first)
{
DiagLog.i(TAG, "network changed, ending the pause")
android.os.Handler(android.os.Looper.getMainLooper()).post { resumeNow(ctx) }
}
}
}
try
{
cm.registerDefaultNetworkCallback(callback)
networkCallback = callback
}
catch (_: RuntimeException)
{ /* too many callbacks: the safety alarm still ends the pause */
}
}
private fun unwatchNetwork(ctx: Context)
{
val callback = networkCallback ?: return
networkCallback = null
try
{
(ctx.getSystemService(Context.CONNECTIVITY_SERVICE) as? android.net.ConnectivityManager)?.unregisterNetworkCallback(callback)
}
catch (_: IllegalArgumentException)
{
}
}
/** Moves the end of the running pause by [minutes]. */
@@ -122,6 +221,7 @@ object PauseManager
return
}
_until.value = 0L
unwatchNetwork(ctx)
PreferenceManager.getDefaultSharedPreferences(ctx).edit()
.remove(PREF_UNTIL).remove(PREF_PROFILE).apply()
alarmManager(ctx).cancel(alarmIntent(ctx))
@@ -64,6 +64,8 @@ enum class EventAction
OPEN_ROUTE_CHECK,
OPEN_LOG,
INSTALL_UPDATE,
/** let the app in [ShxEvent.target] bypass the VPN */
SET_APP_DIRECT,
}
data class ShxEvent(
@@ -81,6 +83,8 @@ data class ShxEvent(
val read: Boolean = false,
/** the problem went away by itself; shown dimmed, no longer "waiting" */
val resolved: Boolean = false,
/** what the action applies to, e.g. a package name */
val target: String? = null,
)
{
/** still needs the user's attention */
@@ -112,6 +116,7 @@ class EventLog private constructor(private val context: Context)
text: String,
action: EventAction = EventAction.NONE,
key: String? = null,
target: String? = null,
)
{
val now = System.currentTimeMillis()
@@ -123,7 +128,7 @@ class EventLog private constructor(private val context: Context)
val next = if (merged >= 0)
{
val old = list[merged]
val bumped = old.copy(at = now, title = title, text = text, action = action, count = old.count + 1, read = false)
val bumped = old.copy(at = now, title = title, text = text, action = action, target = target, count = old.count + 1, read = false)
posted = bumped
listOf(bumped) + list.filterIndexed { i, _ -> i != merged }
}
@@ -131,7 +136,7 @@ class EventLog private constructor(private val context: Context)
{
val event = ShxEvent(
id = now * 1000 + (list.size % 1000), at = now, kind = kind, category = category,
title = title, text = text, action = action, key = key,
title = title, text = text, action = action, key = key, target = target,
)
posted = event
listOf(event) + list
@@ -143,14 +148,21 @@ class EventLog private constructor(private val context: Context)
}
/** Records once per [key] for the whole retention time (e.g. one update notice per version). */
fun recordOnce(kind: EventKind, category: EventCategory, title: String, text: String, action: EventAction, key: String)
fun recordOnce(kind: EventKind, category: EventCategory, title: String, text: String, action: EventAction, key: String, target: String? = null)
{
if (_events.value.none { it.key == key })
{
record(kind, category, title, text, action, key)
record(kind, category, title, text, action, key, target)
}
}
/** The user answered an event (did the action or said no): it no longer waits. */
fun answer(id: Long)
{
_events.update { list -> list.map { if (it.id == id) it.copy(resolved = true, read = true) else it } }
persist()
}
/** Marks the unresolved events whose key starts with [keyPrefix] as resolved. */
fun resolve(keyPrefix: String)
{
@@ -319,6 +331,7 @@ class EventLog private constructor(private val context: Context)
.put("id", e.id).put("at", e.at).put("kind", e.kind.name).put("category", e.category.name)
.put("title", e.title).put("text", e.text).put("action", e.action.name)
.put("key", e.key ?: JSONObject.NULL).put("count", e.count).put("read", e.read).put("resolved", e.resolved)
.put("target", e.target ?: JSONObject.NULL)
internal fun fromJson(o: JSONObject?): ShxEvent?
{
@@ -337,6 +350,7 @@ class EventLog private constructor(private val context: Context)
count = o.optInt("count", 1),
read = o.optBoolean("read"),
resolved = o.optBoolean("resolved"),
target = if (o.isNull("target")) null else o.optString("target"),
)
}
catch (_: Exception)
@@ -115,6 +115,16 @@ object Events
)
}
/** A newly installed app looks like a Russian service that is not direct yet. */
@JvmStatic
fun newRuApp(context: Context, packageName: String, label: String)
{
EventLog.getInstance(context).recordOnce(
EventKind.ASK, EventCategory.ROUTING, context.getString(R.string.event_new_app_title, label),
context.getString(R.string.event_new_app_text), EventAction.SET_APP_DIRECT, "new-app:$packageName", packageName,
)
}
@JvmStatic
fun pauseOver(context: Context)
{
@@ -0,0 +1,114 @@
/*
* 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.routing
import android.app.AppOpsManager
import android.app.usage.NetworkStats
import android.app.usage.NetworkStatsManager
import android.content.Context
import android.net.ConnectivityManager
import android.os.Build
import android.os.Process
import android.util.Log
/**
* Per-app network traffic from the system statistics ("Usage access"), for
* the «Recent apps» list. Android attributes tunneled traffic back to the
* app that produced it, so the totals cover both the VPN and direct paths.
* Only byte counts per app are read - never destinations or content.
*/
object AppTraffic
{
private const val TAG = "AppTraffic"
/* the first uid of regular apps; lower ones are system services */
private const val FIRST_APP_UID = 10000
fun hasAccess(context: Context): Boolean
{
val ops = context.getSystemService(Context.APP_OPS_SERVICE) as? AppOpsManager ?: return false
val mode = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q)
{
ops.unsafeCheckOpNoThrow(AppOpsManager.OPSTR_GET_USAGE_STATS, Process.myUid(), context.packageName)
}
else
{
@Suppress("DEPRECATION")
ops.checkOpNoThrow(AppOpsManager.OPSTR_GET_USAGE_STATS, Process.myUid(), context.packageName)
}
return mode == AppOpsManager.MODE_ALLOWED
}
/**
* Bytes per package since [sinceMs] (wall clock), Wi-Fi and mobile
* together; empty without usage access. The system keeps the counters
* in buckets of up to two hours, so the window is approximate.
*/
fun since(context: Context, sinceMs: Long): Map<String, Long>
{
if (!hasAccess(context))
{
return emptyMap()
}
val manager = context.getSystemService(Context.NETWORK_STATS_SERVICE) as? NetworkStatsManager ?: return emptyMap()
val now = System.currentTimeMillis()
val perUid = HashMap<Int, Long>()
@Suppress("DEPRECATION")
for (type in intArrayOf(ConnectivityManager.TYPE_WIFI, ConnectivityManager.TYPE_MOBILE))
{
try
{
manager.querySummary(type, null, sinceMs, now).use { stats -> sum(stats, perUid) }
}
catch (e: Exception)
{ /* no such network history or no access */
Log.d(TAG, "querySummary($type) failed: $e")
}
}
val pm = context.packageManager
val result = HashMap<String, Long>()
for ((uid, bytes) in perUid)
{
if (uid < FIRST_APP_UID || bytes <= 0)
{
continue
}
val pkg = pm.getPackagesForUid(uid)?.firstOrNull() ?: continue
if (pkg != context.packageName)
{
result[pkg] = (result[pkg] ?: 0L) + bytes
}
}
return result
}
private fun sum(stats: NetworkStats, into: HashMap<Int, Long>)
{
val bucket = NetworkStats.Bucket()
while (stats.hasNextBucket())
{
stats.getNextBucket(bucket)
into[bucket.uid] = (into[bucket.uid] ?: 0L) + bucket.rxBytes + bucket.txBytes
}
}
private inline fun <R> NetworkStats.use(block: (NetworkStats) -> R): R = try
{
block(this)
}
finally
{
close()
}
}
@@ -102,6 +102,63 @@ object RouteCheck
)
}
/** Why an address takes its route, in the order the engine decides. */
enum class Reason
{
/** a resolver of the tunnel or strict private DNS: always through the tunnel */
DNS,
/** a user rule (address or site) sends it through the VPN */
RULE_TUNNEL,
/** a user rule (address or site) sends it direct */
RULE_DIRECT,
/** it is in the RU address base */
RU_LIST,
/** nothing matched: the default route is the tunnel */
DEFAULT,
}
data class Verdict(val direct: Boolean, val reason: Reason)
/**
* Full verdict for an address: like [classify], plus the user rules,
* which the engine applies when [rulesActive] (userspace routing):
* "through the VPN" wins over "direct", both win over the RU base.
*/
@JvmStatic
fun verdict(
address: InetAddress,
exclusionsV4: List<IPRange>,
exclusionsV6: List<IPRange>,
dnsPunchBlocks: Set<String>,
forceRanges: List<IPRange>,
directRanges: List<IPRange>,
rulesActive: Boolean,
): Verdict
{
if (insidePunchBlock(dnsPunchBlocks, address))
{
return Verdict(direct = false, reason = Reason.DNS)
}
if (rulesActive && containsAddress(forceRanges, address))
{
return Verdict(direct = false, reason = Reason.RULE_TUNNEL)
}
if (rulesActive && containsAddress(directRanges, address))
{
return Verdict(direct = true, reason = Reason.RULE_DIRECT)
}
val exclusions = if (address is java.net.Inet4Address) exclusionsV4 else exclusionsV6
if (containsAddress(exclusions, address))
{
return Verdict(direct = true, reason = Reason.RU_LIST)
}
return Verdict(direct = false, reason = Reason.DEFAULT)
}
/** Parses CIDR strings, skipping invalid ones. */
@JvmStatic
fun ranges(cidrs: List<String>): List<IPRange> = cidrs.mapNotNull { runCatching { IPRange(it) }.getOrNull() }
/** Route verdict for one address under the applied exclusion set. */
@JvmStatic
fun classify(
@@ -29,6 +29,9 @@ import android.util.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
@@ -80,10 +83,29 @@ data class RoutingConfig(
val enabledCategories: Set<String> = emptySet(),
/** User package patterns, see [PackagePatterns]. */
val packagePatterns: Set<String> = emptySet(),
/** Patterns the user switched off without deleting them. */
val disabledPatterns: Set<String> = emptySet(),
val forceTunnelPrefixes: List<ForceTunnelPrefix> = emptyList(),
/** User addresses that always go direct (same shape as the force-tunnel entries). */
val directPrefixes: List<ForceTunnelPrefix> = emptyList(),
/** Per-site rules; the engine routes by address, so sites are resolved, see [SiteRule]. */
val siteRules: List<SiteRule> = emptyList(),
val version: Int = RoutingManager.CONFIG_VERSION,
)
/**
* "This site always goes direct / through the VPN". The userspace router
* decides per destination address, so the rule applies to the addresses the
* domain (and its www. name) resolves to; they are refreshed when the
* tunnel comes up and every [RoutingManager.SITE_REFRESH_MS] while it is up.
*/
data class SiteRule(
val id: String,
val domain: String,
val direct: Boolean,
val enabled: Boolean = true,
)
/** Why a package is (not) direct, for the per-app reason line. */
data class TemplateMatch(
/** Built-in category id (may be [RoutingPresets.NAMESPACE]) or null. */
@@ -132,11 +154,32 @@ class RoutingManager private constructor(private val context: Context)
private val _state = MutableStateFlow(RoutingState())
val state: StateFlow<RoutingState> = _state
/* resolved addresses of the site rules, domain -> CIDRs; read by the push on any thread */
@Volatile
private var siteAddresses: Map<String, List<String>> = emptyMap()
private val _siteAddresses = MutableStateFlow<Map<String, List<String>>>(emptyMap())
/** Resolved addresses per site rule domain, for the Rules screen. */
val resolvedSites: StateFlow<Map<String, List<String>>> = _siteAddresses
private var siteRefreshJob: Job? = null
/* site rules are resolved again whenever the tunnel comes up */
private var lastState: VpnStateService.State? = null
private val stateListener = VpnStateService.VpnStateListener {
val state = service?.state
if (state == VpnStateService.State.CONNECTED && lastState != VpnStateService.State.CONNECTED && config.siteRules.isNotEmpty())
{
refreshSites()
}
lastState = state
}
private val connection = object : ServiceConnection
{
override fun onServiceConnected(name: ComponentName?, binder: IBinder?)
{
service = (binder as VpnStateService.LocalBinder).service
service?.registerListener(stateListener)
stateListener.stateChanged()
}
override fun onServiceDisconnected(name: ComponentName?)
@@ -159,6 +202,10 @@ class RoutingManager private constructor(private val context: Context)
{
override fun onReceive(context: Context?, intent: Intent?)
{
if (intent?.action == Intent.ACTION_PACKAGE_ADDED && !intent.getBooleanExtra(Intent.EXTRA_REPLACING, false))
{
intent.data?.schemeSpecificPart?.let { suggestDirect(it) }
}
invalidateCache()
/* a full PackageManager scan: never on the main thread */
scope.launch(Dispatchers.Default) { computeExcludedPackages() }
@@ -182,6 +229,8 @@ class RoutingManager private constructor(private val context: Context)
addDataScheme("package")
}
context.registerReceiver(packagesReceiver, filter)
/* resolve the site rules once so the Rules screen shows their addresses */
refreshSites()
}
fun updateConfig(transform: (RoutingConfig) -> RoutingConfig)
@@ -207,6 +256,34 @@ class RoutingManager private constructor(private val context: Context)
}
}
/**
* A new app that a built-in RU category would cover, while that category
* is off and the app is not direct: ask in «Events» whether it should be.
*/
private fun suggestDirect(pkg: String)
{
val current = config
if (!current.enabled || pkg == context.packageName || pkg in current.directPackages || pkg in current.tunnelOverrides)
{
return
}
val category = RoutingPresets.CATEGORIES.firstOrNull { RoutingPresets.matchesCategory(it, pkg) } ?: return
if (category in current.enabledCategories || templateMatch(pkg, current) != null)
{
return
}
val pm = context.packageManager
val label = try
{
pm.getApplicationInfo(pkg, 0).loadLabel(pm).toString()
}
catch (_: PackageManager.NameNotFoundException)
{
return
}
org.strongswan.android.logic.events.Events.newRuApp(context, pkg, label)
}
/* ------------------------- UI-facing helpers ------------------------- */
fun setGlobalEnabled(enabled: Boolean)
@@ -284,7 +361,13 @@ class RoutingManager private constructor(private val context: Context)
fun removePattern(pattern: String)
{
updateConfig { it.copy(packagePatterns = it.packagePatterns - pattern) }
updateConfig { it.copy(packagePatterns = it.packagePatterns - pattern, disabledPatterns = it.disabledPatterns - pattern) }
}
/** Switches a package pattern off (kept in the list) or back on. */
fun setPatternEnabled(pattern: String, enabled: Boolean)
{
updateConfig { it.copy(disabledPatterns = if (enabled) it.disabledPatterns - pattern else it.disabledPatterns + pattern) }
}
/**
@@ -359,10 +442,109 @@ class RoutingManager private constructor(private val context: Context)
/**
* Enabled force-tunnel prefixes as validated CIDR strings (userspace
* routing feeds them into the native force set).
* routing feeds them into the native force set), including the
* addresses of "through the VPN" site rules.
*/
fun forceTunnelCidrs(): List<String> =
config.forceTunnelPrefixes.filter { it.enabled }.map { it.prefix }
config.forceTunnelPrefixes.filter { it.enabled }.map { it.prefix } + siteCidrs(direct = false)
/** Enabled direct addresses plus the addresses of "direct" site rules. */
fun directCidrs(): List<String> =
config.directPrefixes.filter { it.enabled }.map { it.prefix } + siteCidrs(direct = true)
private fun siteCidrs(direct: Boolean): List<String>
{
val resolved = siteAddresses
return config.siteRules.filter { it.enabled && it.direct == direct }.flatMap { resolved[it.domain].orEmpty() }
}
/* ------------------------- direct addresses ------------------------- */
/** Adds a direct address/network; null if invalid. */
fun addDirectPrefix(cidr: String): String?
{
val normalized = normalizePrefix(cidr) ?: return null
val id = UUID.randomUUID().toString()
updateConfigNoReconnect { config ->
config.copy(directPrefixes = config.directPrefixes + ForceTunnelPrefix(id = id, prefix = normalized))
}
return id
}
fun deleteDirectPrefix(id: String)
{
updateConfigNoReconnect { config -> config.copy(directPrefixes = config.directPrefixes.filter { it.id != id }) }
}
fun setDirectPrefixEnabled(id: String, enabled: Boolean)
{
updateConfigNoReconnect { config ->
config.copy(directPrefixes = config.directPrefixes.map { if (it.id == id) it.copy(enabled = enabled) else it })
}
}
/* ------------------------- site rules ------------------------- */
/** Adds (or replaces) the rule for [input]'s domain; null if it is not a domain. */
fun addSiteRule(input: String, direct: Boolean): String?
{
val domain = normalizeDomain(input) ?: return null
val id = UUID.randomUUID().toString()
updateConfigNoReconnect { config ->
config.copy(siteRules = config.siteRules.filter { it.domain != domain } + SiteRule(id, domain, direct))
}
refreshSites()
return id
}
fun deleteSiteRule(id: String)
{
updateConfigNoReconnect { config -> config.copy(siteRules = config.siteRules.filter { it.id != id }) }
}
fun setSiteRuleEnabled(id: String, enabled: Boolean)
{
updateConfigNoReconnect { config ->
config.copy(siteRules = config.siteRules.map { if (it.id == id) it.copy(enabled = enabled) else it })
}
}
/**
* Resolves the site rules again and pushes the result to the engine.
* Called when a rule is added and when the tunnel comes up; repeats every
* [SITE_REFRESH_MS] while there are rules and the VPN is connected.
*/
fun refreshSites()
{
siteRefreshJob?.cancel()
if (config.siteRules.isEmpty())
{
siteAddresses = emptyMap()
_siteAddresses.value = emptyMap()
return
}
siteRefreshJob = scope.launch {
while (true)
{
val domains = config.siteRules.map { it.domain }.distinct()
val previous = siteAddresses
val resolved = withContext(Dispatchers.IO) { domains.associateWith { resolveSite(it) } }
/* keep the previous answer for a domain that failed to resolve this time */
val merged = resolved.mapValues { (domain, cidrs) -> cidrs.ifEmpty { previous[domain].orEmpty() } }
if (merged != previous)
{
siteAddresses = merged
_siteAddresses.value = merged
pushUserspaceRouting()
}
if (service?.state != VpnStateService.State.CONNECTED)
{
break
}
delay(SITE_REFRESH_MS)
}
}
}
/**
* Like [updateConfig], but force prefixes apply on the fly through the
@@ -474,12 +656,26 @@ class RoutingManager private constructor(private val context: Context)
tunnelOverrides = obj.optJSONArray("tunnelOverrides").toStringSet(),
enabledCategories = obj.optJSONArray("enabledCategories").toStringSet(),
packagePatterns = obj.optJSONArray("packagePatterns").toStringSet(),
disabledPatterns = obj.optJSONArray("disabledPatterns").toStringSet(),
forceTunnelPrefixes = parseForcePrefixes(obj),
directPrefixes = parseForcePrefixes(obj, "directPrefixes"),
siteRules = obj.optJSONArray("siteRules")?.let { arr ->
(0 until arr.length()).mapNotNull { i ->
arr.optJSONObject(i)?.let { o ->
SiteRule(
id = o.optString("id"),
domain = o.optString("domain"),
direct = o.optBoolean("direct"),
enabled = o.optBoolean("enabled", true),
).takeIf { it.domain.isNotBlank() }
}
}
} ?: emptyList(),
version = CONFIG_VERSION,
)
private fun parseForcePrefixes(obj: JSONObject): List<ForceTunnelPrefix> =
obj.optJSONArray("forceTunnelPrefixes")?.let { arr ->
private fun parseForcePrefixes(obj: JSONObject, key: String = "forceTunnelPrefixes"): List<ForceTunnelPrefix> =
obj.optJSONArray(key)?.let { arr ->
(0 until arr.length()).mapNotNull { i ->
arr.optJSONObject(i)?.let { o ->
ForceTunnelPrefix(
@@ -597,6 +793,7 @@ class RoutingManager private constructor(private val context: Context)
obj.put("tunnelOverrides", JSONArray(config.tunnelOverrides.sorted()))
obj.put("enabledCategories", JSONArray(config.enabledCategories.sorted()))
obj.put("packagePatterns", JSONArray(config.packagePatterns.sorted()))
obj.put("disabledPatterns", JSONArray(config.disabledPatterns.sorted()))
val forcePrefixes = JSONArray()
for (entry in config.forceTunnelPrefixes)
{
@@ -607,6 +804,18 @@ class RoutingManager private constructor(private val context: Context)
forcePrefixes.put(o)
}
obj.put("forceTunnelPrefixes", forcePrefixes)
val directPrefixes = JSONArray()
for (entry in config.directPrefixes)
{
directPrefixes.put(JSONObject().put("id", entry.id).put("prefix", entry.prefix).put("enabled", entry.enabled))
}
obj.put("directPrefixes", directPrefixes)
val sites = JSONArray()
for (rule in config.siteRules)
{
sites.put(JSONObject().put("id", rule.id).put("domain", rule.domain).put("direct", rule.direct).put("enabled", rule.enabled))
}
obj.put("siteRules", sites)
val editor = prefs.edit().putString(KEY_CONFIG, obj.toString())
if (commit) editor.commit() else editor.apply()
}
@@ -623,6 +832,7 @@ class RoutingManager private constructor(private val context: Context)
private const val KEY_PROFILES_MIGRATED = "profiles_migrated_v2"
private const val KEY_NOTICE_ONLY = "notice_only_profiles"
private const val RECONNECT_DEBOUNCE_MS = 2000L
const val SITE_REFRESH_MS = 30L * 60 * 1000
/** Config schema: 1 = rule sets, 2 = direct apps + categories + patterns. */
const val CONFIG_VERSION = 2
@@ -637,6 +847,37 @@ class RoutingManager private constructor(private val context: Context)
null
}
/**
* "https://www.Example.com/path" -> "example.com"; null if the input
* is not a plausible host name (an address belongs in an address rule).
*/
fun normalizeDomain(input: String): String?
{
var host = input.trim().lowercase()
host = host.substringAfter("://").substringBefore('/').substringBefore('?').substringBefore(':')
host = host.removePrefix("*.").removePrefix("www.").trimEnd('.')
val label = Regex("[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?")
val labels = host.split('.')
if (labels.size < 2 || labels.any { !label.matches(it) } || labels.last().all { it.isDigit() })
{
return null
}
return host
}
/** The addresses [domain] and www.[domain] resolve to, as host CIDRs. */
private fun resolveSite(domain: String): List<String> =
listOf(domain, "www.$domain").flatMap { name ->
try
{
java.net.InetAddress.getAllByName(name).toList()
}
catch (_: Exception)
{
emptyList()
}
}.map { it.hostAddress + if (it is java.net.Inet6Address) "/128" else "/32" }.distinct()
/** All installed apps that hold the INTERNET permission. */
fun loadInternetPackages(pm: PackageManager): Set<String> = try
{
@@ -673,7 +914,7 @@ class RoutingManager private constructor(private val context: Context)
*/
fun templateMatch(pkg: String, config: RoutingConfig): TemplateMatch?
{
val pattern = config.packagePatterns.firstOrNull { PackagePatterns.matches(it, pkg) }
val pattern = (config.packagePatterns - config.disabledPatterns).firstOrNull { PackagePatterns.matches(it, pkg) }
val category = matchingCategory(pkg, config)
return if (category == null && pattern == null) null else TemplateMatch(category, pattern)
}
@@ -688,7 +929,7 @@ class RoutingManager private constructor(private val context: Context)
{
val result = HashSet<String>()
result.addAll(if (installed.isEmpty()) config.directPackages else config.directPackages.filter { it in installed })
result.addAll(PackagePatterns.matchAll(config.packagePatterns, installed))
result.addAll(PackagePatterns.matchAll(config.packagePatterns - config.disabledPatterns, installed))
for (category in config.enabledCategories)
{
val presets = RoutingPresets.presetsOf(category)
@@ -0,0 +1,52 @@
/*
* Copyright (C) 2026 SHX VPN
*
* This program is free software; you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the
* Free Software Foundation; either version 2 of the License, or (at your
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
*
* This program is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
* for more details.
*/
package org.strongswan.android.ui
import android.content.Intent
import android.os.Bundle
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.ui.Modifier
import org.strongswan.android.ui.compose.DiagnosticsScreen
import org.strongswan.android.ui.compose.RouteCheckMode
import org.strongswan.android.ui.compose.theme.LocalShxColors
import org.strongswan.android.ui.compose.theme.ShxTheme
/** «Diagnostics», opened from the pulse button on Home and from the alert states. */
class DiagnosticsActivity : AppCompatActivity()
{
override fun onCreate(savedInstanceState: Bundle?)
{
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
ShxTheme {
Box(Modifier.fillMaxSize().background(LocalShxColors.current.background))
{
DiagnosticsScreen(
onBack = { finish() },
onLog = { startActivity(Intent(this@DiagnosticsActivity, LogActivity::class.java)) },
onCheckSite = { site -> startActivity(RouteCheckActivity.intent(this@DiagnosticsActivity, RouteCheckMode.SITE, site)) },
onCheckApp = { pkg -> startActivity(RouteCheckActivity.intent(this@DiagnosticsActivity, RouteCheckMode.APP, pkg)) },
)
}
}
}
}
}
@@ -22,15 +22,11 @@ import android.provider.Settings
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.padding
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import org.strongswan.android.R
import org.strongswan.android.logic.events.EventAction
import org.strongswan.android.logic.events.EventLog
import org.strongswan.android.logic.events.ShxEvent
import org.strongswan.android.logic.routing.RoutingManager
import org.strongswan.android.ui.compose.InboxScreen
import org.strongswan.android.ui.compose.SecondaryScaffold
import org.strongswan.android.ui.compose.theme.ShxTheme
/** «Events»: what the app noticed and did by itself, opened from the bell on Home and from problem notifications. */
@@ -42,11 +38,7 @@ class InboxActivity : AppCompatActivity()
enableEdgeToEdge()
setContent {
ShxTheme {
SecondaryScaffold(title = stringResource(R.string.events_title), onBack = { finish() }) { padding ->
Box(Modifier.padding(padding)) {
InboxScreen(onAction = ::perform, onNotificationSettings = ::openNotificationSettings)
}
}
InboxScreen(onBack = { finish() }, onAction = ::perform, onNotificationSettings = ::openNotificationSettings)
}
}
}
@@ -58,12 +50,19 @@ class InboxActivity : AppCompatActivity()
super.onStop()
}
private fun perform(action: EventAction)
private fun perform(event: ShxEvent)
{
val action = event.action
if (action == EventAction.SET_APP_DIRECT)
{
event.target?.let { RoutingManager.getInstance(this).setAppDirect(it, true) }
EventLog.getInstance(this).answer(event.id)
return
}
val main = Intent(this, MainActivity::class.java).addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP)
when (action)
{
EventAction.NONE -> return
EventAction.NONE, EventAction.SET_APP_DIRECT -> return
EventAction.OPEN_HOME -> startActivity(main)
EventAction.OPEN_ROUTING -> startActivity(main.putExtra(MainActivity.EXTRA_OPEN_ROUTING, true))
EventAction.INSTALL_UPDATE -> startActivity(main.putExtra(MainActivity.EXTRA_INSTALL_UPDATE, true))
@@ -14,34 +14,46 @@
package org.strongswan.android.ui
import android.content.Context
import android.content.Intent
import android.os.Bundle
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import org.strongswan.android.R
import org.strongswan.android.ui.compose.RouteCheckMode
import org.strongswan.android.ui.compose.RouteCheckScreen
import org.strongswan.android.ui.compose.SecondaryScaffold
import org.strongswan.android.ui.compose.theme.LocalShxColors
import org.strongswan.android.ui.compose.theme.ShxTheme
/** Standalone route diagnostics screen, opened from the Routing tab. */
/** «How traffic goes»: route of a site, an address or an app. */
class RouteCheckActivity : AppCompatActivity()
{
override fun onCreate(savedInstanceState: Bundle?)
{
super.onCreate(savedInstanceState)
enableEdgeToEdge()
val mode = RouteCheckMode.entries.getOrElse(intent.getIntExtra(EXTRA_MODE, 0)) { RouteCheckMode.SITE }
val query = intent.getStringExtra(EXTRA_QUERY).orEmpty()
setContent {
ShxTheme {
SecondaryScaffold(title = stringResource(R.string.route_check_title), onBack = { finish() }) { padding ->
Box(Modifier.padding(padding)) {
RouteCheckScreen()
}
Box(Modifier.fillMaxSize().background(LocalShxColors.current.background))
{
RouteCheckScreen(onBack = { finish() }, initialMode = mode, initialQuery = query)
}
}
}
}
companion object
{
private const val EXTRA_MODE = "mode"
private const val EXTRA_QUERY = "query"
fun intent(context: Context, mode: RouteCheckMode, query: String): Intent =
Intent(context, RouteCheckActivity::class.java).putExtra(EXTRA_MODE, mode.ordinal).putExtra(EXTRA_QUERY, query)
}
}
@@ -0,0 +1,407 @@
/*
* Copyright (C) 2026 SHX VPN
*
* This program is free software; you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the
* Free Software Foundation; either version 2 of the License, or (at your
* option) any later version.
*/
package org.strongswan.android.ui.compose
import android.content.Context
import android.net.ConnectivityManager
import android.net.NetworkCapabilities
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.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
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.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
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.compose.LocalLifecycleOwner
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.repeatOnLifecycle
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
import org.strongswan.android.R
import org.strongswan.android.logic.CharonVpnService
import org.strongswan.android.logic.VpnStateService
import org.strongswan.android.logic.connectivity.ConnectivityProbe
import org.strongswan.android.logic.connectivity.ProbeTarget
import org.strongswan.android.logic.connectivity.WhiteListManager
import org.strongswan.android.logic.connectivity.WhiteListStatus
import org.strongswan.android.logic.routing.AppTraffic
import org.strongswan.android.logic.routing.RoutingManager
import org.strongswan.android.ui.compose.design.ShxButton
import org.strongswan.android.ui.compose.design.ShxButtonStyle
import org.strongswan.android.ui.compose.design.ShxIcons
import org.strongswan.android.ui.compose.design.ShxPageHeader
import org.strongswan.android.ui.compose.design.ShxPanel
import org.strongswan.android.ui.compose.design.ShxSectionHeader
import org.strongswan.android.ui.compose.routing.AppIcon
import org.strongswan.android.ui.compose.theme.LocalShxColors
import org.strongswan.android.ui.compose.theme.ShxType
/** One line of the check list. */
data class DiagStep(val title: String, val value: String, val ok: Boolean?)
/** Result of a diagnostics round. */
data class DiagReport(val steps: List<DiagStep>, val at: Long, val vpnUp: Boolean)
{
/** null = still checking, true = everything answered */
val allOk: Boolean? get() = if (steps.any { it.ok == null }) null else steps.all { it.ok == true }
}
private suspend fun runDiagnostics(context: Context): DiagReport = coroutineScope {
val vpn = VpnStateService.getLiveInstance()
val vpnUp = vpn?.state == VpnStateService.State.CONNECTED
val gateway = (vpn?.profile)?.gateway
val whiteList = WhiteListManager.getInstance(context)
whiteList.checkNow()
val server = async { if (gateway != null) ConnectivityProbe.probe(ProbeTarget.SERVER, 3000, context, gateway) else null }
val foreign = async { ConnectivityProbe.probe(ProbeTarget.GOOGLE, 3000, context, null) }
val network = withContext(Dispatchers.Default) { networkLabel(context) }
/* the whitelist check runs its own direct round; give it a moment */
delay(2500)
val wl = whiteList.state.value
val serverResult = server.await()
val foreignResult = foreign.await()
val steps = listOf(
DiagStep(context.getString(R.string.diag_step_internet), network ?: context.getString(R.string.diag_none), network != null),
DiagStep(
context.getString(R.string.diag_step_ru),
when (wl.domesticDirectOk)
{
true -> wl.domesticDirectRttMs?.let { context.getString(R.string.diag_ms, it) } ?: context.getString(R.string.diag_ok)
false -> context.getString(R.string.diag_fail)
null -> context.getString(R.string.diag_unknown)
},
wl.domesticDirectOk ?: (network != null),
),
DiagStep(
context.getString(R.string.diag_step_foreign),
when
{
!foreignResult.ok -> if (wl.status == WhiteListStatus.ACTIVE) context.getString(R.string.diag_whitelist) else context.getString(R.string.diag_fail)
vpnUp -> context.getString(R.string.diag_via_vpn_ms, foreignResult.rttMs ?: 0)
else -> context.getString(R.string.diag_direct_ms, foreignResult.rttMs ?: 0)
},
foreignResult.ok,
),
DiagStep(
context.getString(R.string.diag_step_server),
when
{
gateway == null -> context.getString(R.string.diag_no_server)
serverResult?.ok == true -> context.getString(R.string.diag_ms, serverResult.rttMs ?: 0)
else -> context.getString(R.string.diag_no_answer)
},
if (gateway == null) network != null else serverResult?.ok == true,
),
DiagStep(
context.getString(R.string.diag_step_dns),
context.getString(if (vpnUp) R.string.diag_dns_tunnel else R.string.diag_dns_provider),
if (vpnUp) foreignResult.ok else network != null,
),
)
DiagReport(steps, System.currentTimeMillis(), vpnUp)
}
private fun networkLabel(context: Context): String?
{
val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager ?: return null
var wifi = false
var cell = false
for (n in cm.allNetworks)
{
val caps = runCatching { cm.getNetworkCapabilities(n) }.getOrNull() ?: continue
if (caps.hasTransport(NetworkCapabilities.TRANSPORT_VPN))
{
continue
}
if (caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)) wifi = true
if (caps.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR)) cell = true
}
return when
{
wifi -> context.getString(R.string.network_wifi)
cell -> context.getString(R.string.network_cellular)
else -> null
}
}
/** «Diagnostics» (F_Diag): one look at the whole path, what does not open, unreachable hosts. */
@Composable
fun DiagnosticsScreen(
onBack: () -> Unit,
onLog: () -> Unit,
onCheckSite: (String) -> Unit,
onCheckApp: (String) -> Unit,
)
{
val context = LocalContext.current
val c = LocalShxColors.current
var round by remember { mutableIntStateOf(0) }
val report by produceState<DiagReport?>(null, round) {
value = null
value = runDiagnostics(context)
}
val lifecycle = LocalLifecycleOwner.current.lifecycle
val dead by produceState(emptyList<DeadTargetUi>(), lifecycle) {
lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
while (true)
{
val raw = runCatching { withContext(Dispatchers.Default) { CharonVpnService.getDeadDirectTargets() } }.getOrNull()
value = HomeViewModel.parseDeadTargets(raw).filter { !it.viaTunnel }
delay(5_000)
}
}
}
val topApps by produceState(emptyList<String>()) {
value = withContext(Dispatchers.IO) {
AppTraffic.since(context, System.currentTimeMillis() - 60L * 60 * 1000).entries
.sortedByDescending { it.value }.map { it.key }.take(4)
}
}
var site by remember { mutableStateOf("") }
val manager = remember { RoutingManager.getInstance(context) }
Column(Modifier.fillMaxSize().statusBarsPadding().verticalScroll(rememberScrollState()))
{
ShxPageHeader(stringResource(R.string.diag_title), onBack)
{
ShxButton(stringResource(R.string.diag_log), onLog, style = ShxButtonStyle.TONAL, height = 40.dp)
}
Column(Modifier.padding(start = 16.dp, end = 16.dp, top = 4.dp, bottom = 24.dp), verticalArrangement = Arrangement.spacedBy(14.dp))
{
Verdict(report, onRepeat = { round++ })
ShxPanel(padding = androidx.compose.foundation.layout.PaddingValues(horizontal = 16.dp, vertical = 4.dp))
{
val steps = report?.steps ?: listOf(
R.string.diag_step_internet, R.string.diag_step_ru, R.string.diag_step_foreign, R.string.diag_step_server, R.string.diag_step_dns,
).map { DiagStep(context.getString(it), context.getString(R.string.diag_checking), null) }
steps.forEachIndexed { i, step ->
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.fillMaxWidth().height(50.dp))
{
val (bg, fg, mark) = when (step.ok)
{
true -> Triple(c.successContainer, c.onSuccess, "✓")
false -> Triple(c.errorContainer, c.onErrorContainer, "×")
null -> Triple(c.tile, c.iconMuted, "…")
}
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(22.dp).clip(CircleShape).background(bg))
{
Text(mark, style = ShxType.meta.copy(fontWeight = FontWeight.ExtraBold), color = fg)
}
Text(step.title, style = ShxType.body.copy(fontSize = 15.sp), color = c.text, modifier = Modifier.weight(1f))
Text(step.value, style = ShxType.sub, color = c.textSub)
}
if (i < steps.lastIndex)
{
Box(Modifier.fillMaxWidth().height(1.dp).background(c.divider))
}
}
}
ShxSectionHeader(stringResource(R.string.diag_not_opening))
ShxPanel(padding = androidx.compose.foundation.layout.PaddingValues(14.dp))
{
Column(verticalArrangement = Arrangement.spacedBy(14.dp))
{
if (topApps.isNotEmpty())
{
Row(horizontalArrangement = Arrangement.spacedBy(6.dp))
{
topApps.forEach { pkg ->
val label = remember(pkg) {
runCatching { context.packageManager.let { pm -> pm.getApplicationInfo(pkg, 0).loadLabel(pm).toString() } }.getOrDefault(pkg)
}
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(5.dp),
modifier = Modifier.weight(1f).clip(RoundedCornerShape(15.dp)).clickable(role = Role.Button) { onCheckApp(pkg) },
)
{
Box(Modifier.size(48.dp).clip(RoundedCornerShape(15.dp))) { AppIcon(pkg, 48.dp) }
Text(label, style = ShxType.tag.copy(fontWeight = FontWeight.SemiBold), color = c.text, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
}
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically)
{
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.weight(1f).height(48.dp).clip(RoundedCornerShape(16.dp)).background(c.tile).padding(horizontal = 12.dp),
)
{
Icon(ShxIcons.Search, contentDescription = null, tint = c.textSub, modifier = Modifier.size(18.dp))
Box(Modifier.weight(1f))
{
if (site.isEmpty())
{
Text(stringResource(R.string.diag_site_hint), style = ShxType.body.copy(fontSize = 15.sp), color = c.textSub)
}
BasicTextField(
value = site,
onValueChange = { site = it },
singleLine = true,
textStyle = ShxType.body.copy(fontSize = 15.sp, color = c.text),
cursorBrush = SolidColor(c.accent),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri, imeAction = ImeAction.Search),
keyboardActions = KeyboardActions(onSearch = { if (site.isNotBlank()) onCheckSite(site.trim()) }),
modifier = Modifier.fillMaxWidth(),
)
}
}
ShxButton(stringResource(R.string.route_check_button), { onCheckSite(site.trim()) }, height = 48.dp, enabled = site.isNotBlank())
}
}
}
if (dead.isNotEmpty())
{
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp))
{
ShxSectionHeader(stringResource(R.string.dead_targets_title))
Text(
dead.size.toString(),
style = ShxType.tag,
color = c.onWarning,
modifier = Modifier.padding(top = 6.dp).clip(RoundedCornerShape(6.dp)).background(c.warningStrong).padding(horizontal = 7.dp, vertical = 1.dp),
)
}
ShxPanel()
{
dead.forEachIndexed { i, target ->
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
modifier = Modifier.fillMaxWidth().height(60.dp).padding(start = 16.dp, end = 12.dp),
)
{
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp))
{
Text(target.dst, style = ShxType.mono.copy(fontSize = 14.sp, fontWeight = FontWeight.Bold), color = c.text, maxLines = 1, overflow = TextOverflow.Ellipsis)
Text(
stringResource(R.string.diag_dead_meta, target.fails, formatDuration(target.leftMs / 1000)),
style = ShxType.meta.copy(fontWeight = FontWeight.Normal),
color = c.textSub,
)
}
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.height(36.dp).clip(RoundedCornerShape(12.dp)).background(c.warningStrong)
.clickable(role = Role.Button) {
val ip = target.dst.substringBeforeLast(':').removeSurrounding("[", "]")
manager.addForcePrefix(ip + if (ip.contains(':')) "/128" else "/32")
}
.padding(horizontal = 12.dp),
)
{
Text(stringResource(R.string.dead_targets_force), style = ShxType.chip, color = c.onWarningStrong)
}
}
if (i < dead.lastIndex)
{
Box(Modifier.fillMaxWidth().height(1.dp).background(c.divider))
}
}
}
}
ShxButton(stringResource(R.string.diag_send_report), onLog, style = ShxButtonStyle.OUTLINE, height = 52.dp, modifier = Modifier.fillMaxWidth())
}
}
}
/** Green / amber / grey verdict card with a big round mark. */
@Composable
private fun Verdict(report: DiagReport?, onRepeat: () -> Unit)
{
val c = LocalShxColors.current
val context = LocalContext.current
val ok = report?.allOk
val (bg, mark, markFg, titleColor, subColor) = when (ok)
{
true -> Five(c.successContainer, c.success, androidx.compose.ui.graphics.Color.White, c.onSuccessStrong, c.onSuccess)
false -> Five(c.warningStrong, c.warning, androidx.compose.ui.graphics.Color.White, c.onWarningStrong, c.onWarningContainer)
null -> Five(c.tile, c.iconMuted, androidx.compose.ui.graphics.Color.White, c.text, c.textSub)
}
val title = when (ok)
{
true -> stringResource(if (report?.vpnUp == true) R.string.diag_verdict_ok else R.string.diag_verdict_ok_no_vpn)
false -> stringResource(R.string.diag_verdict_problem, report?.steps?.firstOrNull { it.ok == false }?.title ?: "")
null -> stringResource(R.string.diag_checking_title)
}
val sub = when (ok)
{
null -> stringResource(R.string.diag_checking_sub)
else -> stringResource(R.string.diag_checked_at, formatTimeAgo(context, report!!.at))
}
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(24.dp)).background(bg)
.clickable(enabled = ok != null, role = Role.Button, onClick = onRepeat).padding(16.dp),
)
{
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(40.dp).clip(CircleShape).background(mark))
{
Icon(if (ok == false) ShxIcons.Alert else if (ok == null) ShxIcons.Refresh else ShxIcons.Check, contentDescription = null, tint = markFg, modifier = Modifier.size(22.dp))
}
Column(verticalArrangement = Arrangement.spacedBy(2.dp))
{
Text(title, style = ShxType.cardHeading, color = titleColor)
Text(sub, style = ShxType.sub, color = subColor)
}
}
}
private data class Five<A, B, C, D, E>(val a: A, val b: B, val c: C, val d: D, val e: E)
private fun formatTimeAgo(context: Context, at: Long): String
{
val sec = ((System.currentTimeMillis() - at) / 1000).coerceAtLeast(0)
return if (sec < 60) context.getString(R.string.diag_ago_sec, sec) else context.getString(R.string.diag_ago_min, sec / 60)
}
@@ -119,6 +119,12 @@ import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.foundation.layout.fillMaxHeight
import org.strongswan.android.R
import androidx.compose.foundation.layout.widthIn
import org.strongswan.android.logic.routing.RuIpListManager
import org.strongswan.android.ui.DiagnosticsActivity
import org.strongswan.android.ui.compose.home.HomeActions
import org.strongswan.android.ui.compose.home.HomeModel
import org.strongswan.android.ui.compose.home.HomeContent
import org.strongswan.android.data.VpnProfile
import org.strongswan.android.logic.VpnStateService.ErrorState
import org.strongswan.android.logic.VpnStateService.State
@@ -141,12 +147,8 @@ import org.strongswan.android.ui.compose.home.AvailabilityCard
import org.strongswan.android.ui.compose.home.HeroConnect
import org.strongswan.android.ui.compose.home.HomeBannerSlot
import org.strongswan.android.ui.compose.home.DEFAULT_PAUSE_MINUTES
import org.strongswan.android.ui.compose.home.HomeHeader
import org.strongswan.android.ui.compose.home.HomeProblem
import org.strongswan.android.ui.compose.home.HomeQuickActions
import org.strongswan.android.ui.compose.home.PauseCard
import org.strongswan.android.ui.compose.home.PauseSheet
import org.strongswan.android.ui.compose.home.ProblemCard
import org.strongswan.android.ui.compose.home.formatClock
import org.strongswan.android.ui.compose.home.formatCountdown
import org.strongswan.android.ui.compose.home.homeProblem
@@ -229,251 +231,86 @@ fun HomeScreen(
var sheetProfile by remember { mutableStateOf<VpnProfile?>(null) }
var pickerOpen by remember { mutableStateOf(false) }
var pauseSheetOpen by remember { mutableStateOf(false) }
val networkKinds by viewModel.networkKinds.collectAsStateWithLifecycle()
val findingFastest by viewModel.findingFastest.collectAsStateWithLifecycle()
val ruState by remember { RuIpListManager.getInstance(context).state }.collectAsStateWithLifecycle()
val connected = state.vpnState == State.CONNECTED
val connecting = state.vpnState == State.CONNECTING || state.vpnState == State.DISCONNECTING
val hasError = state.error != ErrorState.NO_ERROR
val selected = state.selected
val durationSec = if (connected) traffic.durationSec else 0L
/* a pause only matters while the tunnel is actually down */
val pauseActive = pauseUntil > 0L && state.vpnState == State.DISABLED
val now = if (pauseActive) rememberNow() else 0L
val paused = pauseActive && pauseUntil > now
val problem = if (paused) null else homeProblem(state, networkAvailable)
/* "a site does not open": the route check shows which way a domain goes */
val openRouteCheck = { context.startActivity(Intent(context, RouteCheckActivity::class.java)) }
val durationSec = if (connected) traffic.durationSec else 0L
val status = LocalShxStatusColors.current
val statusText = when
{
paused -> stringResource(R.string.home_paused_title, formatCountdown(pauseUntil - now))
problem != null -> stringResource(problem.titleRes())
hasError -> stringResource(state.errorText)
connected -> stringResource(R.string.status_protected)
connecting && state.vpnState == State.DISCONNECTING -> stringResource(R.string.state_disconnecting)
connecting -> stringResource(R.string.status_connecting)
else -> stringResource(R.string.status_unprotected)
}
val statusColor = when
{
paused -> MaterialTheme.colorScheme.onSurfaceVariant
problem == HomeProblem.NO_NETWORK || problem == HomeProblem.UNREACHABLE || problem == HomeProblem.LOOKUP -> status.warning
hasError -> MaterialTheme.colorScheme.error
connected -> status.connected
connecting -> status.connecting
else -> status.disconnected
}
val subtitle = when
{
paused -> stringResource(R.string.home_paused_subtitle, formatClock(context, pauseUntil))
problem == HomeProblem.NO_NETWORK -> stringResource(R.string.home_problem_offline_subtitle)
connected -> "${selected?.name ?: ""} · ${formatDuration(durationSec)}"
connecting -> selected?.name ?: ""
else -> stringResource(R.string.status_choose_profile)
}
val now = if (pauseActive) rememberNow() else System.currentTimeMillis()
val problem = if (pauseActive && pauseUntil > now) null else homeProblem(state, networkAvailable)
val whiteListActive = whiteList.config.enabled && whiteList.status == WhiteListStatus.ACTIVE
val effects = MaterialTheme.motionScheme.defaultEffectsSpec<Float>()
val spatial = MaterialTheme.motionScheme.defaultSpatialSpec<IntSize>()
Box(Modifier.fillMaxSize())
{
StatusGlowBackground(color = statusColor, active = connected || connecting)
val wide = LocalConfiguration.current.screenWidthDp >= 840
val leftItems: LazyListScope.() -> Unit = {
item(key = "header") {
HomeHeader(unread = unreadEvents, onInbox = { context.startActivity(Intent(context, InboxActivity::class.java)) })
}
item(key = "hero") {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(top = ShxSpacing.xl),
horizontalAlignment = Alignment.CenterHorizontally,
) {
HeroConnect(
state = state.vpnState,
enabled = selected != null,
onConnect = { selected?.let { viewModel.connect(context, it) } },
onDisconnect = { viewModel.disconnect() },
onCancel = { viewModel.disconnect() },
)
Spacer(Modifier.height(ShxSpacing.lg))
/* the status is announced to TalkBack whenever it changes */
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.semantics { liveRegion = LiveRegionMode.Polite },
) {
AnimatedContent(
targetState = statusText,
transitionSpec = { fadeIn(effects) togetherWith fadeOut(effects) },
label = "statusText",
) { text ->
Text(
text = text,
style = MaterialTheme.typography.headlineMedium,
color = statusColor,
)
}
Spacer(Modifier.height(ShxSpacing.xs))
Text(
text = subtitle,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
if (state.profiles.isEmpty())
{
item(key = "empty") {
ShxCard(modifier = Modifier.fillMaxWidth().padding(horizontal = ShxSpacing.lg)) {
ShxEmptyState(
icon = Icons.Rounded.Language,
message = stringResource(R.string.no_profiles),
actionLabel = stringResource(R.string.add_profile_cta),
onAction = onAddProfile,
)
}
}
}
val lockdown = remember { context.isVpnLockdownEnabled() }
val model = HomeModel(
state = state,
traffic = traffic,
exit = exit,
serverRttMs = probes[ProbeTarget.SERVER]?.takeIf { it.ok }?.rttMs,
problem = problem,
whiteListActive = whiteListActive,
pausedUntil = if (pauseActive) pauseUntil else 0L,
now = now,
ruDirect = ruState.enabled,
lockdown = lockdown,
networkKinds = networkKinds,
unread = unreadEvents,
findingFastest = findingFastest,
)
val actions = HomeActions(
connect = { selected?.let { viewModel.connect(context, it) } },
disconnect = { viewModel.disconnect() },
retry = { viewModel.reconnect() },
resume = { viewModel.resumeNow() },
extendPause = { viewModel.extendPause(DEFAULT_PAUSE_MINUTES) },
openPause = { pauseSheetOpen = true },
fastest = { viewModel.connectFastest(context) },
pickServer = { pickerOpen = true },
addServer = onAddProfile,
editProfile = { selected?.let(onEditProfile) },
showLog = onShowLog,
inbox = { context.startActivity(Intent(context, InboxActivity::class.java)) },
diagnostics = { context.startActivity(Intent(context, DiagnosticsActivity::class.java)) },
routeCheck = { context.startActivity(Intent(context, RouteCheckActivity::class.java)) },
networkSettings = {
val panel = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q)
Intent(Settings.Panel.ACTION_INTERNET_CONNECTIVITY)
else
Intent(Settings.ACTION_WIRELESS_SETTINGS)
try
{
item(key = "chip") {
ProfileChip(
profile = selected,
exit = exit,
connected = connected,
onClick = { pickerOpen = true },
modifier = Modifier.padding(horizontal = ShxSpacing.lg).animateItem(),
)
}
context.startActivity(panel)
}
item(key = "actions") {
AnimatedVisibility(
visible = connected,
enter = fadeIn(effects) + expandVertically(spatial),
exit = fadeOut(effects) + shrinkVertically(spatial),
) {
HomeQuickActions(
onPause = { pauseSheetOpen = true },
onSwitch = { pickerOpen = true },
onDiagnose = openRouteCheck,
)
}
}
item(key = "pause") {
AnimatedVisibility(
visible = paused,
enter = fadeIn(effects) + expandVertically(spatial),
exit = fadeOut(effects) + shrinkVertically(spatial),
) {
PauseCard(
until = pauseUntil,
onResume = { viewModel.resumeNow() },
onExtend = { viewModel.extendPause(DEFAULT_PAUSE_MINUTES) },
)
}
}
item(key = "traffic") {
AnimatedVisibility(
visible = connected,
enter = fadeIn(effects) + expandVertically(spatial),
exit = fadeOut(effects) + shrinkVertically(spatial),
) {
TrafficTiles(traffic = traffic, onClick = { viewModel.openDetails(true) })
}
}
}
val rightItems: LazyListScope.() -> Unit = {
item(key = "problem") {
AnimatedVisibility(
visible = problem != null,
enter = fadeIn(effects) + expandVertically(spatial),
exit = fadeOut(effects) + shrinkVertically(spatial),
) {
/* keep the last problem while the card animates out */
val shown = remember { mutableStateOf(HomeProblem.GENERIC) }
problem?.let { shown.value = it }
ProblemCard(
problem = shown.value,
retryIn = state.retryIn,
hasOtherProfiles = state.profiles.size > 1,
onRetry = { viewModel.reconnect() },
onSwitch = { pickerOpen = true },
onEditProfile = { selected?.let(onEditProfile) },
onShowLog = onShowLog,
onNetworkSettings = {
val panel = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q)
Intent(Settings.Panel.ACTION_INTERNET_CONNECTIVITY)
else
Intent(Settings.ACTION_WIRELESS_SETTINGS)
try
{
context.startActivity(panel)
}
catch (_: android.content.ActivityNotFoundException)
{
context.startActivity(Intent(Settings.ACTION_SETTINGS))
}
},
)
}
}
item(key = "banner") {
HomeBannerSlot(
/* connection errors have their own card above */
error = false,
whiteListActive = whiteListActive,
onRetry = { viewModel.reconnect() },
onShowLog = onShowLog,
)
}
if (connected)
catch (_: android.content.ActivityNotFoundException)
{
item(key = "availability") {
AvailabilityCard(
probes = probes,
probing = probing,
whiteList = whiteList,
history = probeHistory,
deadTargets = deadTargets,
onOpenRouting = { viewModel.setTab(ShxTab.ROUTING) },
onForceTunnel = viewModel::forceTunnelDeadTarget,
onRefresh = { viewModel.refreshProbes() },
)
}
context.startActivity(Intent(Settings.ACTION_SETTINGS))
}
item(key = "space") { Spacer(Modifier.height(ShxSpacing.xl)) }
}
if (wide)
{
/* expanded width: hero and profile on the left, status blocks on the right */
Row(Modifier.fillMaxSize().statusBarsPadding())
},
vpnSettings = {
try
{
LazyColumn(
state = listState,
modifier = Modifier.weight(1f).fillMaxHeight(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(ShxSpacing.lg),
) { leftItems() }
LazyColumn(
modifier = Modifier.weight(1f).fillMaxHeight().padding(top = ShxSpacing.xl),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(ShxSpacing.lg),
) { rightItems() }
context.startActivity(Intent(Settings.ACTION_VPN_SETTINGS))
}
}
else
{
LazyColumn(
state = listState,
/* the adaptive navigation scaffold no longer pads the content area,
* so the hero offsets itself below the status bar */
modifier = Modifier.fillMaxSize().statusBarsPadding(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(ShxSpacing.lg),
)
catch (_: android.content.ActivityNotFoundException)
{
leftItems()
rightItems()
}
},
details = { viewModel.openDetails(true) },
)
LazyColumn(
state = listState,
/* the adaptive navigation scaffold no longer pads the content area,
* so the header offsets itself below the status bar */
modifier = Modifier.fillMaxSize().statusBarsPadding(),
horizontalAlignment = Alignment.CenterHorizontally,
)
{
item(key = "home") {
Box(Modifier.widthIn(max = 560.dp))
{
HomeContent(model, actions)
}
}
}
@@ -517,6 +354,14 @@ fun HomeScreen(
pauseSheetOpen = false
viewModel.pause(minutes)
},
onMorning = {
pauseSheetOpen = false
viewModel.pauseUntilMorning()
},
onNetworkChange = {
pauseSheetOpen = false
viewModel.pauseUntilNetworkChange()
},
)
}
@@ -37,6 +37,7 @@ import androidx.lifecycle.repeatOnLifecycle
import androidx.lifecycle.viewModelScope
import androidx.localbroadcastmanager.content.LocalBroadcastManager
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.delay
@@ -175,15 +176,27 @@ class HomeViewModel(app: Application) : AndroidViewModel(app), VpnStateService.V
/* whether any non-VPN network is up; tells "no internet" apart from a server problem */
private val _networkAvailable = MutableStateFlow(true)
val networkAvailable: StateFlow<Boolean> = _networkAvailable
private val underlyingNetworks = HashSet<Network>()
/* (Wi-Fi up, mobile data up) for the "no internet" checklist */
private val _networkKinds = MutableStateFlow(false to false)
val networkKinds: StateFlow<Pair<Boolean, Boolean>> = _networkKinds
private val underlyingNetworks = HashMap<Network, NetworkCapabilities?>()
private val networkCallback = object : ConnectivityManager.NetworkCallback()
{
override fun onAvailable(network: Network)
{
synchronized(underlyingNetworks)
{
underlyingNetworks.add(network)
_networkAvailable.value = true
underlyingNetworks[network] = null
publishNetworks()
}
}
override fun onCapabilitiesChanged(network: Network, capabilities: NetworkCapabilities)
{
synchronized(underlyingNetworks)
{
underlyingNetworks[network] = capabilities
publishNetworks()
}
}
@@ -192,11 +205,23 @@ class HomeViewModel(app: Application) : AndroidViewModel(app), VpnStateService.V
synchronized(underlyingNetworks)
{
underlyingNetworks.remove(network)
_networkAvailable.value = underlyingNetworks.isNotEmpty()
publishNetworks()
}
}
}
private fun publishNetworks()
{
_networkAvailable.value = underlyingNetworks.isNotEmpty()
val caps = underlyingNetworks.values.filterNotNull()
_networkKinds.value = caps.any { it.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) } to
caps.any { it.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) }
}
/* true while «Fastest» pings the servers */
private val _findingFastest = MutableStateFlow(false)
val findingFastest: StateFlow<Boolean> = _findingFastest
private var service: VpnStateService? = null
/* written from the main thread (pullState) and the poll coroutine (Default) */
@@ -440,6 +465,70 @@ class HomeViewModel(app: Application) : AndroidViewModel(app), VpnStateService.V
PauseManager.extend(getApplication(), minutes)
}
/** Pause until 07:00 (today or tomorrow). */
fun pauseUntilMorning()
{
val svc = service ?: return
val profile = _ui.value.activeProfile ?: _ui.value.selected ?: return
PauseManager.pauseUntil(getApplication(), svc, profile.getUUID().toString(), PauseManager.nextMorning(System.currentTimeMillis()))
}
/** Pause until the device moves to another network. */
fun pauseUntilNetworkChange()
{
val svc = service ?: return
val profile = _ui.value.activeProfile ?: _ui.value.selected ?: return
PauseManager.pauseUntilNetworkChange(getApplication(), svc, profile.getUUID().toString())
}
/**
* «Fastest»: pings every profile's server directly (outside the tunnel)
* and connects the one that answers first; a message if none answers.
*/
fun connectFastest(context: Context)
{
if (_findingFastest.value)
{
return
}
val profiles = _ui.value.profiles
if (profiles.isEmpty())
{
return
}
_findingFastest.value = true
viewModelScope.launch {
try
{
val rtts = coroutineScope {
profiles.map { profile ->
async {
profile to ConnectivityProbe.pingServerDirect(getApplication(), profile.gateway, FASTEST_TIMEOUT_MS)?.first
}
}.map { it.await() }
}
val best = rtts.filter { it.second != null }.minByOrNull { it.second!! }?.first
if (best == null)
{
_messages.tryEmit(R.string.home_fastest_none)
return@launch
}
val current = _ui.value.activeProfile
if (_ui.value.vpnState == State.CONNECTED && current?.getUUID() == best.getUUID())
{
_messages.tryEmit(R.string.home_fastest_already)
return@launch
}
select(best)
connect(context, best)
}
finally
{
_findingFastest.value = false
}
}
}
fun resumeNow()
{
PauseManager.resumeNow(getApplication())
@@ -747,6 +836,7 @@ class HomeViewModel(app: Application) : AndroidViewModel(app), VpnStateService.V
private const val PROBE_INTERVAL_MS = 10_000L
private const val EXIT_REFRESH_MS = 60_000L
private const val EXIT_TIMEOUT_MS = 5000
private const val FASTEST_TIMEOUT_MS = 1500
/** Parses "ip:port|fails|window_ms|left_ms|via_tunnel" entries of the native snapshot. */
fun parseDeadTargets(raw: Array<String>?): List<DeadTargetUi> =
@@ -11,33 +11,25 @@ package org.strongswan.android.ui.compose
import android.text.format.DateUtils
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.FlowRow
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.CheckCircle
import androidx.compose.material.icons.rounded.HelpOutline
import androidx.compose.material.icons.rounded.Info
import androidx.compose.material.icons.rounded.NotificationsNone
import androidx.compose.material.icons.rounded.WarningAmber
import androidx.compose.material3.Button
import androidx.compose.material3.FilterChip
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@@ -47,15 +39,16 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.luminance
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.heading
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.strongswan.android.R
import org.strongswan.android.logic.events.EventAction
@@ -63,10 +56,15 @@ 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.logic.events.ShxEvent
import org.strongswan.android.ui.compose.components.ShxEmptyState
import org.strongswan.android.ui.compose.design.ShxChip
import org.strongswan.android.ui.compose.design.ShxChipTone
import org.strongswan.android.ui.compose.design.ShxHeaderAction
import org.strongswan.android.ui.compose.design.ShxIcons
import org.strongswan.android.ui.compose.design.ShxPageHeader
import org.strongswan.android.ui.compose.design.ShxSectionHeader
import org.strongswan.android.ui.compose.home.formatClock
import org.strongswan.android.ui.compose.theme.LocalShxStatusColors
import org.strongswan.android.ui.compose.theme.ShxSpacing
import org.strongswan.android.ui.compose.theme.LocalShxColors
import org.strongswan.android.ui.compose.theme.ShxType
import java.util.Calendar
/** Inbox filters, in chip order. */
@@ -103,10 +101,12 @@ internal fun dayStart(at: Long): Long
return c.timeInMillis
}
/** «Events» (F_Inbox). */
@Composable
fun InboxScreen(onAction: (EventAction) -> Unit, onNotificationSettings: () -> Unit)
fun InboxScreen(onBack: () -> Unit, onAction: (ShxEvent) -> Unit, onNotificationSettings: () -> Unit)
{
val context = LocalContext.current
val c = LocalShxColors.current
val log = remember { EventLog.getInstance(context) }
val events by log.events.collectAsStateWithLifecycle()
var filter by rememberSaveable { mutableStateOf(InboxFilter.ALL) }
@@ -114,68 +114,77 @@ fun InboxScreen(onAction: (EventAction) -> Unit, onNotificationSettings: () -> U
val shown = remember(events, filter) { groupByDay(events.filter(filter::matches)) }
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(bottom = ShxSpacing.xxl),
verticalArrangement = Arrangement.spacedBy(ShxSpacing.sm),
modifier = Modifier.fillMaxSize().background(c.background).statusBarsPadding(),
contentPadding = PaddingValues(bottom = 28.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
)
{
item(key = "header") {
ShxPageHeader(stringResource(R.string.events_title), onBack)
{
if (events.any { !it.read })
{
ShxHeaderAction(stringResource(R.string.events_read_all)) { log.markAllRead() }
}
}
}
item(key = "filters") {
LazyRow(
horizontalArrangement = Arrangement.spacedBy(ShxSpacing.sm),
contentPadding = PaddingValues(horizontal = ShxSpacing.lg),
FlowRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 4.dp, bottom = 4.dp),
)
{
items(InboxFilter.entries.toList(), key = { it.name }) { f ->
val label = when (f)
{
InboxFilter.ALL -> stringResource(R.string.events_filter_all)
InboxFilter.WAITING -> if (waiting > 0) stringResource(R.string.events_filter_waiting_n, waiting)
else stringResource(R.string.events_filter_waiting)
InboxFilter.CONNECTION -> stringResource(R.string.events_filter_connection)
InboxFilter.ROUTING -> stringResource(R.string.events_filter_routing)
InboxFilter.APP -> stringResource(R.string.events_filter_app)
}
FilterChip(selected = filter == f, onClick = { filter = f }, label = { Text(label) })
}
ShxChip(stringResource(R.string.events_filter_all), filter == InboxFilter.ALL, { filter = InboxFilter.ALL })
ShxChip(
if (waiting > 0) stringResource(R.string.events_filter_waiting_n, waiting) else stringResource(R.string.events_filter_waiting),
filter == InboxFilter.WAITING, { filter = InboxFilter.WAITING },
tone = if (waiting > 0) ShxChipTone.WARNING else ShxChipTone.NORMAL,
)
ShxChip(stringResource(R.string.events_filter_connection), filter == InboxFilter.CONNECTION, { filter = InboxFilter.CONNECTION })
ShxChip(stringResource(R.string.events_filter_routing), filter == InboxFilter.ROUTING, { filter = InboxFilter.ROUTING })
ShxChip(stringResource(R.string.events_filter_app), filter == InboxFilter.APP, { filter = InboxFilter.APP })
}
}
if (shown.isEmpty())
{
item(key = "empty") {
ShxEmptyState(
icon = Icons.Rounded.NotificationsNone,
message = stringResource(if (events.isEmpty()) R.string.events_empty else R.string.events_empty_filter),
modifier = Modifier.padding(top = ShxSpacing.xxl),
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.fillMaxWidth().padding(horizontal = 32.dp, vertical = 48.dp),
)
{
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(56.dp).clip(RoundedCornerShape(18.dp)).background(c.accentContainer))
{
Icon(ShxIcons.Bell, contentDescription = null, tint = c.onAccentContainer, modifier = Modifier.size(26.dp))
}
Text(
stringResource(if (events.isEmpty()) R.string.events_empty else R.string.events_empty_filter),
style = ShxType.note.copy(fontSize = 14.sp),
color = c.textSoft,
textAlign = TextAlign.Center,
)
}
}
}
shown.forEach { (day, dayEvents) ->
item(key = "day-$day") {
Text(
text = dayLabel(day),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier
.padding(start = ShxSpacing.xl, end = ShxSpacing.xl, top = ShxSpacing.md)
.semantics { heading() },
)
ShxSectionHeader(dayLabel(day), Modifier.padding(horizontal = 16.dp))
}
items(dayEvents, key = { it.id }) { event ->
EventCard(event = event, onAction = { onAction(event.action) })
EventCard(event = event, onAction = { onAction(event) }, onDecline = { log.answer(event.id) })
}
}
item(key = "footer") {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.fillMaxWidth().padding(horizontal = ShxSpacing.xl, vertical = ShxSpacing.lg),
verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier.fillMaxWidth().padding(horizontal = 22.dp, vertical = 12.dp),
)
{
Text(
text = stringResource(R.string.events_footer),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
TextButton(onClick = onNotificationSettings) { Text(stringResource(R.string.events_notification_settings)) }
Text(stringResource(R.string.events_footer), style = ShxType.meta.copy(fontWeight = FontWeight.Normal), color = c.textSub, textAlign = TextAlign.Center)
ShxHeaderAction(stringResource(R.string.events_notification_settings), onNotificationSettings)
}
}
}
@@ -194,74 +203,95 @@ private fun dayLabel(day: Long): String
}
}
/** Event card of the mockup: radius 22, 36dp icon well, title 15/800 + time, text 13, small buttons. */
@Composable
private fun EventCard(event: ShxEvent, onAction: () -> Unit)
private fun EventCard(event: ShxEvent, onAction: () -> Unit, onDecline: () -> Unit)
{
val context = LocalContext.current
val status = LocalShxStatusColors.current
val scheme = MaterialTheme.colorScheme
val c = LocalShxColors.current
val warn = event.kind == EventKind.WARN && !event.resolved
val container = if (warn) status.warningContainer else scheme.surfaceContainerLow
val ask = event.kind == EventKind.ASK && !event.resolved
val (bg, border) = when
{
warn -> c.warningContainer to c.warningBorder
ask -> c.card to c.accentBorder
else -> c.card to c.cardBorder
}
val (icon: ImageVector, iconBg: Color, iconFg: Color) = when (event.kind)
{
EventKind.WARN -> Triple(Icons.Rounded.WarningAmber, status.warning, if (scheme.background.luminance() > 0.5f) Color.White else Color.Black)
EventKind.ASK -> Triple(Icons.Rounded.HelpOutline, scheme.primary, scheme.onPrimary)
EventKind.INFO -> Triple(Icons.Rounded.Info, scheme.surfaceContainerHigh, scheme.onSurfaceVariant)
EventKind.OK -> Triple(Icons.Rounded.CheckCircle, status.connectedContainer, status.onConnectedContainer)
EventKind.WARN -> Triple(ShxIcons.Alert, c.warning, Color.White)
EventKind.ASK -> Triple(if (event.category == EventCategory.ROUTING) ShxIcons.Apps else ShxIcons.Help, c.accent, Color.White)
EventKind.INFO -> Triple(
when (event.category)
{
EventCategory.APP -> ShxIcons.Download
EventCategory.ROUTING -> ShxIcons.Route
EventCategory.CONNECTION -> ShxIcons.Swap
},
c.tile, c.onAccentContainer,
)
EventKind.OK -> Triple(ShxIcons.Shield, c.successContainer, c.onSuccess)
}
Surface(
shape = MaterialTheme.shapes.extraLarge,
color = container,
val actionBg = if (warn) c.warning else c.accent
val shape = RoundedCornerShape(22.dp)
Row(
horizontalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier
.padding(horizontal = 16.dp)
.fillMaxWidth()
.padding(horizontal = ShxSpacing.lg)
.alpha(if (event.resolved) 0.7f else 1f),
.alpha(if (event.resolved) 0.7f else 1f)
.clip(shape)
.background(bg)
.border(1.dp, border, shape)
.padding(14.dp),
)
{
Row(Modifier.padding(ShxSpacing.lg), horizontalArrangement = Arrangement.spacedBy(ShxSpacing.md))
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(36.dp).clip(RoundedCornerShape(12.dp)).background(iconBg))
{
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.size(36.dp).background(iconBg, CircleShape),
)
Icon(icon, contentDescription = null, tint = iconFg, modifier = Modifier.size(18.dp))
}
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp))
{
Row(verticalAlignment = Alignment.Top, horizontalArrangement = Arrangement.spacedBy(8.dp))
{
Icon(icon, contentDescription = null, tint = iconFg, modifier = Modifier.size(20.dp))
Text(
if (event.count > 1) stringResource(R.string.events_title_count, event.title, event.count) else event.title,
style = ShxType.cardTitle.copy(fontWeight = if (event.read) FontWeight.Bold else FontWeight.ExtraBold),
color = c.text,
modifier = Modifier.weight(1f),
)
Text(formatClock(context, event.at), style = ShxType.meta.copy(fontWeight = FontWeight.Normal), color = c.textSub)
}
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(ShxSpacing.xs))
if (event.text.isNotEmpty())
{
Row(verticalAlignment = Alignment.CenterVertically)
Text(event.text, style = ShxType.note, color = c.textSoft)
}
if (event.resolved)
{
Text(stringResource(R.string.events_resolved), style = ShxType.meta.copy(fontWeight = FontWeight.ExtraBold), color = c.success)
}
else if (event.action != EventAction.NONE)
{
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.padding(top = 6.dp))
{
Text(
text = if (event.count > 1) stringResource(R.string.events_title_count, event.title, event.count) else event.title,
style = MaterialTheme.typography.titleSmall,
fontWeight = if (event.read) FontWeight.SemiBold else FontWeight.ExtraBold,
modifier = Modifier.weight(1f),
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.height(36.dp).clip(RoundedCornerShape(12.dp)).background(actionBg)
.clickable(role = Role.Button, onClick = onAction).padding(horizontal = 14.dp),
)
Spacer(Modifier.size(ShxSpacing.sm))
Text(
text = formatClock(context, event.at),
style = MaterialTheme.typography.labelMedium,
color = scheme.onSurfaceVariant,
)
}
if (event.text.isNotEmpty())
{
Text(text = event.text, style = MaterialTheme.typography.bodyMedium, color = scheme.onSurfaceVariant)
}
if (event.resolved)
{
Text(
text = stringResource(R.string.events_resolved),
style = MaterialTheme.typography.labelMedium,
color = status.connected,
fontWeight = FontWeight.Bold,
)
}
else if (event.action != EventAction.NONE)
{
Button(onClick = onAction, modifier = Modifier.padding(top = ShxSpacing.xs))
{
Text(stringResource(event.action.labelRes()))
Text(stringResource(event.action.labelRes()), style = ShxType.chip.copy(fontWeight = FontWeight.ExtraBold), color = Color.White)
}
if (event.kind == EventKind.ASK)
{
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.height(36.dp).clip(RoundedCornerShape(12.dp)).border(1.dp, c.outline, RoundedCornerShape(12.dp))
.clickable(role = Role.Button, onClick = onDecline).padding(horizontal = 12.dp),
)
{
Text(stringResource(R.string.events_action_no), style = ShxType.chip, color = c.text)
}
}
}
}
@@ -276,5 +306,6 @@ private fun EventAction.labelRes(): Int = when (this)
EventAction.OPEN_ROUTE_CHECK -> R.string.events_action_check
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.NONE -> R.string.events_action_open
}
@@ -9,175 +9,739 @@
package org.strongswan.android.ui.compose
import android.content.Context
import android.net.ConnectivityManager
import android.net.Network
import android.net.NetworkCapabilities
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.Spacer
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
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.graphics.SolidColor
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.preference.PreferenceManager
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import org.strongswan.android.ui.compose.components.ShxStatusPill
import org.strongswan.android.ui.compose.components.ShxSectionCard
import org.strongswan.android.ui.compose.theme.LocalShxStatusColors
import kotlinx.coroutines.withContext
import org.strongswan.android.R
import org.strongswan.android.logic.CharonVpnService
import org.strongswan.android.logic.VpnStateService
import org.strongswan.android.logic.routing.RouteCheck
import org.strongswan.android.ui.compose.theme.ShxSpacing
import org.strongswan.android.logic.routing.RoutingManager
import org.strongswan.android.ui.compose.design.ShxButton
import org.strongswan.android.ui.compose.design.ShxIcons
import org.strongswan.android.ui.compose.design.ShxNote
import org.strongswan.android.ui.compose.design.ShxPageHeader
import org.strongswan.android.ui.compose.design.ShxPanel
import org.strongswan.android.ui.compose.design.ShxSectionHeader
import org.strongswan.android.ui.compose.design.ShxTabs
import org.strongswan.android.ui.compose.design.ShxTone
import org.strongswan.android.ui.compose.routing.AppIcon
import org.strongswan.android.ui.compose.routing.DirectApps
import org.strongswan.android.ui.compose.routing.InstalledApp
import org.strongswan.android.ui.compose.routing.templateLabel
import org.strongswan.android.ui.compose.theme.LocalShxColors
import org.strongswan.android.ui.compose.theme.ShxType
import java.net.InetAddress
import java.net.InetSocketAddress
import java.net.Socket
/** Diagnostic: how a domain resolves and which route its addresses take. */
/** What the route check looks at. */
enum class RouteCheckMode { SITE, IP, APP }
/** TCP 443 reachability over one network: rtt in ms, or null if it did not open. */
data class Reach(val viaDefault: Long?, val direct: Long?, val vpn: Long?)
private sealed interface CheckResult
{
data class Site(val report: RouteCheck.Report, val verdicts: Map<String, RouteCheck.Verdict>, val reach: Reach) : CheckResult
data class Ip(val ip: String, val verdict: RouteCheck.Verdict, val reach: Reach) : CheckResult
data class App(val app: InstalledApp) : CheckResult
data object BadInput : CheckResult
}
private const val PREF_RECENT = "route_check_recent"
/** «How traffic goes» (F_Check): route of a site, an address or an app, why, and a one-tap change. */
@Composable
fun RouteCheckScreen()
fun RouteCheckScreen(onBack: () -> Unit, initialMode: RouteCheckMode = RouteCheckMode.SITE, initialQuery: String = "")
{
val context = LocalContext.current
val c = LocalShxColors.current
val scope = rememberCoroutineScope()
var domain by remember { mutableStateOf("") }
val manager = remember { RoutingManager.getInstance(context) }
val prefs = remember { PreferenceManager.getDefaultSharedPreferences(context) }
var mode by rememberSaveable { mutableStateOf(initialMode) }
var query by rememberSaveable { mutableStateOf(initialQuery) }
var checking by remember { mutableStateOf(false) }
var report by remember { mutableStateOf<RouteCheck.Report?>(null) }
Column(
Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(horizontal = ShxSpacing.lg, vertical = ShxSpacing.md),
)
{
Text(
text = stringResource(R.string.route_check_hint),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
OutlinedTextField(
value = domain,
onValueChange = { domain = it },
label = { Text(stringResource(R.string.route_check_field)) },
singleLine = true,
modifier = Modifier
.fillMaxWidth()
.padding(top = 10.dp),
shape = MaterialTheme.shapes.medium,
)
Button(
onClick = {
checking = true
scope.launch {
report = RouteCheck.check(
context,
domain.trim(),
CharonVpnService.sAppliedGeoExclusionsV4,
CharonVpnService.sAppliedGeoExclusionsV6,
CharonVpnService.sDnsPunchIps,
)
checking = false
}
},
enabled = domain.isNotBlank() && !checking,
shape = MaterialTheme.shapes.medium,
modifier = Modifier.padding(top = 10.dp),
)
var result by remember { mutableStateOf<CheckResult?>(null) }
var recent by remember { mutableStateOf(prefs.getString(PREF_RECENT, "")!!.split('\n').filter { it.isNotBlank() }) }
val rulesActive = remember { CharonVpnService.isUserspaceRoutingEnabled(context) }
val apps by produceState<List<InstalledApp>>(emptyList(), mode) {
if (mode == RouteCheckMode.APP && value.isEmpty())
{
Text(stringResource(if (checking) R.string.route_check_checking else R.string.route_check_button))
value = withContext(Dispatchers.IO) { DirectApps.loadInstalledApps(context.packageManager, context.packageName) }
}
report?.let { rep -> RouteCheckResult(rep) }
}
}
@Composable
private fun RouteCheckResult(report: RouteCheck.Report)
{
val status = LocalShxStatusColors.current
Column(
modifier = Modifier.padding(top = ShxSpacing.lg),
verticalArrangement = Arrangement.spacedBy(ShxSpacing.md),
)
fun rememberQuery(q: String)
{
if (!report.vpnNetworkFound)
recent = (listOf(q) + recent.filter { it != q }).take(4)
prefs.edit().putString(PREF_RECENT, recent.joinToString("\n")).apply()
}
fun run()
{
val input = query.trim()
if (input.isEmpty())
{
ShxStatusPill(
text = stringResource(R.string.route_check_no_vpn),
containerColor = status.disconnectedContainer,
contentColor = status.onDisconnectedContainer,
shape = MaterialTheme.shapes.small,
style = MaterialTheme.typography.labelMedium,
)
return
}
ShxSectionCard(title = stringResource(R.string.route_check_dns_section))
{
RouteCheckRow(stringResource(R.string.route_check_via_vpn_dns), report.vpnDnsIps)
RouteCheckRow(stringResource(R.string.route_check_via_direct_dns), report.directDnsIps)
}
ShxSectionCard(title = stringResource(R.string.route_check_routes))
{
for (route in report.routes)
checking = true
scope.launch {
result = when (mode)
{
val label = stringResource(
when (route.kind)
RouteCheckMode.SITE -> checkSite(context, manager, input, rulesActive)
RouteCheckMode.IP -> checkIp(context, manager, input, rulesActive)
RouteCheckMode.APP -> apps.firstOrNull { it.packageName == input || it.label.equals(input, ignoreCase = true) }
?.let { CheckResult.App(it) } ?: CheckResult.BadInput
}
if (result !is CheckResult.BadInput)
{
rememberQuery(if (result is CheckResult.App) (result as CheckResult.App).app.label else input)
}
checking = false
}
}
fun rerun(wait: Long)
{
scope.launch {
delay(wait)
run()
}
}
LaunchedEffect(Unit)
{
if (initialQuery.isNotBlank())
{
if (mode == RouteCheckMode.APP)
{
delay(300)
}
run()
}
}
Column(Modifier.fillMaxSize().statusBarsPadding().verticalScroll(rememberScrollState()))
{
ShxPageHeader(stringResource(R.string.route_check_title), onBack)
Column(Modifier.padding(start = 16.dp, end = 16.dp, top = 4.dp, bottom = 24.dp), verticalArrangement = Arrangement.spacedBy(12.dp))
{
ShxTabs(
options = listOf(
stringResource(R.string.route_check_mode_site),
stringResource(R.string.route_check_mode_ip),
stringResource(R.string.route_check_mode_app),
),
selected = mode.ordinal,
onSelect = {
val m = RouteCheckMode.entries[it]
if (m != mode)
{
RouteCheck.RouteKind.TUNNEL -> R.string.route_verdict_tunnel
RouteCheck.RouteKind.DIRECT_RU -> R.string.route_verdict_direct
RouteCheck.RouteKind.TUNNEL_DNS -> R.string.route_verdict_dns
},
)
val direct = route.kind == RouteCheck.RouteKind.DIRECT_RU
mode = m
query = ""
result = null
}
},
)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically)
{
val shape = RoundedCornerShape(16.dp)
Row(
verticalAlignment = androidx.compose.ui.Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(ShxSpacing.md),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.weight(1f).height(52.dp).clip(shape).background(c.card).border(2.dp, c.accent, shape).padding(horizontal = 14.dp),
)
{
Text(
text = route.ip,
style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace,
modifier = Modifier.weight(1f),
Icon(
when (mode)
{
RouteCheckMode.SITE -> ShxIcons.Globe
RouteCheckMode.IP -> ShxIcons.Route
RouteCheckMode.APP -> ShxIcons.Apps
},
contentDescription = null, tint = c.textSub, modifier = Modifier.size(18.dp),
)
ShxStatusPill(
text = label,
containerColor = if (direct) status.connectingContainer else status.connectedContainer,
contentColor = if (direct) status.onConnectingContainer else status.onConnectedContainer,
Box(Modifier.weight(1f))
{
if (query.isEmpty())
{
Text(
stringResource(
when (mode)
{
RouteCheckMode.SITE -> R.string.route_check_field
RouteCheckMode.IP -> R.string.route_check_field_ip
RouteCheckMode.APP -> R.string.route_check_field_app
}
),
style = ShxType.body.copy(fontSize = 16.sp),
color = c.textSub,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
BasicTextField(
value = query,
onValueChange = {
query = it
if (mode == RouteCheckMode.APP)
{
result = null
}
},
singleLine = true,
textStyle = ShxType.body.copy(fontSize = 16.sp, fontWeight = FontWeight.Bold, color = c.text),
cursorBrush = SolidColor(c.accent),
keyboardOptions = KeyboardOptions(
keyboardType = when (mode)
{
RouteCheckMode.SITE -> KeyboardType.Uri
RouteCheckMode.IP -> KeyboardType.Ascii
RouteCheckMode.APP -> KeyboardType.Text
},
imeAction = ImeAction.Search,
),
keyboardActions = KeyboardActions(onSearch = { run() }),
modifier = Modifier.fillMaxWidth(),
)
}
}
if (mode != RouteCheckMode.APP)
{
ShxButton(
stringResource(if (checking) R.string.route_check_checking else R.string.route_check_button),
::run, height = 52.dp, enabled = query.isNotBlank() && !checking,
)
}
}
if (report.routes.isEmpty())
if (recent.isNotEmpty() && result == null && query.isEmpty())
{
FlowRow(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalArrangement = Arrangement.spacedBy(6.dp))
{
recent.forEach { item ->
val shape = RoundedCornerShape(10.dp)
Text(
item,
style = ShxType.meta,
color = c.textSoft,
modifier = Modifier
.clip(shape)
.border(1.dp, c.outline, shape)
.clickable(role = Role.Button) {
query = item
run()
}
.padding(horizontal = 12.dp, vertical = 8.dp),
)
}
}
}
if (mode == RouteCheckMode.APP && result == null && query.isNotBlank())
{
AppSuggestions(apps, query) { app ->
query = app.label
result = CheckResult.App(app)
rememberQuery(app.label)
}
}
when (val r = result)
{
is CheckResult.Site -> SiteResult(r, rulesActive, onAlways = { direct ->
manager.addSiteRule(r.report.domain, direct)
rerun(1500)
})
is CheckResult.Ip -> IpResult(r, rulesActive, onAlways = { direct ->
if (direct) manager.addDirectPrefix(r.ip) else manager.addForcePrefix(r.ip)
rerun(300)
})
is CheckResult.App -> AppResult(r.app, manager)
CheckResult.BadInput -> ShxNote(
stringResource(
when (mode)
{
RouteCheckMode.IP -> R.string.route_check_bad_ip
RouteCheckMode.APP -> R.string.route_check_bad_app
RouteCheckMode.SITE -> R.string.route_check_resolve_failed
}
),
tone = ShxTone.ERROR,
)
null -> if (query.isEmpty()) Text(stringResource(R.string.route_check_hint), style = ShxType.note, color = c.textSub, modifier = Modifier.padding(horizontal = 6.dp))
}
}
}
}
/* ------------------------------ checks ------------------------------ */
private fun networks(context: Context): Pair<Network?, Network?>
{
val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager ?: return null to null
var vpn: Network? = null
var direct: Network? = null
for (n in cm.allNetworks)
{
val caps = runCatching { cm.getNetworkCapabilities(n) }.getOrNull() ?: continue
if (caps.hasTransport(NetworkCapabilities.TRANSPORT_VPN))
{
vpn = n
}
else if (caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) && direct == null)
{
direct = n
}
}
return vpn to direct
}
/** TCP connect to ip:443; through [network] when given, else the app's default route. */
private fun connectMs(ip: String, network: Network?): Long? = try
{
val socket = network?.socketFactory?.createSocket() ?: Socket()
socket.use {
val start = System.nanoTime()
it.connect(InetSocketAddress(InetAddress.getByName(ip), 443), 3000)
(System.nanoTime() - start) / 1_000_000
}
}
catch (_: Exception)
{
null
}
private suspend fun reach(context: Context, ip: String?): Reach = coroutineScope {
if (ip == null)
{
return@coroutineScope Reach(null, null, null)
}
val (vpn, direct) = networks(context)
val d = async(Dispatchers.IO) { connectMs(ip, null) }
val dir = async(Dispatchers.IO) { direct?.let { connectMs(ip, it) } }
val v = async(Dispatchers.IO) { vpn?.let { connectMs(ip, it) } }
Reach(d.await(), dir.await(), v.await())
}
private suspend fun checkSite(context: Context, manager: RoutingManager, input: String, rulesActive: Boolean): CheckResult
{
val domain = RoutingManager.normalizeDomain(input) ?: return CheckResult.BadInput
val report = RouteCheck.check(
context, domain,
CharonVpnService.sAppliedGeoExclusionsV4,
CharonVpnService.sAppliedGeoExclusionsV6,
CharonVpnService.sDnsPunchIps,
)
val force = RouteCheck.ranges(manager.forceTunnelCidrs())
val direct = RouteCheck.ranges(manager.directCidrs())
val verdicts = withContext(Dispatchers.Default) {
report.routes.associate { route ->
route.ip to RouteCheck.verdict(
InetAddress.getByName(route.ip),
CharonVpnService.sAppliedGeoExclusionsV4, CharonVpnService.sAppliedGeoExclusionsV6,
CharonVpnService.sDnsPunchIps, force, direct, rulesActive,
)
}
}
return CheckResult.Site(report, verdicts, reach(context, report.vpnDnsIps.firstOrNull() ?: report.routes.firstOrNull()?.ip))
}
private suspend fun checkIp(context: Context, manager: RoutingManager, input: String, rulesActive: Boolean): CheckResult
{
val ip = input.substringBefore('/').trim()
/* only literal addresses: getByName would otherwise resolve a host name */
if (!ip.matches(Regex("[0-9.]+")) && !ip.contains(':'))
{
return CheckResult.BadInput
}
val address = withContext(Dispatchers.IO) { runCatching { InetAddress.getByName(ip) }.getOrNull() } ?: return CheckResult.BadInput
val verdict = RouteCheck.verdict(
address,
CharonVpnService.sAppliedGeoExclusionsV4, CharonVpnService.sAppliedGeoExclusionsV6,
CharonVpnService.sDnsPunchIps,
RouteCheck.ranges(manager.forceTunnelCidrs()), RouteCheck.ranges(manager.directCidrs()), rulesActive,
)
val host = address.hostAddress ?: ip
return CheckResult.Ip(host, verdict, reach(context, host))
}
/* ------------------------------ results ------------------------------ */
@Composable
private fun reasonText(reason: RouteCheck.Reason): String = stringResource(
when (reason)
{
RouteCheck.Reason.DNS -> R.string.route_reason_dns
RouteCheck.Reason.RULE_TUNNEL -> R.string.route_reason_rule_vpn
RouteCheck.Reason.RULE_DIRECT -> R.string.route_reason_rule_direct
RouteCheck.Reason.RU_LIST -> R.string.route_reason_ru
RouteCheck.Reason.DEFAULT -> R.string.route_reason_default
}
)
/** The verdict card of the mockup: label, big route, reachability pill, path diagram, reason. */
@Composable
private fun VerdictCard(direct: Boolean, mixed: Boolean, subject: String, reason: String, reach: Long?, checkedReach: Boolean)
{
val c = LocalShxColors.current
val bg = if (direct) c.successContainer else c.accentContainer
val strong = if (direct) c.onSuccessStrong else c.onAccentContainerStrong
val soft = if (direct) c.onSuccess else c.onAccentContainer
val line = if (direct) c.success else c.accent
val country = remember { VpnStateService.getLiveInstance()?.profile?.name?.take(12) }
Column(
verticalArrangement = Arrangement.spacedBy(14.dp),
modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(24.dp)).background(bg).padding(16.dp),
)
{
Row(verticalAlignment = Alignment.CenterVertically)
{
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp))
{
Text(stringResource(R.string.route_check_result).uppercase(), style = ShxType.section.copy(fontSize = 12.sp), color = soft)
Text(
stringResource(
when
{
mixed -> R.string.route_check_verdict_mixed
direct -> R.string.route_check_verdict_direct
else -> R.string.route_check_verdict_vpn
}
),
style = ShxType.pageTitle,
color = strong,
)
}
if (checkedReach)
{
Text(
text = stringResource(R.string.route_check_resolve_failed),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
if (reach != null) stringResource(R.string.route_check_opens_ms, reach) else stringResource(R.string.route_check_not_opens),
style = ShxType.chip.copy(fontWeight = FontWeight.ExtraBold),
color = if (reach != null) soft else c.onErrorContainer,
modifier = Modifier.clip(RoundedCornerShape(999.dp)).background(c.card).padding(horizontal = 12.dp, vertical = 6.dp),
)
}
}
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp))
{
PathNode(stringResource(R.string.route_check_node_phone), filled = false, color = line)
Box(Modifier.weight(1f).height(3.dp).clip(RoundedCornerShape(2.dp)).background(line))
PathNode(
if (direct) stringResource(R.string.route_check_node_provider)
else listOfNotNull(stringResource(R.string.route_check_node_vpn), country).joinToString(" · "),
filled = true, color = line,
)
Box(Modifier.weight(1f).height(3.dp).clip(RoundedCornerShape(2.dp)).background(line))
PathNode(subject, filled = false, color = line)
}
Text(
buildAnnotatedString {
withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { append(stringResource(R.string.route_check_why)) }
append(' ')
append(reason)
},
style = ShxType.note,
color = soft,
)
}
}
@Composable
private fun PathNode(text: String, filled: Boolean, color: Color)
{
val c = LocalShxColors.current
Text(
text,
style = ShxType.meta.copy(fontWeight = FontWeight.ExtraBold),
color = if (filled) Color.White else c.text,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.clip(RoundedCornerShape(12.dp)).background(if (filled) color else c.card).padding(horizontal = 10.dp, vertical = 8.dp),
)
}
/** "Direct path for comparison": TCP 443 directly and through the tunnel. */
@Composable
private fun CompareCard(reach: Reach)
{
val c = LocalShxColors.current
ShxSectionHeader(stringResource(R.string.route_check_compare))
ShxPanel(padding = PaddingValues(horizontal = 16.dp, vertical = 4.dp))
{
val rows = listOf(
stringResource(R.string.route_check_verdict_direct) to reach.direct,
stringResource(R.string.route_check_verdict_vpn) to reach.vpn,
)
rows.forEachIndexed { i, (title, ms) ->
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().height(52.dp))
{
Text(title, style = ShxType.body, color = c.text, modifier = Modifier.weight(1f))
Text(
if (ms != null) stringResource(R.string.diag_ms, ms) else stringResource(R.string.route_check_not_opens),
style = ShxType.sub.copy(fontWeight = FontWeight.ExtraBold),
color = if (ms != null) c.onSuccess else c.error,
)
}
if (i < rows.lastIndex)
{
Box(Modifier.fillMaxWidth().height(1.dp).background(c.divider))
}
}
}
}
@Composable
private fun RouteCheckRow(label: String, ips: List<String>)
private fun SiteResult(result: CheckResult.Site, rulesActive: Boolean, onAlways: (Boolean) -> Unit)
{
Text(
text = label,
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
val c = LocalShxColors.current
val report = result.report
if (report.routes.isEmpty())
{
ShxNote(stringResource(R.string.route_check_resolve_failed), tone = ShxTone.ERROR)
return
}
val verdicts = result.verdicts.values
val anyDirect = verdicts.any { it.direct }
val allDirect = verdicts.all { it.direct }
val main = verdicts.firstOrNull { it.reason == RouteCheck.Reason.RULE_TUNNEL || it.reason == RouteCheck.Reason.RULE_DIRECT } ?: verdicts.first()
VerdictCard(allDirect, anyDirect && !allDirect, report.domain, reasonText(main.reason), result.reach.viaDefault, checkedReach = true)
if (!report.vpnNetworkFound)
{
ShxNote(stringResource(R.string.route_check_no_vpn), tone = ShxTone.WARNING)
}
else if (result.reach.direct != null || result.reach.vpn != null)
{
CompareCard(result.reach)
}
ShxSectionHeader(stringResource(R.string.route_check_dns_section))
ShxPanel(padding = PaddingValues(horizontal = 16.dp, vertical = 12.dp))
{
Column(verticalArrangement = Arrangement.spacedBy(10.dp))
{
DnsBlock(stringResource(R.string.route_check_via_vpn_dns), report.vpnDnsIps)
DnsBlock(stringResource(R.string.route_check_via_direct_dns), report.directDnsIps)
if (report.vpnDnsIps.isNotEmpty() && report.directDnsIps.isNotEmpty() && report.vpnDnsIps.toSet() != report.directDnsIps.toSet())
{
Text(
buildAnnotatedString {
withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { append(stringResource(R.string.route_check_dns_differ_title)) }
append(' ')
append(stringResource(R.string.route_check_dns_differ))
},
style = ShxType.note,
color = c.onWarningContainer,
modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(12.dp)).background(c.warningContainer).padding(horizontal = 12.dp, vertical = 8.dp),
)
}
}
}
ShxSectionHeader(stringResource(R.string.route_check_addresses, report.routes.size))
ShxPanel()
{
report.routes.forEachIndexed { i, route ->
val verdict = result.verdicts[route.ip] ?: return@forEachIndexed
AddressRow(route.ip, verdict)
if (i < report.routes.lastIndex)
{
Box(Modifier.fillMaxWidth().height(1.dp).background(c.divider))
}
}
}
ChangeButtons(report.domain, rulesActive, onAlways)
}
@Composable
private fun IpResult(result: CheckResult.Ip, rulesActive: Boolean, onAlways: (Boolean) -> Unit)
{
VerdictCard(result.verdict.direct, false, result.ip, reasonText(result.verdict.reason), result.reach.viaDefault, checkedReach = true)
if (result.reach.direct != null || result.reach.vpn != null)
{
CompareCard(result.reach)
}
ChangeButtons(result.ip, rulesActive, onAlways)
}
@Composable
private fun AppResult(app: InstalledApp, manager: RoutingManager)
{
val c = LocalShxColors.current
val state by manager.state.collectAsStateWithLifecycle()
val config = state.config
val excluded by produceState(emptySet<String>(), config) {
value = withContext(Dispatchers.IO) { manager.computeExcludedPackages() }
}
val direct = config.enabled && app.packageName in excluded
val match = RoutingManager.templateMatch(app.packageName, config)
val reason = when
{
!config.enabled -> stringResource(R.string.route_reason_app_routing_off)
app.packageName in config.tunnelOverrides -> stringResource(R.string.route_reason_app_override)
app.packageName in config.directPackages -> stringResource(R.string.route_reason_app_added)
match != null -> stringResource(R.string.route_reason_app_template, templateLabel(match))
else -> stringResource(R.string.route_reason_app_default)
}
VerdictCard(direct, false, app.label, reason, null, checkedReach = false)
ShxNote(stringResource(R.string.route_check_app_note), tone = ShxTone.NEUTRAL)
Text(stringResource(R.string.route_check_change_for, app.label), style = ShxType.sub.copy(fontWeight = FontWeight.Bold), color = c.textSoft, modifier = Modifier.padding(horizontal = 6.dp))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp))
{
ChangeButton(stringResource(R.string.route_check_always_direct), c.successContainer, c.onSuccess, Modifier.weight(1f)) { manager.setAppDirect(app.packageName, true) }
ChangeButton(stringResource(R.string.route_check_always_vpn), c.accentMuted, c.textTonal, Modifier.weight(1f)) { manager.setAppDirect(app.packageName, false) }
}
}
@Composable
private fun ChangeButtons(subject: String, rulesActive: Boolean, onAlways: (Boolean) -> Unit)
{
val c = LocalShxColors.current
Column(verticalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.padding(top = 4.dp))
{
Text(stringResource(R.string.route_check_change_for, subject), style = ShxType.sub.copy(fontWeight = FontWeight.Bold), color = c.textSoft, modifier = Modifier.padding(horizontal = 6.dp))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp))
{
ChangeButton(stringResource(R.string.route_check_always_direct), c.successContainer, c.onSuccess, Modifier.weight(1f)) { onAlways(true) }
ChangeButton(stringResource(R.string.route_check_always_vpn), c.accentMuted, c.textTonal, Modifier.weight(1f)) { onAlways(false) }
}
if (!rulesActive)
{
ShxNote(stringResource(R.string.route_rules_need_flexible), tone = ShxTone.WARNING)
}
}
}
@Composable
private fun ChangeButton(text: String, bg: Color, fg: Color, modifier: Modifier, onClick: () -> Unit)
{
Box(
contentAlignment = Alignment.Center,
modifier = modifier.height(52.dp).clip(RoundedCornerShape(16.dp)).background(bg).clickable(role = Role.Button, onClick = onClick),
)
Text(
text = ips.joinToString(", ").ifEmpty { stringResource(R.string.route_check_none) },
style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace,
{
Text(text, style = ShxType.body.copy(fontWeight = FontWeight.ExtraBold), color = fg)
}
}
@Composable
private fun AddressRow(ip: String, verdict: RouteCheck.Verdict)
{
val c = LocalShxColors.current
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
modifier = Modifier.fillMaxWidth().padding(start = 16.dp, end = 14.dp, top = 10.dp, bottom = 10.dp),
)
{
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp))
{
Text(ip, style = ShxType.mono.copy(fontSize = 14.sp, fontWeight = FontWeight.Bold), color = c.text)
Text(reasonText(verdict.reason), style = ShxType.meta.copy(fontWeight = FontWeight.Normal), color = c.textSub, maxLines = 2)
}
Text(
stringResource(if (verdict.direct) R.string.route_verdict_direct else R.string.route_verdict_tunnel),
style = ShxType.meta.copy(fontWeight = FontWeight.ExtraBold),
color = if (verdict.direct) c.onSuccess else c.onAccentContainer,
modifier = Modifier.clip(RoundedCornerShape(999.dp)).background(if (verdict.direct) c.successContainer else c.accentContainer).padding(horizontal = 10.dp, vertical = 4.dp),
)
}
}
@Composable
private fun DnsBlock(label: String, ips: List<String>)
{
val c = LocalShxColors.current
Column(verticalArrangement = Arrangement.spacedBy(3.dp))
{
Text(label, style = ShxType.sub.copy(fontWeight = FontWeight.Bold), color = c.textSoft)
Text(ips.joinToString(" · ").ifEmpty { stringResource(R.string.route_check_none) }, style = ShxType.mono.copy(fontSize = 14.sp), color = c.text)
}
}
@Composable
private fun AppSuggestions(apps: List<InstalledApp>, query: String, onPick: (InstalledApp) -> Unit)
{
val c = LocalShxColors.current
val matches = remember(apps, query) {
val q = query.trim()
apps.filter { it.label.contains(q, ignoreCase = true) || it.packageName.contains(q, ignoreCase = true) }.take(6)
}
ShxPanel()
{
if (matches.isEmpty())
{
Text(stringResource(R.string.route_check_bad_app), style = ShxType.body, color = c.textSub, modifier = Modifier.padding(16.dp))
}
matches.forEachIndexed { i, app ->
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.fillMaxWidth().clickable { onPick(app) }.padding(horizontal = 16.dp, vertical = 10.dp),
)
{
AppIcon(app.packageName, 36.dp)
Text(app.label, style = ShxType.rowTitle, color = c.text)
}
if (i < matches.lastIndex)
{
Box(Modifier.fillMaxWidth().height(1.dp).background(c.divider))
}
}
}
}
@@ -21,18 +21,29 @@ import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowRight
import androidx.compose.material.icons.rounded.Apps
import androidx.compose.material.icons.rounded.CloudOff
import androidx.compose.material.icons.rounded.Route
import androidx.compose.material.icons.rounded.Rule
import androidx.compose.material.icons.rounded.Shield
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
@@ -40,11 +51,11 @@ import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
@@ -54,7 +65,9 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.heading
import androidx.compose.ui.semantics.semantics
@@ -69,28 +82,43 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.strongswan.android.R
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.ui.unit.dp
import org.strongswan.android.logic.routing.AppTraffic
import org.strongswan.android.ui.compose.design.ShxIcons
import org.strongswan.android.ui.compose.design.ShxNote
import org.strongswan.android.ui.compose.design.ShxPanel
import org.strongswan.android.ui.compose.design.ShxRow
import org.strongswan.android.ui.compose.design.ShxScreenTitle
import org.strongswan.android.ui.compose.design.ShxSectionHeader
import org.strongswan.android.ui.compose.design.ShxTileButton
import org.strongswan.android.ui.compose.design.ShxTone
import org.strongswan.android.ui.compose.routing.ExceptionsPanel
import org.strongswan.android.ui.compose.routing.RecentAppsCard
import org.strongswan.android.ui.compose.routing.RecentPreview
import org.strongswan.android.ui.compose.routing.RuSitesPanel
import org.strongswan.android.ui.compose.routing.SplitTunnelHero
import org.strongswan.android.ui.compose.theme.LocalShxColors
import org.strongswan.android.logic.CharonVpnService
import org.strongswan.android.logic.routing.RoutingManager
import org.strongswan.android.logic.routing.RuIpListManager
import org.strongswan.android.ui.RouteCheckActivity
import org.strongswan.android.ui.compose.components.PredictiveBackScale
import org.strongswan.android.ui.compose.components.ShxCard
import org.strongswan.android.ui.compose.components.ShxSnackbarHost
import org.strongswan.android.ui.compose.routing.AdvancedPage
import org.strongswan.android.ui.compose.routing.AdvancedSection
import org.strongswan.android.ui.compose.routing.DeadHostsPage
import org.strongswan.android.ui.compose.routing.DirectAppsCard
import org.strongswan.android.ui.compose.routing.DirectAppsScreen
import org.strongswan.android.ui.compose.routing.ForcePrefixDialog
import org.strongswan.android.ui.compose.routing.ForcePrefixPage
import org.strongswan.android.ui.compose.routing.PatternDialog
import org.strongswan.android.ui.compose.routing.PatternsPage
import org.strongswan.android.ui.compose.routing.RecentAppsPage
import org.strongswan.android.ui.compose.routing.RoutingHeroCard
import org.strongswan.android.ui.compose.routing.RuSitesCard
import org.strongswan.android.ui.compose.routing.RulesPage
import org.strongswan.android.ui.compose.routing.routingSummary
import org.strongswan.android.ui.compose.theme.LocalShxStatusColors
import org.strongswan.android.ui.compose.theme.ShxSpacing
/** In-tab pages of the Routing tab; "back" returns to the main page, not out of the tab. */
private enum class RoutingPage { MAIN, APPS, FORCE, PATTERNS, DEAD }
private enum class RoutingPage { MAIN, APPS, RULES, RECENT, DEAD }
@Composable
fun RoutingScreen()
@@ -108,7 +136,7 @@ fun RoutingScreen()
{
PredictiveBackScale(
enabled = page != RoutingPage.MAIN,
onBack = { page = RoutingPage.MAIN },
onBack = { page = if (page == RoutingPage.DEAD) RoutingPage.RECENT else RoutingPage.MAIN },
modifier = Modifier
.widthIn(max = 640.dp)
.fillMaxSize(),
@@ -130,7 +158,6 @@ fun RoutingScreen()
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun RoutingPageContent(
page: RoutingPage,
@@ -147,10 +174,6 @@ private fun RoutingPageContent(
val config = state.config
val scope = rememberCoroutineScope()
/* force-prefix dialog: null = hidden, "" = add, otherwise the entry id */
var forceDialogId by remember { mutableStateOf<String?>(null) }
var patternDialog by remember { mutableStateOf(false) }
/* real packages that bypass the tunnel: categories and templates expanded
* against installed apps (cached per config version inside the manager) */
val directPackages by produceState(initialValue = emptyList<String>(), config) {
@@ -164,8 +187,8 @@ private fun RoutingPageContent(
}
}
/* dead direct destinations the engine keeps refusing; polled only while
* the screen is visible (not while the activity is stopped) */
/* dead direct destinations the engine keeps refusing and the split of the
* session traffic; polled only while the screen is visible */
val lifecycle = LocalLifecycleOwner.current.lifecycle
val deadTargets by produceState(initialValue = emptyList<DeadTargetUi>(), lifecycle)
{
@@ -186,6 +209,43 @@ private fun RoutingPageContent(
}
}
}
val split by produceState<Pair<Long, Long>?>(initialValue = null, lifecycle)
{
lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED)
{
while (true)
{
val stats = try
{
withContext(Dispatchers.Default) {
if (CharonVpnService.isUserspaceRoutingEnabled(context)) CharonVpnService.getRoutingStats() else null
}
}
catch (_: Throwable)
{
null
}
value = if (stats != null && stats.size >= 4 && stats[1] + stats[3] > 0) stats[1] to stats[3] else null
delay(5_000)
}
}
}
/* the four busiest apps of the last hour for the «Recent apps» preview */
val recent by produceState(initialValue = emptyList<RecentPreview>(), directPackages, deadTargets)
{
value = withContext(Dispatchers.IO) {
val pm = context.packageManager
val failingOwners = deadTargets.filter { !it.viaTunnel }.mapNotNull { it.owner }.toSet()
AppTraffic.since(context, System.currentTimeMillis() - 60L * 60 * 1000).entries
.sortedByDescending { it.value }
.mapNotNull { (pkg, _) ->
val label = runCatching { pm.getApplicationInfo(pkg, 0).loadLabel(pm).toString() }.getOrNull() ?: return@mapNotNull null
RecentPreview(pkg, label, pkg in directPackages, pkg in failingOwners)
}
.take(4)
}
}
val refreshGeoip = {
scope.launch {
@@ -202,209 +262,129 @@ private fun RoutingPageContent(
snackbarHostState.showSnackbar(message)
}
}
val ruleCount = config.siteRules.size + config.forceTunnelPrefixes.size + config.directPrefixes.size + config.packagePatterns.size
/* the hero says what bypasses the VPN: only the "direct" rules count there */
val directRuleCount = config.siteRules.count { it.direct } + config.directPrefixes.size + config.packagePatterns.size
when (page)
{
RoutingPage.APPS -> DirectAppsScreen(onBack = onBack)
RoutingPage.FORCE -> ForcePrefixPage(
prefixes = config.forceTunnelPrefixes,
onToggle = manager::setForcePrefixEnabled,
onEdit = { forceDialogId = it },
onDelete = manager::deleteForcePrefix,
onAdd = { forceDialogId = "" },
RoutingPage.RULES -> RulesPage(onBack = onBack)
RoutingPage.RECENT -> RecentAppsPage(
deadTargets = deadTargets,
onForceTunnel = { dst -> forceTunnel(manager, dst) },
onOpenDead = { onNavigate(RoutingPage.DEAD) },
onBack = onBack,
)
RoutingPage.PATTERNS -> PatternsPage(
patterns = config.packagePatterns.sorted(),
onDelete = manager::removePattern,
onAdd = { patternDialog = true },
onBack = onBack,
RoutingPage.DEAD -> DeadHostsPage(
targets = deadTargets,
onForceTunnel = { dst -> forceTunnel(manager, dst) },
onBack = { onNavigate(RoutingPage.RECENT) },
)
RoutingPage.DEAD -> DeadHostsPage(targets = deadTargets, onBack = onBack)
RoutingPage.MAIN -> MainPage(
enabled = config.enabled,
ruEnabled = ruState.enabled,
ruState = ruState,
directPackages = directPackages,
forceCount = config.forceTunnelPrefixes.size,
patternCount = config.packagePatterns.size,
deadCount = deadTargets.size,
ruleCount = ruleCount,
directRuleCount = directRuleCount,
deadCount = deadTargets.count { !it.viaTunnel },
split = split,
recent = recent,
onMasterToggle = manager::setGlobalEnabled,
onRuToggle = ruList::setEnabled,
onRuMode = ruList::setMode,
onRefresh = { refreshGeoip() },
onOpenApps = { onNavigate(RoutingPage.APPS) },
onAddAllRf = manager::enableAllRfApps,
onOpenAdvanced = {
onNavigate(
when (it)
{
AdvancedPage.FORCE -> RoutingPage.FORCE
AdvancedPage.PATTERNS -> RoutingPage.PATTERNS
AdvancedPage.DEAD -> RoutingPage.DEAD
}
)
},
onOpen = onNavigate,
)
}
/* ---- dialogs, hosted outside the scrolling pages ---- */
if (forceDialogId != null)
{
val initial = if (forceDialogId == "")
{
""
}
else
{
config.forceTunnelPrefixes.firstOrNull { it.id == forceDialogId }?.prefix ?: ""
}
ForcePrefixDialog(
initial = initial,
onConfirm = { cidr ->
if (forceDialogId == "")
{
manager.addForcePrefix(cidr) != null
}
else
{
manager.updateForcePrefix(forceDialogId!!, cidr)
}
},
onDismiss = { forceDialogId = null },
)
}
if (patternDialog)
{
PatternDialog(onConfirm = { manager.addPattern(it) }, onDismiss = { patternDialog = false })
}
}
@OptIn(ExperimentalMaterial3Api::class)
/** "ip:port" of a dead destination -> a /32 (or /128) "always through the VPN" rule. */
private fun forceTunnel(manager: RoutingManager, dst: String)
{
val ip = dst.substringBeforeLast(':').removeSurrounding("[", "]")
manager.addForcePrefix(ip + if (ip.contains(':')) "/128" else "/32")
}
@Composable
private fun MainPage(
enabled: Boolean,
ruEnabled: Boolean,
ruState: org.strongswan.android.logic.routing.RuIpListState,
directPackages: List<String>,
forceCount: Int,
patternCount: Int,
ruleCount: Int,
directRuleCount: Int,
deadCount: Int,
split: Pair<Long, Long>?,
recent: List<RecentPreview>,
onMasterToggle: (Boolean) -> Unit,
onRuToggle: (Boolean) -> Unit,
onRuMode: (String) -> Unit,
onRefresh: () -> Unit,
onOpenApps: () -> Unit,
onAddAllRf: () -> Unit,
onOpenAdvanced: (AdvancedPage) -> Unit,
onOpen: (RoutingPage) -> Unit,
)
{
val context = LocalContext.current
val openCheck = { context.startActivity(Intent(context, RouteCheckActivity::class.java)) }
Column(
Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(bottom = ShxSpacing.xl),
verticalArrangement = Arrangement.spacedBy(ShxSpacing.md),
.statusBarsPadding()
.verticalScroll(rememberScrollState()),
)
{
TopAppBar(
title = { Text(stringResource(R.string.routing_title), fontWeight = FontWeight.SemiBold) },
actions = {
IconButton(onClick = { context.startActivity(Intent(context, RouteCheckActivity::class.java)) })
ShxScreenTitle(stringResource(R.string.routing_title))
Column(
verticalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 4.dp, bottom = 24.dp),
)
{
SplitTunnelHero(
enabled = enabled,
summary = routingSummary(enabled, ruState.enabled, directPackages.size, directRuleCount),
split = split,
onToggle = onMasterToggle,
)
ShxSectionHeader(stringResource(R.string.routing_section_ru))
RuSitesPanel(state = ruState, onToggle = onRuToggle, onModeChange = onRuMode, onRefresh = onRefresh)
ShxSectionHeader(stringResource(R.string.routing_section_exceptions))
ExceptionsPanel(
directPackages = directPackages,
ruleCount = ruleCount,
onApps = onOpenApps,
onRules = { onOpen(RoutingPage.RULES) },
)
/* kill switch + direct apps: the direct apps are added to the VPN's disallowed list
* (CharonVpnService.addDisallowedApplication), so the system lockdown does not cover
* them and they keep using the normal network when the tunnel drops. The RU-sites part
* is routed by IP *inside* the VPN's scope (the RU ranges are simply left out of its
* routes), so under lockdown it is blocked together with everything else. */
if (enabled && directPackages.isNotEmpty() && remember { context.isVpnLockdownEnabled() })
{
ShxNote(stringResource(R.string.routing_killswitch_note), tone = ShxTone.NEUTRAL)
}
ShxSectionHeader(stringResource(R.string.routing_section_check))
ShxTileButton(stringResource(R.string.routing_check_row), ShxIcons.Search, openCheck)
RecentAppsCard(apps = recent, failing = deadCount, onClick = { onOpen(RoutingPage.RECENT) })
if (deadCount > 0)
{
ShxPanel()
{
Icon(
Icons.Rounded.Route,
contentDescription = stringResource(R.string.route_check_title),
ShxRow(
title = stringResource(R.string.dead_targets_title),
subtitle = stringResource(R.string.routing_adv_dead_sub),
icon = ShxIcons.Alert,
iconBackground = LocalShxColors.current.warningStrong,
iconTint = LocalShxColors.current.onWarning,
chevron = true,
onClick = { onOpen(RoutingPage.DEAD) },
)
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.background,
),
)
/* ---- hero: master switch + one-line summary ---- */
RoutingHeroCard(
enabled = enabled,
summary = routingSummary(enabled, ruEnabled, directPackages.size),
onToggle = onMasterToggle,
modifier = Modifier.padding(horizontal = ShxSpacing.lg),
)
/* ---- Russian sites ---- */
SectionTitle(stringResource(R.string.routing_section_ru))
RuSitesCard(
state = ruState,
onToggle = onRuToggle,
onModeChange = onRuMode,
onRefresh = onRefresh,
modifier = Modifier.padding(horizontal = ShxSpacing.lg),
)
/* ---- apps ---- */
SectionTitle(stringResource(R.string.routing_section_apps))
DirectAppsCard(
count = directPackages.size,
previewPackages = directPackages,
onOpen = onOpenApps,
onAddAllRf = onAddAllRf,
modifier = Modifier.padding(horizontal = ShxSpacing.lg),
)
/* kill switch + direct apps: the direct apps are added to the VPN's disallowed list
* (CharonVpnService.addDisallowedApplication), so the system lockdown does not cover
* them and they keep using the normal network when the tunnel drops. The RU-sites part
* is routed by IP *inside* the VPN's scope (the RU ranges are simply left out of its
* routes), so under lockdown it is blocked together with everything else. */
if (enabled && directPackages.isNotEmpty() && remember { context.isVpnLockdownEnabled() })
{
KillSwitchNote()
}
}
AdvancedSection(
forceCount = forceCount,
patternCount = patternCount,
deadCount = deadCount,
onOpen = onOpenAdvanced,
modifier = Modifier.padding(horizontal = ShxSpacing.lg),
)
}
}
@Composable
private fun SectionTitle(text: String)
{
Text(
text = text.uppercase(),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier
.fillMaxWidth()
.padding(start = ShxSpacing.lg + ShxSpacing.lg, top = ShxSpacing.md)
.semantics { heading() },
)
}
@Composable
private fun KillSwitchNote()
{
Row(
horizontalArrangement = Arrangement.spacedBy(ShxSpacing.md),
modifier = Modifier
.padding(horizontal = ShxSpacing.lg)
.fillMaxWidth(),
)
{
Icon(
Icons.Rounded.Shield,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
)
Text(
text = stringResource(R.string.routing_killswitch_note),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
@@ -24,6 +24,10 @@ import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import org.strongswan.android.R
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.ui.Modifier
import org.strongswan.android.ui.compose.design.ShxPageHeader
import org.strongswan.android.ui.compose.theme.LocalShxColors
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
@@ -34,24 +38,17 @@ fun SecondaryScaffold(
onBack: () -> Unit,
actions: @Composable RowScope.() -> Unit = {},
snackbarHost: @Composable () -> Unit = {},
floatingActionButton: @Composable () -> Unit = {},
content: @Composable (PaddingValues) -> Unit,
)
{
/* header of the mockup: 44dp back button + 22/800 title, no tinted bar */
Scaffold(
snackbarHost = snackbarHost,
floatingActionButton = floatingActionButton,
containerColor = LocalShxColors.current.background,
topBar = {
TopAppBar(
title = { Text(title, maxLines = 1, overflow = TextOverflow.Ellipsis, fontWeight = FontWeight.SemiBold) },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back))
}
},
actions = actions,
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.background,
),
)
ShxPageHeader(title = title, onBack = onBack, modifier = Modifier.statusBarsPadding(), actions = actions)
},
content = content,
)
@@ -71,6 +71,13 @@ import androidx.compose.ui.res.stringResource
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import org.strongswan.android.R
import org.strongswan.android.ui.compose.design.ShxIcons
import org.strongswan.android.ui.compose.theme.LocalShxColors
import org.strongswan.android.ui.compose.theme.ShxType
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteDefaults
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.foundation.layout.size
import androidx.compose.ui.unit.dp
import org.strongswan.android.data.VpnProfile
import org.strongswan.android.data.VpnProfileDataSource
import org.strongswan.android.logic.StrongSwanApplication
@@ -168,6 +175,10 @@ fun ShxApp(viewModel: HomeViewModel = viewModel())
ShxNavigationSuiteItems(selected = state.tab, onSelect = viewModel::setTab, colors = navItemColors)
},
containerColor = MaterialTheme.colorScheme.background,
navigationSuiteColors = NavigationSuiteDefaults.colors(
navigationBarContainerColor = LocalShxColors.current.tile,
navigationRailContainerColor = LocalShxColors.current.tile,
),
) {
Surface(
modifier = Modifier.fillMaxSize(),
@@ -267,34 +278,33 @@ private fun ShareProfileDialog(profile: VpnProfile, onDismiss: () -> Unit, onCon
)
}
/** Item colors for the adaptive navigation suite, keeping the brand palette. */
/** Item colors of the mockup nav bar: #E7DFFF pill, #1C1B20 selected, #49454F the rest. */
@Composable
private fun shxNavigationItemColors(): NavigationSuiteItemColors
{
val bg = MaterialTheme.colorScheme.surfaceContainer
val indicator = MaterialTheme.colorScheme.primary.copy(alpha = 0.32f).compositeOver(bg)
val c = LocalShxColors.current
return NavigationSuiteItemColors(
navigationBarItemColors = NavigationBarItemDefaults.colors(
selectedIconColor = MaterialTheme.colorScheme.primary,
unselectedIconColor = MaterialTheme.colorScheme.onSurfaceVariant,
selectedTextColor = MaterialTheme.colorScheme.primary,
unselectedTextColor = MaterialTheme.colorScheme.onSurfaceVariant,
indicatorColor = indicator,
selectedIconColor = c.text,
unselectedIconColor = c.textSoft,
selectedTextColor = c.text,
unselectedTextColor = c.textSoft,
indicatorColor = c.accentContainer,
),
navigationRailItemColors = NavigationRailItemDefaults.colors(
selectedIconColor = MaterialTheme.colorScheme.primary,
unselectedIconColor = MaterialTheme.colorScheme.onSurfaceVariant,
selectedTextColor = MaterialTheme.colorScheme.primary,
unselectedTextColor = MaterialTheme.colorScheme.onSurfaceVariant,
indicatorColor = indicator,
selectedIconColor = c.text,
unselectedIconColor = c.textSoft,
selectedTextColor = c.text,
unselectedTextColor = c.textSoft,
indicatorColor = c.accentContainer,
),
navigationDrawerItemColors = NavigationDrawerItemDefaults.colors(
selectedContainerColor = indicator,
selectedContainerColor = c.accentContainer,
unselectedContainerColor = Color.Transparent,
selectedIconColor = MaterialTheme.colorScheme.primary,
unselectedIconColor = MaterialTheme.colorScheme.onSurfaceVariant,
selectedTextColor = MaterialTheme.colorScheme.primary,
unselectedTextColor = MaterialTheme.colorScheme.onSurfaceVariant,
selectedIconColor = c.text,
unselectedIconColor = c.textSoft,
selectedTextColor = c.text,
unselectedTextColor = c.textSoft,
),
)
}
@@ -316,22 +326,29 @@ private fun NavigationSuiteScope.ShxNavigationSuiteItems(
onClick = { onSelect(tab) },
icon = {
Icon(
imageVector = tab.icon(selected = tab == selected),
imageVector = tab.icon(),
contentDescription = null,
modifier = Modifier.size(22.dp),
)
},
label = {
Text(
stringResource(tab.label()),
style = ShxType.nav.copy(fontWeight = if (tab == selected) FontWeight.ExtraBold else FontWeight.SemiBold),
)
},
label = { Text(stringResource(tab.label())) },
colors = colors,
)
}
}
private fun ShxTab.icon(selected: Boolean): ImageVector = when (this)
/* the line icons of the mockup tab bar */
private fun ShxTab.icon(): ImageVector = when (this)
{
ShxTab.HOME -> if (selected) Icons.Filled.Home else Icons.Outlined.Home
ShxTab.PROFILES -> if (selected) Icons.Filled.Storage else Icons.Outlined.Storage
ShxTab.ROUTING -> if (selected) Icons.Filled.AltRoute else Icons.Outlined.AltRoute
ShxTab.SETTINGS -> if (selected) Icons.Filled.Settings else Icons.Outlined.Settings
ShxTab.HOME -> ShxIcons.Power
ShxTab.PROFILES -> ShxIcons.Globe
ShxTab.ROUTING -> ShxIcons.Route
ShxTab.SETTINGS -> ShxIcons.Sliders
}
private fun ShxTab.label(): Int = when (this)
@@ -45,18 +45,29 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import org.strongswan.android.ui.compose.theme.ShxSpacing
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.style.TextAlign
import org.strongswan.android.ui.compose.design.ShxButton
import org.strongswan.android.ui.compose.design.ShxButtonStyle
import org.strongswan.android.ui.compose.design.ShxIcons
import org.strongswan.android.ui.compose.design.ShxSwitch
import org.strongswan.android.ui.compose.theme.LocalShxColors
import org.strongswan.android.ui.compose.theme.ShxType
/** Uppercase section header + grouped card, the standard grouped-list building block. */
/** Section header (13/800 purple, +0.04em) + white card: the grouped-list block of the mockup. */
@Composable
fun ShxSection(title: String, content: @Composable () -> Unit)
{
Text(
text = title.uppercase(),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = ShxType.section,
color = LocalShxColors.current.accentText,
modifier = Modifier
.fillMaxWidth()
.padding(start = ShxSpacing.lg + ShxSpacing.lg, top = 20.dp, bottom = ShxSpacing.sm)
.padding(start = 22.dp, end = 22.dp, top = 20.dp, bottom = 8.dp)
.semantics { heading() },
)
ShxCard(modifier = Modifier.fillMaxWidth().padding(horizontal = ShxSpacing.lg)) {
@@ -64,57 +75,41 @@ fun ShxSection(title: String, content: @Composable () -> Unit)
}
}
/** 1px divider between rows of a card (#F0ECF4). */
@Composable
fun ShxSectionDivider(indent: Dp = 68.dp)
fun ShxSectionDivider(indent: Dp = 0.dp)
{
HorizontalDivider(
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.6f),
modifier = Modifier.padding(start = indent),
)
Box(Modifier.padding(start = indent).fillMaxWidth().height(1.dp).background(LocalShxColors.current.divider))
}
/** Rounded-square icon badge shared by all setting rows. */
/** Rounded-square icon well of the rows: 40dp, radius 14, #E7DFFF. */
@Composable
fun ShxIconBadge(icon: ImageVector, tint: Color = MaterialTheme.colorScheme.primary)
fun ShxIconBadge(icon: ImageVector, tint: Color = LocalShxColors.current.onAccentContainer)
{
Surface(
shape = MaterialTheme.shapes.small,
color = MaterialTheme.colorScheme.primaryContainer,
modifier = Modifier.size(36.dp),
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.size(40.dp).clip(RoundedCornerShape(14.dp)).background(LocalShxColors.current.accentContainer),
)
{
Icon(
imageVector = icon,
contentDescription = null,
tint = tint,
modifier = Modifier
.padding(ShxSpacing.sm)
.size(20.dp),
)
Icon(imageVector = icon, contentDescription = null, tint = tint, modifier = Modifier.size(20.dp))
}
}
@Composable
private fun RowScope.RowLabels(title: String, subtitle: String?, enabled: Boolean)
{
val c = LocalShxColors.current
Column(
verticalArrangement = Arrangement.spacedBy(2.dp),
modifier = Modifier
.weight(1f)
.padding(horizontal = ShxSpacing.lg),
.padding(horizontal = 14.dp),
)
{
Text(
title,
style = MaterialTheme.typography.bodyLarge,
color = if (enabled) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(title, style = ShxType.rowTitle, color = if (enabled) c.text else c.textSub)
if (subtitle != null)
{
Text(
subtitle,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(subtitle, style = ShxType.sub, color = c.textSub)
}
}
}
@@ -129,25 +124,23 @@ fun ShxSettingRow(
onClick: () -> Unit,
)
{
val c = LocalShxColors.current
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.clickable(enabled = enabled, onClick = onClick)
.padding(horizontal = ShxSpacing.lg, vertical = 14.dp),
.heightIn(min = 76.dp)
.padding(start = 16.dp, end = 14.dp, top = 10.dp, bottom = 10.dp),
)
{
ShxIconBadge(icon = icon, tint = if (enabled) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant)
ShxIconBadge(icon = icon, tint = if (enabled) c.onAccentContainer else c.textSub)
RowLabels(title, subtitle, enabled)
Icon(
Icons.AutoMirrored.Rounded.KeyboardArrowRight,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
Icon(ShxIcons.Chevron, contentDescription = null, tint = c.textSub, modifier = Modifier.size(18.dp))
}
}
/** Setting row with a trailing switch; the whole row toggles. */
/** Setting row with the mockup switch; the whole row toggles. */
@Composable
fun ShxSettingToggleRow(
icon: ImageVector,
@@ -158,6 +151,7 @@ fun ShxSettingToggleRow(
onCheckedChange: (Boolean) -> Unit,
)
{
val c = LocalShxColors.current
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
@@ -168,29 +162,28 @@ fun ShxSettingToggleRow(
role = Role.Switch,
onValueChange = onCheckedChange,
)
.padding(horizontal = ShxSpacing.lg, vertical = 14.dp),
.heightIn(min = 76.dp)
.padding(start = 16.dp, end = 14.dp, top = 10.dp, bottom = 10.dp),
)
{
ShxIconBadge(icon = icon, tint = if (enabled) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant)
ShxIconBadge(icon = icon, tint = if (enabled) c.onAccentContainer else c.textSub)
RowLabels(title, subtitle, enabled)
Switch(checked = checked, onCheckedChange = null, enabled = enabled)
ShxSwitch(checked = checked, onCheckedChange = null, enabled = enabled)
}
}
/** The standard rounded card: low container, optional hairline border. */
/** The card of the mockup: white, 1px #EDE8F2 border, radius 24. */
@Composable
fun ShxCard(
modifier: Modifier = Modifier,
bordered: Boolean = false,
@Suppress("UNUSED_PARAMETER") bordered: Boolean = true,
content: @Composable () -> Unit,
)
{
Surface(
shape = MaterialTheme.shapes.large,
color = MaterialTheme.colorScheme.surfaceContainerLow,
border = if (bordered) BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant) else null,
modifier = modifier,
) {
val c = LocalShxColors.current
val shape = RoundedCornerShape(24.dp)
Box(modifier.clip(shape).background(c.card).border(1.dp, c.cardBorder, shape))
{
content()
}
}
@@ -205,38 +198,26 @@ fun ShxEmptyState(
onAction: (() -> Unit)? = null,
)
{
val c = LocalShxColors.current
Column(
modifier = modifier
.fillMaxWidth()
.padding(ShxSpacing.xl),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(0.dp),
)
{
Box(
modifier = Modifier
.size(64.dp)
.background(MaterialTheme.colorScheme.surfaceContainerHigh, CircleShape),
modifier = Modifier.size(64.dp).clip(RoundedCornerShape(20.dp)).background(c.tile),
contentAlignment = Alignment.Center,
) {
Icon(
icon,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(30.dp),
)
Icon(icon, contentDescription = null, tint = c.iconMuted, modifier = Modifier.size(30.dp))
}
Spacer(Modifier.height(14.dp))
Text(
message,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontWeight = FontWeight.Medium,
)
Text(message, style = ShxType.body, color = c.textSub, textAlign = TextAlign.Center)
if (actionLabel != null && onAction != null)
{
Spacer(Modifier.height(ShxSpacing.lg))
OutlinedButton(onClick = onAction, shape = MaterialTheme.shapes.medium) { Text(actionLabel) }
ShxButton(actionLabel, onAction, style = ShxButtonStyle.TONAL, height = 48.dp)
}
}
}
@@ -22,10 +22,10 @@ import androidx.compose.ui.semantics.heading
import androidx.compose.ui.semantics.semantics
import org.strongswan.android.ui.compose.theme.ShxSpacing
/** Card with an uppercase caption and a padded content column (form groups, detail blocks). */
/** Card with an optional uppercase caption and a padded content column (form groups, detail blocks). */
@Composable
fun ShxSectionCard(
title: String,
title: String?,
modifier: Modifier = Modifier,
content: @Composable () -> Unit,
)
@@ -37,12 +37,15 @@ fun ShxSectionCard(
) {
Column(Modifier.padding(ShxSpacing.lg), verticalArrangement = Arrangement.spacedBy(ShxSpacing.md))
{
Text(
text = title.uppercase(),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.semantics { heading() },
)
if (title != null)
{
Text(
text = title.uppercase(),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.semantics { heading() },
)
}
content()
}
}
@@ -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.ui.compose.design
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.StrokeJoin
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.graphics.vector.addPathNodes
import androidx.compose.ui.unit.dp
/**
* The line icons of the mockup: the same 24x24 SVG paths, stroked with
* round caps and joins (Icon() tints them).
*/
object ShxIcons
{
private fun line(name: String, path: String, width: Float = 2f): ImageVector =
ImageVector.Builder(name = name, defaultWidth = 24.dp, defaultHeight = 24.dp, viewportWidth = 24f, viewportHeight = 24f)
.addPath(
pathData = addPathNodes(path),
fill = null,
stroke = SolidColor(Color.Black),
strokeLineWidth = width,
strokeLineCap = StrokeCap.Round,
strokeLineJoin = StrokeJoin.Round,
)
.build()
val Power = line("power", "M12 3v9M6.3 6.3a8 8 0 1 0 11.4 0", 2.2f)
val Globe = line("globe", "M12 3a9 9 0 1 0 0 18a9 9 0 1 0 0-18M3 12h18M12 3c3 3 3 15 0 18M12 3c-3 3-3 15 0 18")
val Route = line("route", "M4 12h6l4-6h6M10 12l4 6h6")
val Sliders = line("sliders", "M4 7h10M18 7h2M4 17h4M12 17h8M14 5v4M8 15v4")
val Bell = line("bell", "M6 16V11a6 6 0 0 1 12 0v5l1.5 2h-15zM10 20a2 2 0 0 0 4 0")
val Pulse = line("pulse", "M3 12h4l3-8 4 16 3-8h4")
val Pause = line("pause", "M9 5v14M15 5v14", 2.2f)
val Bolt = line("bolt", "M13 2L4 14h7l-1 8 9-12h-7z", 2.2f)
val Help = line("help", "M9.5 9.5a2.5 2.5 0 1 1 3.5 2.3c-.6.3-1 .9-1 1.6M12 17v.5", 2.2f)
val Swap = line("swap", "M4 8h13l-3-3M20 16H7l3 3", 2.2f)
val Chevron = line("chevron", "M9 6l6 6-6 6")
val Back = line("back", "M15 6l-6 6 6 6")
val Search = line("search", "M11 4a7 7 0 1 0 0 14a7 7 0 1 0 0-14M20 20l-3.5-3.5", 2.2f)
val Refresh = line("refresh", "M20 12a8 8 0 1 1-2.3-5.7M20 4v5h-5", 2.2f)
val Apps = line("apps", "M6 4h2a2 2 0 0 1 2 2v2a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2zM16 4h2a2 2 0 0 1 2 2v2a2 2 0 0 1-2 2h-2a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2zM6 14h2a2 2 0 0 1 2 2v2a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2v-2a2 2 0 0 1 2-2zM16 14h2a2 2 0 0 1 2 2v2a2 2 0 0 1-2 2h-2a2 2 0 0 1-2-2v-2a2 2 0 0 1 2-2z")
val Lines = line("lines", "M5 7h14M5 12h14M5 17h9", 2.2f)
val Alert = line("alert", "M12 4l9 16H3zM12 10v4M12 17.5v.5", 2.2f)
val Shield = line("shield", "M12 3l8 3v6c0 5-3.5 8-8 9-4.5-1-8-4-8-9V6z", 2.2f)
val Download = line("download", "M12 4v12M7 11l5 5 5-5M5 20h14", 2.2f)
val Database = line("db", "M4 6c0-1.7 3.6-3 8-3s8 1.3 8 3-3.6 3-8 3-8-1.3-8-3zM4 6v12c0 1.7 3.6 3 8 3s8-1.3 8-3V6", 2.2f)
val Check = line("check", "M5 12l5 5 9-10", 2.4f)
val Plus = line("plus", "M12 5v14M5 12h14", 2.4f)
val Close = line("close", "M6 6l12 12M18 6L6 18", 2.2f)
val Trash = line("trash", "M5 7h14M10 11v6M14 11v6M7 7l1 13h8l1-13M9 7V4h6v3", 2f)
val WifiOff = line("wifiOff", "M2 8.8a15 15 0 0 1 4.4-2.7M10 5.1A15 15 0 0 1 22 8.8M5 12.9a10 10 0 0 1 4.6-2.6M14.6 10.5A10 10 0 0 1 19 12.9M8.5 16.4a5 5 0 0 1 7 0M12 20h.01M3 3l18 18", 2.2f)
val Key = line("key", "M15 7a4 4 0 1 0-3.9 5L4 19v2h3v-2h2v-2h2l1.9-1.9A4 4 0 0 0 15 7z", 2f)
val Info = line("info", "M12 3a9 9 0 1 0 0 18a9 9 0 1 0 0-18M12 11v6M12 7.5v.5", 2.2f)
val Stats = line("stats", "M5 20V10M12 20V4M19 20v-7", 2.4f)
}
@@ -0,0 +1,618 @@
/*
* 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.
*/
@file:OptIn(ExperimentalMaterial3Api::class)
package org.strongswan.android.ui.compose.design
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.animateDpAsState
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.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.selection.toggleable
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowRight
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.heading
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import org.strongswan.android.R
import org.strongswan.android.ui.compose.theme.LocalShxColors
import org.strongswan.android.ui.compose.theme.ShxType
/*
* Building blocks of the «Final 2026» mockup. Every size, radius and color
* here is taken from the mockup boards; screens should be composed from
* these instead of default Material components so they match the canvas.
*/
/** Tab screen title: 26/800, padding 18/20/8. */
@Composable
fun ShxScreenTitle(text: String, modifier: Modifier = Modifier, actions: @Composable RowScope.() -> Unit = {})
{
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = modifier.fillMaxWidth().padding(start = 20.dp, end = 12.dp, top = 18.dp, bottom = 8.dp),
)
{
Text(text, style = ShxType.screenTitle, color = LocalShxColors.current.text, modifier = Modifier.weight(1f).semantics { heading() })
actions()
}
}
/** Header of a secondary screen: 44dp back button and a 22/800 title. */
@Composable
fun ShxPageHeader(title: String, onBack: () -> Unit, modifier: Modifier = Modifier, actions: @Composable RowScope.() -> Unit = {})
{
val c = LocalShxColors.current
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
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(Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = stringResource(R.string.back), tint = c.text, modifier = Modifier.size(22.dp))
}
Text(
title,
style = ShxType.pageTitle,
color = c.text,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f).semantics { heading() },
)
actions()
}
}
/** Text action in a header: 13/700 purple, 40dp high. */
@Composable
fun ShxHeaderAction(text: String, onClick: () -> Unit)
{
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.height(40.dp).clip(RoundedCornerShape(20.dp)).clickable(onClick = onClick).padding(horizontal = 12.dp),
)
{
Text(text, style = ShxType.chip, color = LocalShxColors.current.accentText)
}
}
/** Section header above a card: 13/800 purple, +0.04em, margin 6/6/0. */
@Composable
fun ShxSectionHeader(text: String, modifier: Modifier = Modifier)
{
Text(
text = text.uppercase(),
style = ShxType.section,
color = LocalShxColors.current.accentText,
modifier = modifier.padding(start = 6.dp, end = 6.dp, top = 6.dp).semantics { heading() },
)
}
/** White card, 1px border, radius 24. */
@Composable
fun ShxPanel(
modifier: Modifier = Modifier,
padding: PaddingValues = PaddingValues(0.dp),
onClick: (() -> Unit)? = null,
content: @Composable ColumnScope.() -> Unit,
)
{
val c = LocalShxColors.current
val shape = RoundedCornerShape(24.dp)
Column(
modifier = modifier
.fillMaxWidth()
.clip(shape)
.background(c.card)
.border(1.dp, c.cardBorder, shape)
.then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier)
.padding(padding),
content = content,
)
}
/** Colored box (alerts, notes, hero cards): no border unless given. */
@Composable
fun ShxBox(
background: Color,
modifier: Modifier = Modifier,
radius: Dp = 24.dp,
border: Color? = null,
padding: PaddingValues = PaddingValues(16.dp),
content: @Composable ColumnScope.() -> Unit,
)
{
val shape = RoundedCornerShape(radius)
Column(
modifier = modifier
.fillMaxWidth()
.clip(shape)
.background(background)
.then(if (border != null) Modifier.border(1.dp, border, shape) else Modifier)
.padding(padding),
verticalArrangement = Arrangement.spacedBy(10.dp),
content = content,
)
}
/** Rounded square icon well (40dp, radius 14 by default). */
@Composable
fun ShxIconWell(icon: ImageVector, background: Color, tint: Color, size: Dp = 40.dp, radius: Dp = 14.dp, iconSize: Dp = 20.dp)
{
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.size(size).clip(RoundedCornerShape(radius)).background(background),
)
{
Icon(icon, contentDescription = null, tint = tint, modifier = Modifier.size(iconSize))
}
}
/**
* List row inside a card: 76dp, 40dp icon well, 16/700 title, 13 sub,
* optional trailing; a 1px divider below unless it is the last row.
*/
@Composable
fun ShxRow(
title: String,
modifier: Modifier = Modifier,
subtitle: String? = null,
icon: ImageVector? = null,
iconBackground: Color? = null,
iconTint: Color? = null,
subtitleColor: Color? = null,
divider: Boolean = false,
chevron: Boolean = false,
minHeight: Dp = 76.dp,
onClick: (() -> Unit)? = null,
leading: (@Composable () -> Unit)? = null,
trailing: (@Composable RowScope.() -> Unit)? = null,
)
{
val c = LocalShxColors.current
Column(modifier.fillMaxWidth())
{
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(14.dp),
modifier = Modifier
.fillMaxWidth()
.then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier)
.heightIn(min = minHeight)
.padding(start = 16.dp, end = 14.dp, top = 10.dp, bottom = 10.dp),
)
{
if (leading != null)
{
leading()
}
else if (icon != null)
{
ShxIconWell(icon, iconBackground ?: c.accentContainer, iconTint ?: c.onAccentContainer)
}
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp))
{
Text(title, style = ShxType.rowTitle, color = c.text)
if (subtitle != null)
{
Text(subtitle, style = ShxType.sub, color = subtitleColor ?: c.textSub)
}
}
trailing?.invoke(this)
if (chevron)
{
Icon(Icons.AutoMirrored.Rounded.KeyboardArrowRight, contentDescription = null, tint = c.textSub, modifier = Modifier.size(20.dp))
}
}
if (divider)
{
Box(Modifier.fillMaxWidth().height(1.dp).background(c.divider))
}
}
}
/** Toggle of the mockup: 52x32 track, 24dp white knob. */
@Composable
fun ShxSwitch(checked: Boolean, onCheckedChange: ((Boolean) -> Unit)?, modifier: Modifier = Modifier, enabled: Boolean = true)
{
val c = LocalShxColors.current
val track by animateColorAsState(if (checked) c.accent else c.outline, label = "switchTrack")
val offset by animateDpAsState(if (checked) 20.dp else 0.dp, label = "switchKnob")
Box(
modifier = modifier
.size(52.dp, 32.dp)
.clip(RoundedCornerShape(16.dp))
.background(if (enabled) track else track.copy(alpha = 0.4f))
.then(
if (onCheckedChange != null)
Modifier.toggleable(value = checked, enabled = enabled, role = Role.Switch, onValueChange = onCheckedChange)
else Modifier
)
.padding(4.dp),
)
{
Box(Modifier.offset(x = offset).size(24.dp).clip(CircleShape).background(Color.White))
}
}
enum class ShxButtonStyle { PRIMARY, TONAL, OUTLINE, WARNING, DANGER }
/** Buttons of the mockup: 56 (large), 48 (medium) or 36 (small) high. */
@Composable
fun ShxButton(
text: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
style: ShxButtonStyle = ShxButtonStyle.PRIMARY,
height: Dp = 56.dp,
enabled: Boolean = true,
icon: ImageVector? = null,
)
{
val c = LocalShxColors.current
val radius = when
{
height >= 56.dp -> 18.dp
height >= 44.dp -> 16.dp
else -> 12.dp
}
val shape = RoundedCornerShape(radius)
val (bg, fg) = when (style)
{
ShxButtonStyle.PRIMARY -> c.accent to c.onAccent
ShxButtonStyle.TONAL -> c.tile to c.textTonal
ShxButtonStyle.OUTLINE -> Color.Transparent to c.text
ShxButtonStyle.WARNING -> c.warning to Color.White
ShxButtonStyle.DANGER -> c.errorContainer to c.onErrorContainer
}
val textStyle = if (height >= 44.dp) ShxType.button else ShxType.chip.copy(fontWeight = FontWeight.ExtraBold)
Row(
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
modifier = modifier
.height(height)
.clip(shape)
.background(if (enabled) bg else bg.copy(alpha = 0.38f))
.then(if (style == ShxButtonStyle.OUTLINE) Modifier.border(1.dp, c.outline, shape) else Modifier)
.clickable(enabled = enabled, role = Role.Button, onClick = onClick)
.padding(horizontal = if (height >= 44.dp) 16.dp else 14.dp),
)
{
if (icon != null)
{
Icon(icon, contentDescription = null, tint = fg, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(8.dp))
}
Text(text, style = textStyle, color = if (enabled) fg else fg.copy(alpha = 0.6f), maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
enum class ShxChipTone { NORMAL, WARNING }
/** Filter chip: 34dp, radius 12; selected = deep purple fill. */
@Composable
fun ShxChip(text: String, selected: Boolean, onClick: () -> Unit, tone: ShxChipTone = ShxChipTone.NORMAL)
{
val c = LocalShxColors.current
val shape = RoundedCornerShape(12.dp)
val (bg, fg) = when
{
selected -> c.chipSelected to (if (c.chipSelected.luminanceIsDark()) Color.White else Color(0xFF1C1B20))
tone == ShxChipTone.WARNING -> c.warningStrong to c.onWarning
else -> Color.Transparent to c.text
}
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.height(34.dp)
.clip(shape)
.background(bg)
.then(if (!selected && tone == ShxChipTone.NORMAL) Modifier.border(1.dp, c.outline, shape) else Modifier)
.selectable(selected = selected, role = Role.Tab, onClick = onClick)
.padding(horizontal = 12.dp),
)
{
Text(text, style = if (selected) ShxType.chip else ShxType.chip.copy(fontWeight = FontWeight.SemiBold), color = fg, maxLines = 1, softWrap = false)
}
}
private fun Color.luminanceIsDark(): Boolean = (0.299f * red + 0.587f * green + 0.114f * blue) < 0.5f
/** Segmented tabs: grey track (radius 16, padding 4), white selected tab (radius 12, 40dp). */
@Composable
fun ShxTabs(options: List<String>, selected: Int, onSelect: (Int) -> Unit, modifier: Modifier = Modifier)
{
val c = LocalShxColors.current
Row(
modifier = modifier.fillMaxWidth().clip(RoundedCornerShape(16.dp)).background(c.tile).padding(4.dp),
)
{
options.forEachIndexed { index, label ->
val on = index == selected
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.weight(1f)
.height(40.dp)
.then(if (on) Modifier.shadow(1.dp, RoundedCornerShape(12.dp)) else Modifier)
.clip(RoundedCornerShape(12.dp))
.background(if (on) c.card else Color.Transparent)
.selectable(selected = on, role = Role.Tab, onClick = { onSelect(index) }),
)
{
Text(
label,
style = ShxType.body.copy(fontWeight = if (on) FontWeight.ExtraBold else FontWeight.SemiBold),
color = if (on) c.text else c.textSoft,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
}
enum class ShxChoiceTone { ACCENT, SUCCESS }
/** Selectable choice card (radius 18): 2px colored border when selected. */
@Composable
fun ShxChoiceCard(
title: String,
subtitle: String,
selected: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier,
tone: ShxChoiceTone = ShxChoiceTone.ACCENT,
badge: (@Composable () -> Unit)? = null,
)
{
val c = LocalShxColors.current
val shape = RoundedCornerShape(18.dp)
val (border, bg, titleColor, subColor) = when
{
!selected -> Quad(c.outline, c.card, c.text, c.textSoft)
tone == ShxChoiceTone.SUCCESS -> Quad(c.success, c.successSoft, c.onSuccessStrong, c.onSuccess)
else -> Quad(c.accent, c.accentSoft, c.text, c.textSoft)
}
Column(
verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = modifier
.clip(shape)
.background(bg)
.border(if (selected) 2.dp else 1.dp, border, shape)
.selectable(selected = selected, role = Role.RadioButton, onClick = onClick)
.padding(14.dp),
)
{
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp))
{
Text(title, style = ShxType.cardTitle, color = titleColor)
badge?.invoke()
}
Text(subtitle, style = ShxType.meta.copy(fontWeight = FontWeight.Normal), color = subColor)
}
}
private data class Quad(val a: Color, val b: Color, val c: Color, val d: Color)
/** Text field of the mockup: white box, radius 14, 2px purple border, label inside. */
@Composable
fun ShxField(
label: String,
value: String,
onValueChange: (String) -> Unit,
modifier: Modifier = Modifier,
mono: Boolean = false,
isError: Boolean = false,
errorText: String? = null,
keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
keyboardActions: KeyboardActions = KeyboardActions.Default,
)
{
val c = LocalShxColors.current
val shape = RoundedCornerShape(14.dp)
val tint = if (isError) c.error else c.accent
Column(modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(4.dp))
{
Column(
verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier
.fillMaxWidth()
.clip(shape)
.background(c.card)
.border(2.dp, tint, shape)
.padding(horizontal = 14.dp, vertical = 10.dp),
)
{
Text(label, style = ShxType.meta.copy(fontWeight = FontWeight.Bold), color = if (isError) c.error else c.accentText)
BasicTextField(
value = value,
onValueChange = onValueChange,
singleLine = true,
textStyle = (if (mono) ShxType.mono.copy(fontSize = 16.sp) else ShxType.body.copy(fontSize = 16.sp)).copy(color = c.text),
cursorBrush = SolidColor(c.accent),
keyboardOptions = keyboardOptions,
keyboardActions = keyboardActions,
modifier = Modifier.fillMaxWidth(),
)
}
if (isError && errorText != null)
{
Text(errorText, style = ShxType.meta, color = c.error, modifier = Modifier.padding(horizontal = 4.dp))
}
}
}
enum class ShxTone { ACCENT, WARNING, SUCCESS, NEUTRAL, ERROR }
/** Soft note box: 12/14 padding, radius 18, 13/1.45 text. */
@Composable
fun ShxNote(text: String, modifier: Modifier = Modifier, tone: ShxTone = ShxTone.ACCENT)
{
val c = LocalShxColors.current
val (bg, fg) = when (tone)
{
ShxTone.ACCENT -> c.accentSoft to c.textTonal
ShxTone.WARNING -> c.warningContainer to c.onWarningContainer
ShxTone.SUCCESS -> c.successSoft to c.onSuccess
ShxTone.NEUTRAL -> c.tile to c.textSoft
ShxTone.ERROR -> c.errorContainer to c.onErrorContainer
}
Text(
text = text,
style = ShxType.note,
color = fg,
modifier = modifier.fillMaxWidth().clip(RoundedCornerShape(18.dp)).background(bg).padding(horizontal = 14.dp, vertical = 12.dp),
)
}
/** Pill badge: 11/800, radius 999. */
@Composable
fun ShxBadge(text: String, tone: ShxTone = ShxTone.WARNING, modifier: Modifier = Modifier)
{
val c = LocalShxColors.current
val (bg, fg) = when (tone)
{
ShxTone.WARNING -> c.warningStrong to c.onWarning
ShxTone.ACCENT -> c.accentContainer to c.onAccentContainer
ShxTone.SUCCESS -> c.successContainer to c.onSuccess
ShxTone.NEUTRAL -> c.tile to c.textSoft
ShxTone.ERROR -> c.errorContainer to c.onErrorContainer
}
Text(
text = text,
style = ShxType.tag,
color = fg,
modifier = modifier.clip(RoundedCornerShape(999.dp)).background(bg).padding(horizontal = 8.dp, vertical = 2.dp),
)
}
/** Small square tag on rule rows: radius 6. */
@Composable
fun ShxTag(text: String, background: Color, color: Color)
{
Text(
text = text.uppercase(),
style = ShxType.tag.copy(fontSize = 10.sp),
color = color,
modifier = Modifier.clip(RoundedCornerShape(6.dp)).background(background).padding(horizontal = 6.dp, vertical = 2.dp),
)
}
/** Bottom sheet of the mockup: radius 32, 36x4 handle, page background. */
@Composable
fun ShxSheet(onDismiss: () -> Unit, content: @Composable ColumnScope.() -> Unit)
{
val c = LocalShxColors.current
ModalBottomSheet(
onDismissRequest = onDismiss,
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
containerColor = c.background,
scrimColor = c.scrim,
shape = RoundedCornerShape(topStart = 32.dp, topEnd = 32.dp),
dragHandle = {
Box(Modifier.padding(top = 12.dp, bottom = 4.dp).size(36.dp, 4.dp).clip(RoundedCornerShape(2.dp)).background(c.outline))
},
)
{
Column(
verticalArrangement = Arrangement.spacedBy(14.dp),
modifier = Modifier.fillMaxWidth().padding(start = 16.dp, end = 16.dp, bottom = 28.dp),
content = content,
)
}
}
/** Sheet title: 24/800 with 4dp side inset. */
@Composable
fun ShxSheetTitle(text: String)
{
Text(text, style = ShxType.sheetTitle, color = LocalShxColors.current.text, modifier = Modifier.padding(start = 4.dp, end = 4.dp, top = 4.dp).semantics { heading() })
}
/** Grey info line/tile button: 52dp, radius 18, 15/600. */
@Composable
fun ShxTileButton(text: String, icon: ImageVector?, onClick: () -> Unit, modifier: Modifier = Modifier)
{
val c = LocalShxColors.current
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
modifier = modifier
.fillMaxWidth()
.height(52.dp)
.clip(RoundedCornerShape(18.dp))
.background(c.tile)
.clickable(role = Role.Button, onClick = onClick)
.padding(horizontal = 16.dp),
)
{
if (icon != null)
{
Icon(icon, contentDescription = null, tint = c.textSub, modifier = Modifier.size(18.dp))
}
Text(text, style = ShxType.body.copy(fontSize = 15.sp), color = c.textSub)
}
}
/** Body text of a screen intro: 14, sub color, 6dp inset. */
@Composable
fun ShxIntro(text: String, modifier: Modifier = Modifier)
{
Text(text, style = ShxType.note.copy(fontSize = 14.sp), color = LocalShxColors.current.textSub, modifier = modifier.padding(horizontal = 4.dp))
}
@@ -0,0 +1,390 @@
/*
* Copyright (C) 2026 SHX VPN
*
* This program is free software; you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the
* Free Software Foundation; either version 2 of the License, or (at your
* option) any later version.
*/
package org.strongswan.android.ui.compose.home
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.material3.Text
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.LiveRegionMode
import androidx.compose.ui.semantics.liveRegion
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import org.strongswan.android.R
import org.strongswan.android.data.VpnProfile
import org.strongswan.android.logic.VpnStateService.State
import org.strongswan.android.ui.compose.ExitUi
import org.strongswan.android.ui.compose.HomeUiState
import org.strongswan.android.ui.compose.TrafficUi
import org.strongswan.android.ui.compose.design.ShxButton
import org.strongswan.android.ui.compose.design.ShxButtonStyle
import org.strongswan.android.ui.compose.design.ShxIcons
import org.strongswan.android.ui.compose.design.ShxNote
import org.strongswan.android.ui.compose.design.ShxRow
import org.strongswan.android.ui.compose.design.ShxTone
import org.strongswan.android.ui.compose.formatBytes
import org.strongswan.android.ui.compose.formatDuration
import org.strongswan.android.ui.compose.theme.LocalShxColors
import org.strongswan.android.ui.compose.theme.ShxType
/** Everything Home shows, prepared by HomeScreen. */
internal data class HomeModel(
val state: HomeUiState,
val traffic: TrafficUi,
val exit: ExitUi?,
val serverRttMs: Long?,
val problem: HomeProblem?,
val whiteListActive: Boolean,
val pausedUntil: Long,
val now: Long,
val ruDirect: Boolean,
val lockdown: Boolean,
val networkKinds: Pair<Boolean, Boolean>,
val unread: Int,
val findingFastest: Boolean,
)
/** What Home can do. */
internal class HomeActions(
val connect: () -> Unit,
val disconnect: () -> Unit,
val retry: () -> Unit,
val resume: () -> Unit,
val extendPause: () -> Unit,
val openPause: () -> Unit,
val fastest: () -> Unit,
val pickServer: () -> Unit,
val addServer: () -> Unit,
val editProfile: () -> Unit,
val showLog: () -> Unit,
val inbox: () -> Unit,
val diagnostics: () -> Unit,
val routeCheck: () -> Unit,
val networkSettings: () -> Unit,
val vpnSettings: () -> Unit,
val details: () -> Unit,
)
/** Two-letter badge of a server: the exit country when known, else the profile initials. */
internal fun serverCode(profile: VpnProfile?, exit: ExitUi?): String
{
exit?.country?.takeIf { it.length == 2 && it.all(Char::isLetter) }?.let { return it.uppercase() }
val name = profile?.name?.trim().orEmpty()
val letters = name.filter { it.isLetterOrDigit() }
return letters.take(2).uppercase().ifEmpty { "VPN" }
}
/** "185.•••.•••.12" like on the mockup. */
internal fun maskIp(ip: String): String
{
val parts = ip.split('.')
return if (parts.size == 4) "${parts[0]}.•••.•••.${parts[3]}" else ip
}
@Composable
internal fun HomeContent(m: HomeModel, a: HomeActions)
{
val c = LocalShxColors.current
val context = LocalContext.current
val s = m.state
val profile = s.activeProfile ?: s.selected
val connected = s.vpnState == State.CONNECTED
val connecting = s.vpnState == State.CONNECTING
val paused = m.pausedUntil > m.now && s.vpnState == State.DISABLED
val name = profile?.name ?: ""
Column(Modifier.fillMaxWidth())
{
HomeTopBar(unread = m.unread, onInbox = a.inbox, onDiagnostics = a.diagnostics)
/* ---------------- hero ---------------- */
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.fillMaxWidth().padding(top = 8.dp).semantics { liveRegion = LiveRegionMode.Polite },
)
{
when
{
s.profiles.isEmpty() ->
{
HeroBlob(heroLookEmpty(), ShxIcons.Plus, stringResource(R.string.home_empty_add), a.addServer)
HeroCaption(stringResource(R.string.home_empty_title), stringResource(R.string.home_empty_status))
}
paused ->
{
HeroBlob(heroLookIdle(), ShxIcons.Pause, stringResource(R.string.home_pause_resume), a.resume)
HeroCaption(
stringResource(R.string.home_paused_title, formatCountdown(m.pausedUntil - m.now)),
stringResource(R.string.home_paused_subtitle, formatClock(context, m.pausedUntil)),
)
}
m.whiteListActive && !connected ->
{
HeroBlob(heroLookWarning(), ShxIcons.Alert, stringResource(R.string.home_connect_now), a.connect)
HeroCaption(
stringResource(R.string.event_whitelist_on_title),
stringResource(R.string.home_wl_status),
titleColor = c.onWarningStrong,
statusColor = c.onWarningContainer,
titleSize = 22,
)
}
m.problem == HomeProblem.NO_NETWORK ->
{
HeroBlob(heroLookIdle(), ShxIcons.WifiOff, stringResource(R.string.disconnect), a.disconnect)
HeroCaption(stringResource(R.string.home_problem_offline_title), stringResource(R.string.home_problem_offline_subtitle))
}
m.problem == HomeProblem.UNREACHABLE || m.problem == HomeProblem.LOOKUP ->
{
HeroBlob(heroLookWarning(), ShxIcons.Alert, stringResource(R.string.disconnect), a.disconnect)
HeroCaption(
stringResource(m.problem.titleRes()),
if (s.retryIn > 0) stringResource(R.string.home_unreachable_status, name, s.retryIn) else name,
)
}
m.problem != null ->
{
HeroBlob(heroLookError(), ShxIcons.Alert, stringResource(R.string.retry), a.retry)
HeroCaption(
stringResource(m.problem.titleRes()),
stringResource(R.string.home_error_status, name, stringResource(s.errorText)),
titleColor = c.onErrorContainer,
statusColor = c.onErrorContainer,
)
}
connected ->
{
HeroBlob(heroLookConnected(), ShxIcons.Power, stringResource(R.string.disconnect), a.disconnect)
HeroCaption(
stringResource(R.string.status_protected),
if (m.ruDirect) stringResource(R.string.home_status_ru, formatDuration(m.traffic.durationSec))
else formatDuration(m.traffic.durationSec),
dot = c.success,
)
}
connecting || s.vpnState == State.DISCONNECTING ->
{
HeroBlob(heroLookConnecting(), ShxIcons.Power, stringResource(R.string.home_cancel_connect), a.disconnect)
HeroCaption(
stringResource(if (connecting) R.string.status_connecting else R.string.state_disconnecting),
name,
)
}
else ->
{
HeroBlob(heroLookIdle(), ShxIcons.Power, stringResource(R.string.connect), a.connect, enabled = profile != null)
HeroCaption(stringResource(R.string.home_state_off), stringResource(R.string.home_state_off_status))
}
}
}
/* ---------------- below the hero ---------------- */
Column(
verticalArrangement = Arrangement.spacedBy(10.dp),
modifier = Modifier.fillMaxWidth().padding(start = 16.dp, end = 16.dp, top = 22.dp, bottom = 24.dp),
)
{
val server: @Composable () -> Unit = {
val details = when
{
connected && m.exit?.ip != null ->
if (m.serverRttMs != null) stringResource(R.string.home_server_ip_rtt, maskIp(m.exit.ip), m.serverRttMs)
else stringResource(R.string.home_server_ip, maskIp(m.exit.ip))
else -> profile?.gateway ?: ""
}
ServerCard(serverCode(profile, m.exit), name, details, stringResource(R.string.home_server_change), a.pickServer)
}
when
{
s.profiles.isEmpty() ->
{
ShxButton(stringResource(R.string.home_empty_add), a.addServer, height = 60.dp, modifier = Modifier.fillMaxWidth(), icon = ShxIcons.Plus)
EmptyInfo()
}
paused ->
{
server()
Row(horizontalArrangement = Arrangement.spacedBy(8.dp))
{
ShxButton(stringResource(R.string.home_pause_resume), a.resume, modifier = Modifier.weight(1f))
ShxButton(stringResource(R.string.home_pause_extend), a.extendPause, style = ShxButtonStyle.TONAL, modifier = Modifier.weight(1f))
}
ShxNote(stringResource(R.string.home_pause_note), tone = ShxTone.WARNING)
}
m.whiteListActive && !connected ->
{
Row(horizontalArrangement = Arrangement.spacedBy(8.dp))
{
ShxButton(stringResource(R.string.home_wl_what), a.diagnostics, style = ShxButtonStyle.WARNING, height = 52.dp, modifier = Modifier.weight(1f))
ShxButton(stringResource(R.string.home_wl_wifi), a.networkSettings, style = ShxButtonStyle.TONAL, height = 52.dp, modifier = Modifier.weight(1f))
}
if (!m.lockdown)
{
LockdownRow(a.vpnSettings)
}
}
m.problem == HomeProblem.NO_NETWORK ->
{
val (wifi, cell) = m.networkKinds
CheckPanel {
CheckLine(if (wifi) "✓" else "×", if (wifi) c.successContainer else c.errorContainer, if (wifi) c.onSuccess else c.onErrorContainer,
stringResource(R.string.network_wifi), stringResource(if (wifi) R.string.home_net_on else R.string.home_net_off))
CheckLine(if (cell) "✓" else "×", if (cell) c.successContainer else c.errorContainer, if (cell) c.onSuccess else c.onErrorContainer,
stringResource(R.string.network_cellular), stringResource(if (cell) R.string.home_net_on else R.string.home_net_off))
CheckLine("", c.tile, c.iconMuted, name, stringResource(R.string.home_net_waiting), muted = true)
}
StateCard(
background = c.accentSoft, border = c.accentContainer, titleColor = c.onAccentContainer, textColor = c.onAccentContainer,
title = stringResource(R.string.home_problem_offline_card),
text = stringResource(R.string.home_problem_offline_text),
primary = stringResource(R.string.home_problem_network_settings) to a.networkSettings,
)
}
m.problem == HomeProblem.UNREACHABLE || m.problem == HomeProblem.LOOKUP ->
{
val multiple = s.profiles.size > 1
StateCard(
background = c.warningContainer, border = c.warningBorder, titleColor = c.onWarningContainer, textColor = c.onWarningContainer,
title = stringResource(if (m.problem == HomeProblem.UNREACHABLE) R.string.home_problem_unreachable_card else R.string.home_problem_lookup_card),
text = stringResource(if (m.problem == HomeProblem.UNREACHABLE) R.string.home_problem_unreachable_text else R.string.home_problem_lookup_text),
primary = if (multiple) stringResource(R.string.home_fastest_server) to a.fastest else stringResource(R.string.retry) to a.retry,
secondary = if (multiple) stringResource(R.string.retry) to a.retry else null,
)
CheckRow(a.diagnostics)
}
m.problem != null ->
{
server()
val auth = m.problem == HomeProblem.AUTH
StateCard(
background = c.errorSoft, border = c.errorBorder, titleColor = c.onErrorContainer, textColor = c.onErrorSoft,
title = stringResource(when (m.problem)
{
HomeProblem.AUTH -> R.string.home_problem_auth_card
HomeProblem.SERVER_CERT -> R.string.home_problem_cert_card
else -> R.string.home_problem_generic_card
}),
text = stringResource(when (m.problem)
{
HomeProblem.AUTH -> R.string.home_problem_auth_text
HomeProblem.SERVER_CERT -> R.string.home_problem_cert_text
else -> R.string.home_problem_generic_text
}),
primary = if (auth) stringResource(R.string.home_problem_edit_profile) to a.editProfile else stringResource(R.string.retry) to a.retry,
secondary = stringResource(R.string.show_log) to a.showLog,
)
if (s.profiles.size > 1)
{
ShxButton(stringResource(R.string.home_other_server), a.pickServer, style = ShxButtonStyle.TONAL, height = 52.dp, modifier = Modifier.fillMaxWidth())
}
}
connected ->
{
server()
Row(horizontalArrangement = Arrangement.spacedBy(8.dp))
{
QuickTile(ShxIcons.Pause, stringResource(R.string.home_action_pause15), a.openPause, Modifier.weight(1f))
QuickTile(ShxIcons.Bolt, stringResource(if (m.findingFastest) R.string.home_fastest_searching else R.string.home_action_fastest), a.fastest, Modifier.weight(1f))
QuickTile(ShxIcons.Help, stringResource(R.string.home_action_not_loading), a.routeCheck, Modifier.weight(1f))
}
val t = m.traffic
val split = t.split
val vpnShare = split?.let { 100 - it.directShare }
TrafficCard(
amount = formatBytes(t.bytesIn + t.bytesOut),
period = stringResource(R.string.home_traffic_session),
vpnShare = vpnShare,
caption = if (vpnShare != null) stringResource(R.string.home_traffic_split, vpnShare, 100 - vpnShare)
else stringResource(R.string.home_traffic_all_vpn),
onClick = a.details,
)
}
connecting || s.vpnState == State.DISCONNECTING ->
{
server()
ShxButton(stringResource(R.string.home_cancel), a.disconnect, style = ShxButtonStyle.TONAL, height = 48.dp, modifier = Modifier.fillMaxWidth())
}
else ->
{
server()
if (s.profiles.size > 1)
{
Row(horizontalArrangement = Arrangement.spacedBy(8.dp))
{
QuickTile(ShxIcons.Bolt, stringResource(if (m.findingFastest) R.string.home_fastest_searching else R.string.home_action_fastest), a.fastest, Modifier.weight(1f))
QuickTile(ShxIcons.Globe, stringResource(R.string.home_action_switch), a.pickServer, Modifier.weight(1f))
}
}
}
}
}
}
}
/** «Where to get a profile?» card of the empty Home. */
@Composable
private fun EmptyInfo()
{
val c = LocalShxColors.current
val shape = RoundedCornerShape(20.dp)
Column(
verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier.fillMaxWidth().clip(shape).background(c.card).border(1.dp, c.cardBorder, shape).padding(horizontal = 16.dp, vertical = 14.dp),
)
{
Text(stringResource(R.string.home_empty_where_title), style = ShxType.body.copy(fontWeight = FontWeight.ExtraBold), color = c.text)
Text(stringResource(R.string.home_empty_where_text), style = ShxType.note, color = c.textSub)
}
}
/** "Always-on VPN is off · Turn on" row under the whitelist alert. */
@Composable
private fun LockdownRow(onEnable: () -> Unit)
{
val c = LocalShxColors.current
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(18.dp)).background(c.tileSoft).padding(start = 14.dp, end = 12.dp, top = 10.dp, bottom = 10.dp),
)
{
Text(stringResource(R.string.home_lockdown_off), style = ShxType.sub.copy(fontWeight = FontWeight.SemiBold), color = c.textTonal, modifier = Modifier.weight(1f))
ShxButton(stringResource(R.string.home_lockdown_enable), onEnable, style = ShxButtonStyle.TONAL, height = 36.dp)
}
}
/** "Check what is wrong" row (white card with chevron). */
@Composable
private fun CheckRow(onClick: () -> Unit)
{
val c = LocalShxColors.current
val shape = RoundedCornerShape(20.dp)
Column(Modifier.fillMaxWidth().clip(shape).background(c.card).border(1.dp, c.cardBorder, shape))
{
ShxRow(
title = stringResource(R.string.home_check_row),
subtitle = stringResource(R.string.home_check_row_sub),
chevron = true,
minHeight = 64.dp,
onClick = onClick,
)
}
}
@@ -0,0 +1,418 @@
/*
* Copyright (C) 2026 SHX VPN
*
* This program is free software; you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the
* Free Software Foundation; either version 2 of the License, or (at your
* option) any later version.
*/
package org.strongswan.android.ui.compose.home
import androidx.compose.animation.animateColorAsState
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.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.RoundRect
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Outline
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.heading
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import org.strongswan.android.R
import org.strongswan.android.ui.compose.design.ShxButton
import org.strongswan.android.ui.compose.design.ShxButtonStyle
import org.strongswan.android.ui.compose.design.ShxIcons
import org.strongswan.android.ui.compose.theme.LocalShxColors
import org.strongswan.android.ui.compose.theme.ShxType
/*
* The parts of the Home screen, 1:1 with the «Final 2026» boards
* (F_Home, F_HomeAlert, F_HomeConnecting, F_HomePaused, F_HomeEmpty,
* F_HomeOffline, F_HomeUnreachable).
*/
/** Header: Shield-S square, "shx vpn" wordmark, events bell (with count) and diagnostics. */
@Composable
internal fun HomeTopBar(unread: Int, onInbox: () -> Unit, onDiagnostics: () -> Unit)
{
val c = LocalShxColors.current
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth().padding(start = 20.dp, end = 20.dp, top = 18.dp, bottom = 4.dp),
)
{
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.weight(1f))
{
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(34.dp).clip(RoundedCornerShape(10.dp)).background(c.accent))
{
Icon(painterResource(R.drawable.ic_notification), contentDescription = null, tint = Color.White, modifier = Modifier.size(26.dp))
}
Text(
text = buildAnnotatedString {
append("shx")
withStyle(SpanStyle(fontWeight = FontWeight.Medium, color = c.accentText)) { append("vpn") }
},
style = ShxType.pageTitle.copy(letterSpacing = (-0.04).sp),
color = c.text,
modifier = Modifier.semantics { heading() },
)
}
val bellLabel = if (unread > 0) androidx.compose.ui.res.pluralStringResource(R.plurals.home_events_unread, unread, unread)
else stringResource(R.string.events_title)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp))
{
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.size(44.dp)
.clip(CircleShape)
.background(c.tile)
.clickable(role = Role.Button, onClick = onInbox)
.semantics { contentDescription = bellLabel },
)
{
Icon(ShxIcons.Bell, contentDescription = null, tint = c.text, modifier = Modifier.size(22.dp))
if (unread > 0)
{
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.align(Alignment.TopEnd)
.offset(x = (-2).dp, y = 4.dp)
.heightIn(min = 18.dp)
.widthIn(min = 18.dp)
.clip(RoundedCornerShape(9.dp))
.background(c.accent)
.border(2.dp, c.background, RoundedCornerShape(9.dp))
.padding(horizontal = 4.dp),
)
{
Text(if (unread > 99) "99+" else unread.toString(), style = ShxType.tag.copy(fontSize = 10.sp), color = Color.White)
}
}
}
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.size(44.dp)
.clip(CircleShape)
.background(c.tile)
.clickable(role = Role.Button, onClick = onDiagnostics),
)
{
Icon(ShxIcons.Pulse, contentDescription = stringResource(R.string.diag_title), tint = c.text, modifier = Modifier.size(22.dp))
}
}
}
}
/** Colors of the hero for one state: fill, icon, inner and outer ring. */
internal data class HeroLook(val fill: Color, val icon: Color, val ring1: Color, val ring2: Color)
@Composable
internal fun heroLookConnected() = LocalShxColors.current.let { HeroLook(it.accent, Color.White, it.accentContainer, it.accentSoft) }
@Composable
internal fun heroLookConnecting() = LocalShxColors.current.let { HeroLook(it.accentContainer, it.accentText, it.accentSoft, it.accentFaint) }
@Composable
internal fun heroLookIdle() = LocalShxColors.current.let { HeroLook(it.tile, it.textSoft, it.tileSoft, it.tileFaint) }
@Composable
internal fun heroLookEmpty() = LocalShxColors.current.let { HeroLook(it.tile, it.iconMuted, it.tileSoft, it.tileFaint) }
@Composable
internal fun heroLookError() = LocalShxColors.current.let { HeroLook(it.errorContainer, it.error, it.errorRing, it.errorFaint) }
@Composable
internal fun heroLookWarning() = LocalShxColors.current.let { HeroLook(it.warningStrong, it.warning, it.warningRing, it.warningFaint) }
/**
* The organic blob of the mockup: border-radius 40% 60% 55% 45% / 50% 45% 55% 50%,
* grown by [spread] on every side (the box-shadow rings).
*/
private class BlobShape(private val spread: Float = 0f) : Shape
{
override fun createOutline(size: Size, layoutDirection: LayoutDirection, density: Density): Outline =
Outline.Generic(Path().apply { addRoundRect(blob(size, spread)) })
}
private fun blob(size: Size, spread: Float): RoundRect
{
val w = size.width
val h = size.height
return RoundRect(
left = -spread, top = -spread, right = w + spread, bottom = h + spread,
topLeftCornerRadius = CornerRadius(0.40f * w + spread, 0.50f * h + spread),
topRightCornerRadius = CornerRadius(0.60f * w + spread, 0.45f * h + spread),
bottomRightCornerRadius = CornerRadius(0.55f * w + spread, 0.55f * h + spread),
bottomLeftCornerRadius = CornerRadius(0.45f * w + spread, 0.50f * h + spread),
)
}
/** The 188dp hero button with its two 14dp rings, margin 28/22 like the mockup. */
@Composable
internal fun HeroBlob(look: HeroLook, icon: ImageVector, label: String, onClick: () -> Unit, enabled: Boolean = true)
{
val fill by animateColorAsState(look.fill, label = "heroFill")
val iconTint by animateColorAsState(look.icon, label = "heroIcon")
val ring1 by animateColorAsState(look.ring1, label = "heroRing1")
val ring2 by animateColorAsState(look.ring2, label = "heroRing2")
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.padding(top = 28.dp, bottom = 22.dp)
.size(188.dp)
.drawBehind {
val r2 = 28.dp.toPx()
val r1 = 14.dp.toPx()
drawPath(Path().apply { addRoundRect(blob(size, r2)) }, ring2)
drawPath(Path().apply { addRoundRect(blob(size, r1)) }, ring1)
}
.clip(BlobShape())
.background(fill)
.clickable(enabled = enabled, role = Role.Button, onClickLabel = label, onClick = onClick)
.semantics { contentDescription = label },
)
{
Icon(icon, contentDescription = null, tint = iconTint, modifier = Modifier.size(68.dp))
}
}
/** State title (24/800) and the status line (14/600) under the hero. */
@Composable
internal fun HeroCaption(title: String, status: String, titleColor: Color? = null, statusColor: Color? = null, dot: Color? = null, titleSize: Int = 24)
{
val c = LocalShxColors.current
Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.padding(horizontal = 24.dp))
{
Text(
title,
style = ShxType.heroState.copy(fontSize = titleSize.sp),
color = titleColor ?: c.text,
textAlign = TextAlign.Center,
)
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.widthIn(max = 320.dp))
{
if (dot != null)
{
Box(Modifier.size(8.dp).clip(CircleShape).background(dot))
}
Text(
status,
style = ShxType.body.copy(lineHeight = 20.sp),
color = statusColor ?: c.textSub,
textAlign = TextAlign.Center,
)
}
}
}
/** The server card: 40dp country circle, name 16/700, details 13, "Change" 13/700 purple. */
@Composable
internal fun ServerCard(code: String, name: String, details: String, action: String, onClick: () -> Unit)
{
val c = LocalShxColors.current
val shape = RoundedCornerShape(24.dp)
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier
.fillMaxWidth()
.clip(shape)
.background(c.card)
.border(1.dp, c.cardBorder, shape)
.clickable(role = Role.Button, onClick = onClick)
.padding(horizontal = 14.dp, vertical = 12.dp),
)
{
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(40.dp).clip(CircleShape).background(c.accentContainer))
{
Text(code, style = ShxType.body.copy(fontWeight = FontWeight.ExtraBold), color = c.onAccentContainer)
}
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp))
{
Text(name, style = ShxType.rowTitle, color = c.text, maxLines = 1, overflow = TextOverflow.Ellipsis)
Text(details, style = ShxType.sub, color = c.textSub, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
Text(action, style = ShxType.chip, color = c.accentText)
}
}
/** One of the three quick action tiles: 72dp, radius 20, grey. */
@Composable
internal fun QuickTile(icon: ImageVector, label: String, onClick: () -> Unit, modifier: Modifier = Modifier)
{
val c = LocalShxColors.current
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(6.dp, Alignment.CenterVertically),
modifier = modifier
.heightIn(min = 72.dp)
.clip(RoundedCornerShape(20.dp))
.background(c.tile)
.clickable(role = Role.Button, onClick = onClick)
.padding(horizontal = 4.dp, vertical = 8.dp),
)
{
Icon(icon, contentDescription = null, tint = c.textTonal, modifier = Modifier.size(20.dp))
Text(label, style = ShxType.meta.copy(fontWeight = FontWeight.Bold), color = c.textTonal, textAlign = TextAlign.Center, maxLines = 2)
}
}
/** Traffic card: big number, split bar (VPN purple / direct green) and a caption. */
@Composable
internal fun TrafficCard(amount: String, period: String, vpnShare: Int?, caption: String, onClick: () -> Unit)
{
val c = LocalShxColors.current
val shape = RoundedCornerShape(24.dp)
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier
.fillMaxWidth()
.clip(shape)
.background(c.card)
.border(1.dp, c.cardBorder, shape)
.clickable(role = Role.Button, onClick = onClick)
.padding(14.dp),
)
{
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(8.dp))
{
Row(verticalAlignment = Alignment.Bottom, horizontalArrangement = Arrangement.spacedBy(8.dp))
{
Text(amount, style = ShxType.number, color = c.text)
Text(period, style = ShxType.sub, color = c.textSub, modifier = Modifier.padding(bottom = 2.dp))
}
Row(Modifier.fillMaxWidth().height(6.dp).clip(RoundedCornerShape(3.dp)), horizontalArrangement = Arrangement.spacedBy(2.dp))
{
val share = (vpnShare ?: 100).coerceIn(0, 100)
if (share > 0)
{
Box(Modifier.weight(share.toFloat()).height(6.dp).clip(RoundedCornerShape(3.dp)).background(c.accent))
}
if (share < 100)
{
Box(Modifier.weight((100 - share).toFloat()).height(6.dp).clip(RoundedCornerShape(3.dp)).background(c.successBar))
}
}
Text(caption, style = ShxType.meta, color = c.textSub)
}
Icon(ShxIcons.Chevron, contentDescription = null, tint = c.textSub, modifier = Modifier.size(18.dp))
}
}
/** Status card of the problem states: radius 24, title 15/800, text 13/1.45, buttons 48. */
@Composable
internal fun StateCard(
background: Color,
border: Color,
titleColor: Color,
textColor: Color,
title: String,
text: String,
extra: String? = null,
primary: Pair<String, () -> Unit>? = null,
secondary: Pair<String, () -> Unit>? = null,
)
{
val shape = RoundedCornerShape(24.dp)
Column(
verticalArrangement = Arrangement.spacedBy(10.dp),
modifier = Modifier.fillMaxWidth().clip(shape).background(background).border(1.dp, border, shape).padding(16.dp),
)
{
Text(title, style = ShxType.cardTitle, color = titleColor)
Text(text, style = ShxType.note, color = textColor)
if (extra != null)
{
Text(extra, style = ShxType.meta.copy(fontWeight = FontWeight.Bold), color = textColor)
}
if (primary != null || secondary != null)
{
Row(horizontalArrangement = Arrangement.spacedBy(8.dp))
{
primary?.let { (label, action) ->
ShxButton(label, action, height = 48.dp, modifier = Modifier.weight(1f))
}
secondary?.let { (label, action) ->
ShxButton(label, action, style = ShxButtonStyle.TONAL, height = 48.dp)
}
}
}
}
}
/** Row of check marks (connecting steps, network state): 22dp mark, 14/600 text, 13 value. */
@Composable
internal fun CheckLine(mark: String, markBg: Color, markFg: Color, title: String, value: String, strong: Boolean = false, muted: Boolean = false)
{
val c = LocalShxColors.current
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.fillMaxWidth().heightIn(min = 40.dp))
{
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(22.dp).clip(CircleShape).background(markBg))
{
Text(mark, style = ShxType.meta.copy(fontWeight = FontWeight.ExtraBold), color = markFg)
}
Text(
title,
style = ShxType.body.copy(fontWeight = if (strong) FontWeight.ExtraBold else FontWeight.SemiBold),
color = if (muted) c.iconMuted else c.text,
modifier = Modifier.weight(1f),
)
Text(value, style = ShxType.sub, color = c.textSub)
}
}
/** White panel holding [CheckLine]s: radius 24, padding 8/16 (6/16 when compact). */
@Composable
internal fun CheckPanel(content: @Composable () -> Unit)
{
val c = LocalShxColors.current
val shape = RoundedCornerShape(24.dp)
Column(Modifier.fillMaxWidth().clip(shape).background(c.card).border(1.dp, c.cardBorder, shape).padding(horizontal = 16.dp, vertical = 6.dp))
{
content()
}
}
@@ -7,72 +7,50 @@
* option) any later version.
*/
@file:OptIn(ExperimentalMaterial3Api::class)
package org.strongswan.android.ui.compose.home
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.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.CloudOff
import androidx.compose.material.icons.rounded.HelpOutline
import androidx.compose.material.icons.rounded.Key
import androidx.compose.material.icons.rounded.Pause
import androidx.compose.material.icons.rounded.SearchOff
import androidx.compose.material.icons.rounded.SwapHoriz
import androidx.compose.material.icons.rounded.VerifiedUser
import androidx.compose.material.icons.rounded.WarningAmber
import androidx.compose.material.icons.rounded.WifiOff
import androidx.compose.material.icons.rounded.NotificationsNone
import androidx.compose.material3.Badge
import androidx.compose.material3.BadgedBox
import androidx.compose.material3.Button
import androidx.compose.material3.FilledTonalIconButton
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.heading
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlinx.coroutines.delay
import org.strongswan.android.R
import org.strongswan.android.logic.PauseManager
import org.strongswan.android.logic.VpnStateService.ErrorState
import org.strongswan.android.logic.VpnStateService.State
import org.strongswan.android.ui.compose.HomeUiState
import org.strongswan.android.ui.compose.theme.LocalShxStatusColors
import org.strongswan.android.ui.compose.theme.ShxSpacing
import org.strongswan.android.ui.compose.design.ShxButton
import org.strongswan.android.ui.compose.design.ShxIcons
import org.strongswan.android.ui.compose.design.ShxSheet
import org.strongswan.android.ui.compose.theme.LocalShxColors
import org.strongswan.android.ui.compose.theme.ShxType
import java.util.Date
/** What keeps the tunnel from working right now, in the user's terms. */
@@ -109,7 +87,7 @@ internal fun homeProblem(state: HomeUiState, networkAvailable: Boolean): HomePro
}
}
/** Short headline for the hero while [problem] is shown. */
/** Short headline for the hero while [this] problem is shown. */
internal fun HomeProblem.titleRes(): Int = when (this)
{
HomeProblem.NO_NETWORK -> R.string.home_problem_offline_title
@@ -120,103 +98,6 @@ internal fun HomeProblem.titleRes(): Int = when (this)
HomeProblem.GENERIC -> R.string.home_problem_generic_title
}
/** App bar of the Connect tab: the Shield-S mark, the name and the events bell. */
@Composable
internal fun HomeHeader(unread: Int, onInbox: () -> Unit, modifier: Modifier = Modifier)
{
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = modifier.fillMaxWidth().padding(start = ShxSpacing.xl - ShxSpacing.xs, end = ShxSpacing.md, top = ShxSpacing.sm),
)
{
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.size(34.dp)
.background(MaterialTheme.colorScheme.primary, RoundedCornerShape(10.dp)),
)
{
Icon(
painterResource(R.drawable.ic_notification),
contentDescription = null,
tint = MaterialTheme.colorScheme.onPrimary,
modifier = Modifier.size(26.dp),
)
}
Spacer(Modifier.size(10.dp))
Text(
text = stringResource(R.string.app_name),
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.ExtraBold,
modifier = Modifier.weight(1f).heightIn(min = 44.dp).wrapContentHeight().semantics { heading() },
)
val bellLabel = if (unread > 0) pluralStringResource(R.plurals.home_events_unread, unread, unread)
else stringResource(R.string.events_title)
FilledTonalIconButton(onClick = onInbox, modifier = Modifier.size(44.dp).semantics { contentDescription = bellLabel })
{
BadgedBox(badge = {
if (unread > 0)
{
Badge { Text(if (unread > 99) "99+" else unread.toString()) }
}
})
{
Icon(Icons.Rounded.NotificationsNone, contentDescription = null)
}
}
}
}
/** The three most common actions while connected. */
@Composable
internal fun HomeQuickActions(
onPause: () -> Unit,
onSwitch: () -> Unit,
onDiagnose: () -> Unit,
modifier: Modifier = Modifier,
)
{
Row(
horizontalArrangement = Arrangement.spacedBy(ShxSpacing.sm),
modifier = modifier.fillMaxWidth().padding(horizontal = ShxSpacing.lg),
)
{
QuickAction(Icons.Rounded.Pause, stringResource(R.string.home_action_pause), onPause, Modifier.weight(1f))
QuickAction(Icons.Rounded.SwapHoriz, stringResource(R.string.home_action_switch), onSwitch, Modifier.weight(1f))
QuickAction(Icons.Rounded.HelpOutline, stringResource(R.string.home_action_not_loading), onDiagnose, Modifier.weight(1f))
}
}
@Composable
private fun QuickAction(icon: ImageVector, label: String, onClick: () -> Unit, modifier: Modifier)
{
Surface(
onClick = onClick,
shape = MaterialTheme.shapes.large,
color = MaterialTheme.colorScheme.surfaceContainerHigh,
contentColor = MaterialTheme.colorScheme.onSurface,
modifier = modifier.heightIn(min = 72.dp),
)
{
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(6.dp, Alignment.CenterVertically),
modifier = Modifier.padding(horizontal = ShxSpacing.xs, vertical = ShxSpacing.md),
)
{
Icon(icon, contentDescription = null, modifier = Modifier.size(20.dp))
Text(
text = label,
style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
}
}
/** Wall-clock "now" that ticks every second while composed. */
@Composable
internal fun rememberNow(): Long
@@ -242,207 +123,88 @@ internal fun formatCountdown(ms: Long): String
internal fun formatClock(context: android.content.Context, at: Long): String =
android.text.format.DateFormat.getTimeFormat(context).format(Date(at))
/** Shown while the VPN is paused: when it comes back, and the two ways to change that. */
internal const val DEFAULT_PAUSE_MINUTES = 15
/** -1 stands for "until the morning" (07:00). */
private val PAUSE_OPTIONS = listOf(5, 15, 60, -1)
/** «Pause VPN» sheet of the mockup: 2x2 durations, "until the network changes", one big button. */
@Composable
internal fun PauseCard(until: Long, onResume: () -> Unit, onExtend: () -> Unit, modifier: Modifier = Modifier)
internal fun PauseSheet(onDismiss: () -> Unit, onPick: (Int) -> Unit, onMorning: () -> Unit, onNetworkChange: () -> Unit)
{
Column(
verticalArrangement = Arrangement.spacedBy(ShxSpacing.sm),
modifier = modifier.fillMaxWidth().padding(horizontal = ShxSpacing.lg),
)
val c = LocalShxColors.current
val context = LocalContext.current
val now = remember { System.currentTimeMillis() }
var choice by remember { mutableStateOf(DEFAULT_PAUSE_MINUTES) }
fun until(option: Int) = if (option < 0) PauseManager.nextMorning(now) else now + option * 60_000L
fun label(option: Int): String = when
{
Row(horizontalArrangement = Arrangement.spacedBy(ShxSpacing.sm))
option < 0 -> context.getString(R.string.home_pause_morning)
option >= 60 -> context.getString(R.string.home_pause_hours, option / 60)
else -> context.getString(R.string.home_pause_min, option)
}
ShxSheet(onDismiss = onDismiss)
{
Column(verticalArrangement = Arrangement.spacedBy(4.dp), modifier = Modifier.padding(start = 4.dp, end = 4.dp, top = 4.dp))
{
Button(onClick = onResume, modifier = Modifier.weight(1f).heightIn(min = 56.dp))
Text(stringResource(R.string.home_pause_title), style = ShxType.sheetTitle, color = c.text)
Text(stringResource(R.string.home_pause_explain), style = ShxType.note.copy(fontSize = 14.sp), color = c.textSub)
}
PAUSE_OPTIONS.chunked(2).forEach { row ->
Row(horizontalArrangement = Arrangement.spacedBy(8.dp))
{
Text(stringResource(R.string.home_pause_resume), fontWeight = FontWeight.Bold)
}
FilledTonalButton(onClick = onExtend, modifier = Modifier.weight(1f).heightIn(min = 56.dp))
{
Text(stringResource(R.string.home_pause_extend), fontWeight = FontWeight.Bold)
row.forEach { option ->
val on = option == choice
val shape = RoundedCornerShape(20.dp)
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(2.dp, Alignment.CenterVertically),
modifier = Modifier
.weight(1f)
.height(64.dp)
.clip(shape)
.background(if (on) c.accentSoft else c.card)
.border(if (on) 2.dp else 1.dp, if (on) c.accent else c.cardBorder, shape)
.selectable(selected = on, role = Role.RadioButton, onClick = { choice = option }),
)
{
Text(label(option), style = ShxType.cardHeading, color = c.text)
Text(stringResource(R.string.home_pause_until, formatClock(context, until(option))), style = ShxType.meta.copy(fontWeight = FontWeight.Normal), color = c.textSub)
}
}
}
}
val status = LocalShxStatusColors.current
Surface(
shape = MaterialTheme.shapes.large,
color = status.connectingContainer,
contentColor = status.onConnectingContainer,
val shape = RoundedCornerShape(20.dp)
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier
.fillMaxWidth()
.clip(shape)
.background(c.card)
.border(1.dp, c.cardBorder, shape)
.clickable(role = Role.Button, onClick = onNetworkChange)
.padding(horizontal = 16.dp, vertical = 14.dp),
)
{
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp))
{
Text(stringResource(R.string.home_pause_network), style = ShxType.cardTitle.copy(fontWeight = FontWeight.Bold), color = c.text)
Text(stringResource(R.string.home_pause_network_sub), style = ShxType.meta.copy(fontWeight = FontWeight.Normal), color = c.textSub)
}
Icon(ShxIcons.Chevron, contentDescription = null, tint = c.iconMuted, modifier = Modifier.size(18.dp))
}
ShxButton(
text = if (choice < 0) stringResource(R.string.home_pause_go_morning) else stringResource(R.string.home_pause_go, label(choice)),
onClick = { if (choice < 0) onMorning() else onPick(choice) },
modifier = Modifier.fillMaxWidth(),
)
Text(
stringResource(R.string.home_pause_footer),
style = ShxType.meta.copy(fontWeight = FontWeight.Normal),
color = c.textSub,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth(),
)
{
Text(
text = stringResource(R.string.home_pause_note),
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(horizontal = ShxSpacing.lg, vertical = ShxSpacing.md),
)
}
}
}
/** Bottom sheet with the pause durations. */
@Composable
internal fun PauseSheet(onDismiss: () -> Unit, onPick: (Int) -> Unit)
{
ModalBottomSheet(
onDismissRequest = onDismiss,
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
containerColor = MaterialTheme.colorScheme.surfaceContainerLow,
)
{
Column(
verticalArrangement = Arrangement.spacedBy(ShxSpacing.sm),
modifier = Modifier.fillMaxWidth().padding(horizontal = ShxSpacing.xl).padding(bottom = ShxSpacing.xl),
)
{
Text(
text = stringResource(R.string.home_pause_title),
style = MaterialTheme.typography.titleLarge,
modifier = Modifier.semantics { heading() },
)
Text(
text = stringResource(R.string.home_pause_explain),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(ShxSpacing.xs))
PAUSE_MINUTES.forEach { minutes ->
val label = pluralStringResource(R.plurals.home_pause_minutes, minutes, minutes)
if (minutes == DEFAULT_PAUSE_MINUTES)
{
Button(onClick = { onPick(minutes) }, modifier = Modifier.fillMaxWidth().heightIn(min = 52.dp)) { Text(label) }
}
else
{
FilledTonalButton(onClick = { onPick(minutes) }, modifier = Modifier.fillMaxWidth().heightIn(min = 52.dp)) { Text(label) }
}
}
}
}
}
internal const val DEFAULT_PAUSE_MINUTES = 15
private val PAUSE_MINUTES = listOf(5, 15, 30, 60)
/**
* The reason the tunnel is not working, in plain words, with the one action
* most likely to fix it (and a second one where it helps).
*/
@Composable
internal fun ProblemCard(
problem: HomeProblem,
retryIn: Int,
hasOtherProfiles: Boolean,
onRetry: () -> Unit,
onSwitch: () -> Unit,
onEditProfile: () -> Unit,
onShowLog: () -> Unit,
onNetworkSettings: () -> Unit,
modifier: Modifier = Modifier,
)
{
val warn = problem == HomeProblem.NO_NETWORK || problem == HomeProblem.UNREACHABLE || problem == HomeProblem.LOOKUP
val status = LocalShxStatusColors.current
val container = if (warn) status.warningContainer else MaterialTheme.colorScheme.errorContainer
val content = if (warn) status.onWarningContainer else MaterialTheme.colorScheme.onErrorContainer
val icon = when (problem)
{
HomeProblem.NO_NETWORK -> Icons.Rounded.WifiOff
HomeProblem.UNREACHABLE -> Icons.Rounded.CloudOff
HomeProblem.LOOKUP -> Icons.Rounded.SearchOff
HomeProblem.AUTH -> Icons.Rounded.Key
HomeProblem.SERVER_CERT -> Icons.Rounded.VerifiedUser
HomeProblem.GENERIC -> Icons.Rounded.WarningAmber
}
val text = when (problem)
{
HomeProblem.NO_NETWORK -> stringResource(R.string.home_problem_offline_text)
HomeProblem.UNREACHABLE -> stringResource(R.string.home_problem_unreachable_text)
HomeProblem.LOOKUP -> stringResource(R.string.home_problem_lookup_text)
HomeProblem.AUTH -> stringResource(R.string.home_problem_auth_text)
HomeProblem.SERVER_CERT -> stringResource(R.string.home_problem_cert_text)
HomeProblem.GENERIC -> stringResource(R.string.home_problem_generic_text)
}
/* primary + secondary action per problem */
val actions: List<Pair<String, () -> Unit>> = when (problem)
{
HomeProblem.NO_NETWORK -> listOf(
stringResource(R.string.home_problem_network_settings) to onNetworkSettings,
)
HomeProblem.UNREACHABLE, HomeProblem.LOOKUP -> buildList {
if (hasOtherProfiles)
{
add(stringResource(R.string.home_problem_other_server) to onSwitch)
}
add(stringResource(R.string.retry) to onRetry)
if (!hasOtherProfiles)
{
add(stringResource(R.string.show_log) to onShowLog)
}
}
HomeProblem.AUTH -> listOf(
stringResource(R.string.home_problem_edit_profile) to onEditProfile,
stringResource(R.string.show_log) to onShowLog,
)
HomeProblem.SERVER_CERT, HomeProblem.GENERIC -> listOf(
stringResource(R.string.retry) to onRetry,
stringResource(R.string.show_log) to onShowLog,
)
}
Surface(
shape = MaterialTheme.shapes.extraLarge,
color = container,
contentColor = content,
modifier = modifier.fillMaxWidth().padding(horizontal = ShxSpacing.lg),
)
{
Column(
verticalArrangement = Arrangement.spacedBy(ShxSpacing.sm),
modifier = Modifier.padding(ShxSpacing.lg),
)
{
Row(verticalAlignment = Alignment.CenterVertically)
{
Icon(icon, contentDescription = null)
Spacer(Modifier.size(ShxSpacing.md))
Text(
text = stringResource(problem.cardTitleRes()),
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.ExtraBold,
)
}
Text(text = text, style = MaterialTheme.typography.bodyMedium)
if (retryIn > 0 && problem != HomeProblem.AUTH)
{
Text(
text = stringResource(R.string.home_problem_retry_in, retryIn),
style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.Bold,
)
}
Row(horizontalArrangement = Arrangement.spacedBy(ShxSpacing.sm), modifier = Modifier.padding(top = ShxSpacing.xs))
{
actions.forEachIndexed { index, (label, action) ->
if (index == 0)
{
Button(onClick = action, modifier = Modifier.weight(1f)) { Text(label, maxLines = 1, overflow = TextOverflow.Ellipsis) }
}
else
{
FilledTonalButton(onClick = action) { Text(label, maxLines = 1) }
}
}
}
}
}
}
/** Card headline: what is going on, as opposed to the hero's short state. */
private fun HomeProblem.cardTitleRes(): Int = when (this)
{
HomeProblem.NO_NETWORK -> R.string.home_problem_offline_card
HomeProblem.UNREACHABLE -> R.string.home_problem_unreachable_card
HomeProblem.LOOKUP -> R.string.home_problem_lookup_card
HomeProblem.AUTH -> R.string.home_problem_auth_card
HomeProblem.SERVER_CERT -> R.string.home_problem_cert_card
HomeProblem.GENERIC -> R.string.home_problem_generic_card
}
@@ -1,537 +0,0 @@
/*
* Copyright (C) 2026 SHX VPN
*
* This program is free software; you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the
* Free Software Foundation; either version 2 of the License, or (at your
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
*
* This program is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
* for more details.
*/
package org.strongswan.android.ui.compose.routing
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.selection.toggleable
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowRight
import androidx.compose.material.icons.rounded.Add
import androidx.compose.material.icons.rounded.Delete
import androidx.compose.material.icons.rounded.ExpandMore
import androidx.compose.material.icons.rounded.Lan
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.heading
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import org.strongswan.android.R
import org.strongswan.android.logic.routing.ForceTunnelPrefix
import org.strongswan.android.ui.compose.DeadTargetUi
import org.strongswan.android.ui.compose.SecondaryScaffold
import org.strongswan.android.ui.compose.components.ShxCard
import org.strongswan.android.ui.compose.components.ShxIconBadge
import org.strongswan.android.ui.compose.components.ShxSectionDivider
import org.strongswan.android.ui.compose.formatDuration
import org.strongswan.android.ui.compose.theme.ShxSpacing
/** Sub pages of the "Advanced" section. */
enum class AdvancedPage { FORCE, PATTERNS, DEAD }
/** One entry of the advanced list: title, hint, count badge and a chevron. */
@Composable
private fun AdvancedRow(title: String, subtitle: String, count: Int, warn: Boolean, onClick: () -> Unit)
{
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.heightIn(min = 56.dp)
.padding(horizontal = ShxSpacing.lg, vertical = ShxSpacing.md),
)
{
Column(Modifier.weight(1f))
{
Text(title, style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.Medium)
Text(
subtitle,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Spacer(Modifier.width(ShxSpacing.md))
Surface(
shape = MaterialTheme.shapes.small,
color = if (warn) MaterialTheme.colorScheme.errorContainer else MaterialTheme.colorScheme.surfaceContainerHighest,
contentColor = if (warn) MaterialTheme.colorScheme.onErrorContainer else MaterialTheme.colorScheme.onSurfaceVariant,
)
{
Text(
text = count.toString(),
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp),
)
}
Icon(
Icons.AutoMirrored.Rounded.KeyboardArrowRight,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
/** Collapsible "Advanced": always-via-VPN addresses, package templates and unreachable hosts. */
@Composable
internal fun AdvancedSection(
forceCount: Int,
patternCount: Int,
deadCount: Int,
onOpen: (AdvancedPage) -> Unit,
modifier: Modifier = Modifier,
)
{
var open by rememberSaveable { mutableStateOf(false) }
val rotation by animateFloatAsState(if (open) 180f else 0f, label = "advancedChevron")
ShxCard(modifier = modifier.fillMaxWidth())
{
Column {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.toggleable(value = open, role = Role.Button, onValueChange = { open = it })
.heightIn(min = 64.dp)
.padding(ShxSpacing.lg),
)
{
Column(Modifier.weight(1f))
{
Text(
text = stringResource(R.string.routing_advanced),
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Medium,
modifier = Modifier.semantics { heading() },
)
Text(
text = stringResource(R.string.routing_advanced_summary),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Icon(
Icons.Rounded.ExpandMore,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.rotate(rotation),
)
}
AnimatedVisibility(visible = open)
{
Column(Modifier.padding(bottom = ShxSpacing.sm))
{
AdvancedRow(
title = stringResource(R.string.routing_force_section),
subtitle = stringResource(R.string.routing_adv_force_sub),
count = forceCount,
warn = false,
onClick = { onOpen(AdvancedPage.FORCE) },
)
AdvancedRow(
title = stringResource(R.string.routing_patterns_title),
subtitle = stringResource(R.string.routing_adv_patterns_sub),
count = patternCount,
warn = false,
onClick = { onOpen(AdvancedPage.PATTERNS) },
)
AdvancedRow(
title = stringResource(R.string.dead_targets_title),
subtitle = stringResource(R.string.routing_adv_dead_sub),
count = deadCount,
warn = deadCount > 0,
onClick = { onOpen(AdvancedPage.DEAD) },
)
}
}
}
}
}
/* ------------------------------ sub pages ------------------------------ */
@Composable
private fun SubPage(title: String, onBack: () -> Unit, content: @Composable () -> Unit)
{
SecondaryScaffold(title = title, onBack = onBack) { padding ->
Column(
Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(padding)
.padding(horizontal = ShxSpacing.lg)
.padding(bottom = ShxSpacing.xl),
)
{
content()
}
}
}
@Composable
private fun Hint(text: String)
{
Text(
text = text,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = ShxSpacing.xs, vertical = ShxSpacing.md),
)
}
/** "Always via VPN": addresses that never go direct, even inside the RU ranges. */
@Composable
internal fun ForcePrefixPage(
prefixes: List<ForceTunnelPrefix>,
onToggle: (String, Boolean) -> Unit,
onEdit: (String) -> Unit,
onDelete: (String) -> Unit,
onAdd: () -> Unit,
onBack: () -> Unit,
)
{
SubPage(stringResource(R.string.routing_force_section), onBack)
{
Hint(stringResource(R.string.routing_force_summary))
ShxCard(Modifier.fillMaxWidth())
{
Column {
prefixes.forEach { entry ->
ForcePrefixRow(
entry = entry,
onToggle = { onToggle(entry.id, it) },
onEdit = { onEdit(entry.id) },
onDelete = { onDelete(entry.id) },
)
ShxSectionDivider()
}
AddRow(stringResource(R.string.routing_force_add), onAdd)
}
}
}
}
/** "Package templates": patterns like ru.* or *yandex* that select apps by package name. */
@Composable
internal fun PatternsPage(
patterns: List<String>,
onDelete: (String) -> Unit,
onAdd: () -> Unit,
onBack: () -> Unit,
)
{
SubPage(stringResource(R.string.routing_patterns_title), onBack)
{
Hint(stringResource(R.string.routing_patterns_hint))
ShxCard(Modifier.fillMaxWidth())
{
Column {
if (patterns.isEmpty())
{
Text(
text = stringResource(R.string.routing_patterns_empty),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(ShxSpacing.lg),
)
ShxSectionDivider()
}
patterns.forEach { pattern ->
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 56.dp)
.padding(start = ShxSpacing.lg, end = ShxSpacing.sm),
)
{
Text(
text = pattern,
style = MaterialTheme.typography.bodyLarge,
fontFamily = FontFamily.Monospace,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
IconButton(onClick = { onDelete(pattern) })
{
Icon(
Icons.Rounded.Delete,
contentDescription = stringResource(R.string.routing_patterns_delete),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
ShxSectionDivider(indent = 0.dp)
}
AddRow(stringResource(R.string.routing_patterns_add), onAdd)
}
}
}
}
/** "Unreachable hosts": direct destinations the engine keeps refusing. */
@Composable
internal fun DeadHostsPage(targets: List<DeadTargetUi>, onBack: () -> Unit)
{
SubPage(stringResource(R.string.dead_targets_title), onBack)
{
Hint(stringResource(R.string.dead_targets_summary))
if (targets.isNotEmpty())
{
ShxCard(Modifier.fillMaxWidth())
{
Column {
targets.forEachIndexed { index, target ->
if (index > 0)
{
ShxSectionDivider(indent = 0.dp)
}
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(ShxSpacing.md),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = ShxSpacing.lg, vertical = 10.dp),
)
{
Box(
modifier = Modifier
.size(8.dp)
.background(MaterialTheme.colorScheme.error, CircleShape),
)
Column(Modifier.weight(1f))
{
Text(
text = target.dst,
style = MaterialTheme.typography.bodyMedium,
fontFamily = FontFamily.Monospace,
)
Text(
text = listOfNotNull(
target.owner,
stringResource(R.string.dead_targets_fails_fmt, target.fails),
stringResource(R.string.dead_targets_cooldown_fmt, formatDuration(target.leftMs / 1000)),
).joinToString(" · "),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
}
}
}
}
@Composable
private fun AddRow(label: String, onClick: () -> Unit)
{
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.heightIn(min = 56.dp)
.padding(horizontal = ShxSpacing.lg, vertical = ShxSpacing.md),
)
{
Icon(
Icons.Rounded.Add,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(22.dp),
)
Spacer(Modifier.width(ShxSpacing.lg))
Text(
label,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.Medium,
)
}
}
/** One force-tunnel prefix: tap edits, switch toggles, trash deletes. */
@Composable
internal fun ForcePrefixRow(
entry: ForceTunnelPrefix,
onToggle: (Boolean) -> Unit,
onEdit: () -> Unit,
onDelete: () -> Unit,
)
{
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onEdit)
.padding(horizontal = ShxSpacing.lg, vertical = ShxSpacing.sm),
)
{
ShxIconBadge(
Icons.Rounded.Lan,
tint = if (entry.enabled) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.width(ShxSpacing.lg))
Text(
entry.prefix,
style = MaterialTheme.typography.bodyLarge,
color = if (entry.enabled) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
IconButton(onClick = onDelete)
{
Icon(
Icons.Rounded.Delete,
contentDescription = stringResource(R.string.routing_force_delete),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Switch(checked = entry.enabled, onCheckedChange = onToggle)
}
}
/** Add/edit dialog for a force-tunnel prefix; shows an error until valid. */
@Composable
internal fun ForcePrefixDialog(initial: String, onConfirm: (String) -> Boolean, onDismiss: () -> Unit)
{
TextInputDialog(
title = stringResource(R.string.routing_force_dialog_title),
hint = stringResource(R.string.routing_force_hint),
errorText = stringResource(R.string.routing_force_invalid),
confirmLabel = stringResource(R.string.routing_force_save),
initial = initial,
onConfirm = onConfirm,
onDismiss = onDismiss,
)
}
/** Add dialog for a package template. */
@Composable
internal fun PatternDialog(onConfirm: (String) -> Boolean, onDismiss: () -> Unit)
{
TextInputDialog(
title = stringResource(R.string.routing_patterns_dialog_title),
hint = stringResource(R.string.routing_patterns_input_hint),
errorText = stringResource(R.string.routing_patterns_invalid),
confirmLabel = stringResource(R.string.routing_force_save),
initial = "",
onConfirm = onConfirm,
onDismiss = onDismiss,
)
}
@Composable
private fun TextInputDialog(
title: String,
hint: String,
errorText: String,
confirmLabel: String,
initial: String,
onConfirm: (String) -> Boolean,
onDismiss: () -> Unit,
)
{
var text by remember { mutableStateOf(initial) }
var error by remember { mutableStateOf(false) }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(title) },
text = {
OutlinedTextField(
value = text,
onValueChange = {
text = it
error = false
},
singleLine = true,
isError = error,
label = { Text(hint) },
supportingText = if (error)
{
{ Text(errorText) }
}
else
{
null
},
)
},
confirmButton = {
TextButton(onClick = {
if (onConfirm(text.trim()))
{
onDismiss()
}
else
{
error = true
}
})
{
Text(confirmLabel)
}
},
dismissButton = {
TextButton(onClick = onDismiss)
{
Text(stringResource(android.R.string.cancel))
}
},
)
}
@@ -0,0 +1,123 @@
/*
* Copyright (C) 2026 SHX VPN
*
* This program is free software; you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the
* Free Software Foundation; either version 2 of the License, or (at your
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
*
* This program is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
* for more details.
*/
package org.strongswan.android.ui.compose.routing
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import org.strongswan.android.R
import org.strongswan.android.ui.compose.DeadTargetUi
import org.strongswan.android.ui.compose.design.ShxIntro
import org.strongswan.android.ui.compose.design.ShxPageHeader
import org.strongswan.android.ui.compose.design.ShxPanel
import org.strongswan.android.ui.compose.formatDuration
import org.strongswan.android.ui.compose.theme.LocalShxColors
import org.strongswan.android.ui.compose.theme.ShxType
/**
* Direct destinations the engine keeps refusing because they do not answer,
* with the app that keeps trying and a one-tap "through the VPN".
*/
@Composable
internal fun DeadHostsPage(targets: List<DeadTargetUi>, onForceTunnel: (String) -> Unit, onBack: () -> Unit)
{
val context = LocalContext.current
val c = LocalShxColors.current
Column(Modifier.fillMaxSize().background(c.background).statusBarsPadding().verticalScroll(rememberScrollState()))
{
ShxPageHeader(stringResource(R.string.dead_targets_title), onBack)
Column(Modifier.padding(start = 16.dp, end = 16.dp, bottom = 24.dp), verticalArrangement = Arrangement.spacedBy(12.dp))
{
ShxIntro(stringResource(R.string.dead_targets_summary))
if (targets.isNotEmpty())
{
ShxPanel()
{
targets.forEachIndexed { index, target ->
val owner = remember(target.owner) {
target.owner?.let { pkg ->
runCatching {
val pm = context.packageManager
pm.getApplicationInfo(pkg, 0).loadLabel(pm).toString()
}.getOrDefault(pkg)
}
}
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
modifier = Modifier.fillMaxWidth().heightIn(min = 60.dp).padding(start = 16.dp, end = 12.dp, top = 8.dp, bottom = 8.dp),
)
{
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp))
{
Text(target.dst, style = ShxType.mono.copy(fontSize = 14.sp, fontWeight = FontWeight.Bold), color = c.text, maxLines = 1, overflow = TextOverflow.Ellipsis)
Text(
listOfNotNull(
owner,
stringResource(R.string.dead_targets_fails_fmt, target.fails),
if (target.viaTunnel) stringResource(R.string.dead_targets_via_tunnel)
else stringResource(R.string.dead_targets_cooldown_fmt, formatDuration(target.leftMs / 1000)),
).joinToString(" · "),
style = ShxType.meta.copy(fontWeight = FontWeight.Normal),
color = c.textSub,
)
}
if (!target.viaTunnel)
{
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.height(36.dp).clip(RoundedCornerShape(12.dp)).background(c.warningStrong)
.clickable(role = Role.Button) { onForceTunnel(target.dst) }.padding(horizontal = 12.dp),
)
{
Text(stringResource(R.string.dead_targets_force), style = ShxType.chip, color = c.onWarningStrong)
}
}
}
if (index < targets.lastIndex)
{
Box(Modifier.fillMaxWidth().height(1.dp).background(c.divider))
}
}
}
}
}
}
}
@@ -0,0 +1,392 @@
/*
* Copyright (C) 2026 SHX VPN
*
* This program is free software; you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the
* Free Software Foundation; either version 2 of the License, or (at your
* option) any later version.
*/
package org.strongswan.android.ui.compose.routing
import android.content.ActivityNotFoundException
import android.content.Intent
import android.net.Uri
import android.provider.Settings
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.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
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.saveable.rememberSaveable
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.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
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.compose.LocalLifecycleOwner
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.repeatOnLifecycle
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
import org.strongswan.android.R
import org.strongswan.android.logic.routing.AppTraffic
import org.strongswan.android.logic.routing.RoutingManager
import org.strongswan.android.ui.compose.DeadTargetUi
import org.strongswan.android.ui.compose.design.ShxButton
import org.strongswan.android.ui.compose.design.ShxChip
import org.strongswan.android.ui.compose.design.ShxChipTone
import org.strongswan.android.ui.compose.design.ShxIcons
import org.strongswan.android.ui.compose.design.ShxNote
import org.strongswan.android.ui.compose.design.ShxPanel
import org.strongswan.android.ui.compose.design.ShxSectionHeader
import org.strongswan.android.ui.compose.design.ShxSwitch
import org.strongswan.android.ui.compose.formatBytes
import org.strongswan.android.ui.compose.theme.LocalShxColors
import org.strongswan.android.ui.compose.theme.ShxType
/** One app in «Recent apps». */
data class RecentApp(
val packageName: String,
val label: String,
val bytes: Long,
val direct: Boolean,
/** failed direct connections the engine cut off, 0 if none */
val fails: Long,
)
private enum class RecentFilter { ALL, VPN, DIRECT, FAILING }
/** Window of «Recent apps». */
private const val RECENT_WINDOW_MS = 60L * 60 * 1000
private const val RECENT_REFRESH_MS = 10_000L
/** Builds the list: apps with traffic or failures, failing first, then busiest. */
fun buildRecentApps(
traffic: Map<String, Long>,
excluded: Set<String>,
dead: List<DeadTargetUi>,
label: (String) -> String?,
): List<RecentApp>
{
val fails = HashMap<String, Long>()
dead.filter { !it.viaTunnel }.forEach { target -> target.owner?.let { fails[it] = (fails[it] ?: 0L) + target.fails } }
return (traffic.keys + fails.keys).mapNotNull { pkg ->
val name = label(pkg) ?: return@mapNotNull null
RecentApp(pkg, name, traffic[pkg] ?: 0L, pkg in excluded, fails[pkg] ?: 0L)
}.sortedWith(compareByDescending<RecentApp> { it.fails > 0 }.thenByDescending { it.bytes })
}
/** «Recent apps» (F_Recent / F_RecentEmpty). */
@Composable
internal fun RecentAppsPage(deadTargets: List<DeadTargetUi>, onForceTunnel: (String) -> Unit, onOpenDead: () -> Unit, onBack: () -> Unit)
{
val context = LocalContext.current
val c = LocalShxColors.current
val manager = remember { RoutingManager.getInstance(context) }
val state by manager.state.collectAsStateWithLifecycle()
val lifecycle = LocalLifecycleOwner.current.lifecycle
/* re-checked whenever the screen comes back (the user may have granted access in Settings) */
var resumes by remember { mutableIntStateOf(0) }
LaunchedEffect(lifecycle) {
lifecycle.repeatOnLifecycle(Lifecycle.State.RESUMED) { resumes++ }
}
val access = remember(resumes) { AppTraffic.hasAccess(context) }
var dismissed by rememberSaveable { mutableStateOf(false) }
val traffic by produceState(emptyMap<String, Long>(), access, lifecycle) {
if (!access)
{
return@produceState
}
lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
while (true)
{
value = withContext(Dispatchers.IO) { AppTraffic.since(context, System.currentTimeMillis() - RECENT_WINDOW_MS) }
delay(RECENT_REFRESH_MS)
}
}
}
val excluded by produceState(emptySet<String>(), state.config) {
value = withContext(Dispatchers.IO) { manager.computeExcludedPackages() }
}
val apps = remember(traffic, excluded, deadTargets) {
val pm = context.packageManager
buildRecentApps(traffic, excluded, deadTargets) { pkg ->
runCatching { pm.getApplicationInfo(pkg, 0).loadLabel(pm).toString() }.getOrNull()
}
}
var filter by rememberSaveable { mutableStateOf(RecentFilter.ALL) }
Column(Modifier.fillMaxSize().background(c.background).statusBarsPadding().verticalScroll(rememberScrollState()))
{
RecentHeader(live = access, onBack = onBack)
if (!access && !dismissed)
{
RecentAccessNeeded(onAllow = { openUsageAccess(context) }, onLater = { dismissed = true })
return@Column
}
val failing = apps.filter { it.fails > 0 }
val shown = apps.filter {
when (filter)
{
RecentFilter.ALL -> true
RecentFilter.VPN -> !it.direct
RecentFilter.DIRECT -> it.direct
RecentFilter.FAILING -> it.fails > 0
}
}
Column(Modifier.padding(start = 16.dp, end = 16.dp, top = 4.dp, bottom = 24.dp), verticalArrangement = Arrangement.spacedBy(12.dp))
{
if (!access)
{
ShxNote(stringResource(R.string.recent_subtitle_no_access))
}
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp))
{
ShxChip(stringResource(R.string.recent_filter_all, apps.size), filter == RecentFilter.ALL, { filter = RecentFilter.ALL })
ShxChip(stringResource(R.string.recent_filter_vpn), filter == RecentFilter.VPN, { filter = RecentFilter.VPN })
ShxChip(stringResource(R.string.recent_filter_direct), filter == RecentFilter.DIRECT, { filter = RecentFilter.DIRECT })
if (failing.isNotEmpty())
{
ShxChip(stringResource(R.string.recent_filter_failing, failing.size), filter == RecentFilter.FAILING, { filter = RecentFilter.FAILING }, tone = ShxChipTone.WARNING)
}
}
failing.firstOrNull()?.let { app ->
FailingCard(app, onForceTunnel = {
deadTargets.filter { it.owner == app.packageName && !it.viaTunnel }.forEach { onForceTunnel(it.dst) }
}, onDetails = onOpenDead)
}
if (shown.isEmpty())
{
Text(
stringResource(R.string.recent_empty),
style = ShxType.body,
color = c.textSub,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth().padding(24.dp),
)
}
listOf(false, true).forEach { direct ->
val group = shown.filter { it.direct == direct }
if (group.isNotEmpty())
{
Column(verticalArrangement = Arrangement.spacedBy(6.dp))
{
ShxSectionHeader(stringResource(if (direct) R.string.recent_group_direct else R.string.recent_group_vpn))
ShxPanel()
{
group.forEachIndexed { i, app ->
RecentRow(app, onToggle = { manager.setAppDirect(app.packageName, it) })
if (i < group.lastIndex)
{
Box(Modifier.fillMaxWidth().height(1.dp).background(c.divider))
}
}
}
}
}
}
Text(stringResource(R.string.recent_note), style = ShxType.meta.copy(fontWeight = FontWeight.Normal, lineHeight = 17.sp), color = c.textSub, modifier = Modifier.padding(horizontal = 6.dp))
}
}
}
/** Header with the live dot: 22/800 title and "last hour · updates live". */
@Composable
private fun RecentHeader(live: Boolean, onBack: () -> Unit)
{
val c = LocalShxColors.current
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
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))
}
Column(Modifier.weight(1f))
{
Text(stringResource(R.string.recent_title), style = ShxType.pageTitle, color = c.text)
if (live)
{
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp))
{
Box(Modifier.size(7.dp).clip(CircleShape).background(c.success))
Text(stringResource(R.string.recent_subtitle), style = ShxType.meta, color = c.textSub)
}
}
}
}
}
/** Row of the mockup: 40dp icon, name 15/700, route pill + traffic, switch "direct"; 70dp. */
@Composable
private fun RecentRow(app: RecentApp, onToggle: (Boolean) -> Unit)
{
val c = LocalShxColors.current
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.fillMaxWidth().height(70.dp).padding(horizontal = 14.dp),
)
{
Box(Modifier.size(40.dp).clip(RoundedCornerShape(12.dp))) { AppIcon(app.packageName, 40.dp) }
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp))
{
Text(app.label, style = ShxType.cardTitle.copy(fontWeight = FontWeight.Bold), color = c.text, maxLines = 1, overflow = TextOverflow.Ellipsis)
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp))
{
Text(
stringResource(if (app.direct) R.string.recent_route_direct else R.string.recent_route_vpn),
style = ShxType.meta.copy(fontWeight = FontWeight.ExtraBold),
color = if (app.direct) c.onSuccess else c.onAccentContainer,
modifier = Modifier.clip(RoundedCornerShape(999.dp)).background(if (app.direct) c.successContainer else c.accentContainer).padding(horizontal = 7.dp, vertical = 1.dp),
)
Text(
buildString {
append(formatBytes(app.bytes))
if (app.fails > 0)
{
append(" · ")
append(stringResource(R.string.recent_fails, app.fails))
}
},
style = ShxType.meta.copy(fontWeight = FontWeight.Normal),
color = if (app.fails > 0) c.warning else c.textSub,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
ShxSwitch(checked = app.direct, onCheckedChange = onToggle)
}
}
/** Amber card of the first failing app. */
@Composable
private fun FailingCard(app: RecentApp, onForceTunnel: () -> Unit, onDetails: () -> Unit)
{
val c = LocalShxColors.current
val shape = RoundedCornerShape(24.dp)
Column(
verticalArrangement = Arrangement.spacedBy(10.dp),
modifier = Modifier.fillMaxWidth().clip(shape).background(c.warningContainer).border(1.dp, c.warningBorder, shape).padding(14.dp),
)
{
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp))
{
Box(Modifier.size(40.dp).clip(RoundedCornerShape(12.dp))) { AppIcon(app.packageName, 40.dp) }
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp))
{
Text(stringResource(R.string.recent_failing_title, app.label), style = ShxType.cardTitle, color = c.onWarningStrong)
Text(stringResource(R.string.recent_failing_text, app.fails), style = ShxType.meta.copy(fontWeight = FontWeight.Normal, lineHeight = 16.sp), color = c.onWarningContainer)
}
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp))
{
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.weight(1f).height(42.dp).clip(RoundedCornerShape(12.dp)).background(c.warning).clickable(role = Role.Button, onClick = onForceTunnel),
)
{
Text(stringResource(R.string.recent_failing_action), style = ShxType.chip.copy(fontWeight = FontWeight.ExtraBold), color = androidx.compose.ui.graphics.Color.White)
}
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.height(42.dp).clip(RoundedCornerShape(12.dp)).border(1.dp, c.warningBorder, RoundedCornerShape(12.dp))
.clickable(role = Role.Button, onClick = onDetails).padding(horizontal = 14.dp),
)
{
Text(stringResource(R.string.recent_failing_details), style = ShxType.chip, color = c.onWarningStrong)
}
}
}
}
/** F_RecentEmpty: ghost icon grid, the explanation, privacy note, allow button. */
@Composable
private fun RecentAccessNeeded(onAllow: () -> Unit, onLater: () -> Unit)
{
val c = LocalShxColors.current
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp),
modifier = Modifier.fillMaxWidth().padding(start = 24.dp, end = 24.dp, bottom = 28.dp),
)
{
val ghosts = listOf(c.accentContainer, c.tile, c.successContainer, c.tile, c.accentContainer, c.tile)
Column(verticalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.padding(top = 48.dp))
{
ghosts.chunked(3).forEach { row ->
Row(horizontalArrangement = Arrangement.spacedBy(10.dp))
{
row.forEach { Box(Modifier.size(56.dp).clip(RoundedCornerShape(18.dp)).background(it)) }
}
}
}
Text(stringResource(R.string.recent_access_title), style = ShxType.pageTitle.copy(letterSpacing = (-0.02).sp), color = c.text, textAlign = TextAlign.Center, modifier = Modifier.padding(top = 16.dp))
Text(stringResource(R.string.recent_access_text), style = ShxType.note.copy(fontSize = 15.sp, lineHeight = 22.sp), color = c.textSoft, textAlign = TextAlign.Center)
ShxNote(stringResource(R.string.recent_access_privacy))
Spacer(Modifier.height(24.dp))
ShxButton(stringResource(R.string.recent_access_allow), onAllow, modifier = Modifier.fillMaxWidth())
Text(
stringResource(R.string.recent_access_later),
style = ShxType.body.copy(fontWeight = FontWeight.Bold),
color = c.accentText,
modifier = Modifier.clip(RoundedCornerShape(12.dp)).clickable(role = Role.Button, onClick = onLater).padding(8.dp),
)
}
}
private fun openUsageAccess(context: android.content.Context)
{
val withPackage = Intent(Settings.ACTION_USAGE_ACCESS_SETTINGS, Uri.fromParts("package", context.packageName, null))
try
{
context.startActivity(withPackage)
}
catch (_: ActivityNotFoundException)
{
try
{
context.startActivity(Intent(Settings.ACTION_USAGE_ACCESS_SETTINGS))
}
catch (_: ActivityNotFoundException)
{
}
}
}
@@ -42,7 +42,7 @@ import org.strongswan.android.ui.compose.theme.ShxSpacing
/** Plain-words summary of what currently bypasses the VPN. */
@Composable
internal fun routingSummary(enabled: Boolean, ruEnabled: Boolean, appCount: Int): String
internal fun routingSummary(enabled: Boolean, ruEnabled: Boolean, appCount: Int, ruleCount: Int = 0): String
{
if (!enabled)
{
@@ -57,6 +57,10 @@ internal fun routingSummary(enabled: Boolean, ruEnabled: Boolean, appCount: Int)
{
parts.add(pluralStringResource(R.plurals.routing_apps_n, appCount, appCount))
}
if (ruleCount > 0)
{
parts.add(pluralStringResource(R.plurals.routing_rules_n, ruleCount, ruleCount))
}
return if (parts.isEmpty())
{
stringResource(R.string.routing_hero_nothing)
@@ -0,0 +1,309 @@
/*
* Copyright (C) 2026 SHX VPN
*
* This program is free software; you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the
* Free Software Foundation; either version 2 of the License, or (at your
* option) any later version.
*/
package org.strongswan.android.ui.compose.routing
import android.text.format.DateUtils
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.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import androidx.compose.material3.LoadingIndicator
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import org.strongswan.android.R
import org.strongswan.android.logic.routing.RuIpListState
import org.strongswan.android.logic.routing.RuIpListStatus
import org.strongswan.android.ui.compose.design.ShxBadge
import org.strongswan.android.ui.compose.design.ShxChoiceCard
import org.strongswan.android.ui.compose.design.ShxIcons
import org.strongswan.android.ui.compose.design.ShxIconWell
import org.strongswan.android.ui.compose.design.ShxPanel
import org.strongswan.android.ui.compose.design.ShxSwitch
import org.strongswan.android.ui.compose.design.ShxTone
import org.strongswan.android.ui.compose.theme.LocalShxColors
import org.strongswan.android.ui.compose.theme.ShxType
import org.strongswan.android.utils.Constants
import java.text.NumberFormat
/* The blocks of the Routing tab, 1:1 with the F_Routing board. */
/** Purple hero card: master switch, what bypasses the VPN, and the live split bar. */
@Composable
internal fun SplitTunnelHero(enabled: Boolean, summary: String, split: Pair<Long, Long>?, onToggle: (Boolean) -> Unit)
{
val c = LocalShxColors.current
Column(
verticalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(26.dp)).background(if (enabled) c.accentContainer else c.tile).padding(16.dp),
)
{
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(14.dp))
{
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp))
{
Text(stringResource(R.string.routing_hero_title), style = ShxType.cardHeading, color = if (enabled) c.onAccentContainerStrong else c.text)
Text(summary, style = ShxType.sub.copy(fontWeight = FontWeight.Normal), color = if (enabled) c.onAccentContainer else c.textSub)
}
ShxSwitch(checked = enabled, onCheckedChange = onToggle)
}
if (enabled && split != null)
{
val total = (split.first + split.second).coerceAtLeast(1)
val vpn = ((split.first * 100) / total).toInt()
Row(Modifier.fillMaxWidth().height(8.dp).clip(RoundedCornerShape(4.dp)), horizontalArrangement = Arrangement.spacedBy(2.dp))
{
if (vpn > 0) Box(Modifier.weight(vpn.toFloat()).height(8.dp).clip(RoundedCornerShape(4.dp)).background(c.accent))
if (vpn < 100) Box(Modifier.weight((100 - vpn).toFloat()).height(8.dp).clip(RoundedCornerShape(4.dp)).background(c.successBar))
}
Row(Modifier.fillMaxWidth())
{
Text(stringResource(R.string.routing_split_vpn, vpn), style = ShxType.meta.copy(fontWeight = FontWeight.Bold), color = c.onAccentContainer, modifier = Modifier.weight(1f))
Text(stringResource(R.string.routing_split_direct, 100 - vpn), style = ShxType.meta.copy(fontWeight = FontWeight.Bold), color = c.onAccentContainer)
}
}
}
}
/** White «RU sites direct» card: base status, refresh, switch, and the two modes. */
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
internal fun RuSitesPanel(state: RuIpListState, onToggle: (Boolean) -> Unit, onModeChange: (String) -> Unit, onRefresh: () -> Unit)
{
val c = LocalShxColors.current
ShxPanel(padding = androidx.compose.foundation.layout.PaddingValues(16.dp))
{
Column(verticalArrangement = Arrangement.spacedBy(12.dp))
{
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp))
{
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp))
{
Text(stringResource(R.string.routing_ru_title), style = ShxType.rowTitle, color = c.text)
Text(
if (state.lastUpdatedMs > 0L)
stringResource(
R.string.routing_db_short,
NumberFormat.getIntegerInstance().format(state.ipv4Ranges + state.ipv6Ranges),
DateUtils.getRelativeTimeSpanString(state.lastUpdatedMs, System.currentTimeMillis(), DateUtils.DAY_IN_MILLIS).toString().lowercase(),
)
else stringResource(R.string.routing_db_never),
style = ShxType.meta.copy(fontWeight = FontWeight.Normal),
color = c.textSub,
)
}
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.size(40.dp).clip(CircleShape).background(c.tile)
.clickable(enabled = state.status != RuIpListStatus.UPDATING, role = Role.Button, onClickLabel = stringResource(R.string.routing_db_refresh), onClick = onRefresh),
)
{
if (state.status == RuIpListStatus.UPDATING)
{
LoadingIndicator(Modifier.size(22.dp))
}
else
{
Icon(ShxIcons.Refresh, contentDescription = stringResource(R.string.routing_db_refresh), tint = c.onAccentContainer, modifier = Modifier.size(16.dp))
}
}
ShxSwitch(checked = state.enabled, onCheckedChange = onToggle)
}
if (state.enabled)
{
Row(horizontalArrangement = Arrangement.spacedBy(8.dp))
{
ShxChoiceCard(
title = stringResource(R.string.routing_mode_stable),
subtitle = stringResource(R.string.routing_mode_stable_short),
selected = state.mode == Constants.RUSPLIT_MODE_SYSTEM,
onClick = { onModeChange(Constants.RUSPLIT_MODE_SYSTEM) },
modifier = Modifier.weight(1f),
)
ShxChoiceCard(
title = stringResource(R.string.routing_mode_flexible),
subtitle = stringResource(R.string.routing_mode_flexible_short),
selected = state.mode == Constants.RUSPLIT_MODE_USERSPACE,
onClick = { onModeChange(Constants.RUSPLIT_MODE_USERSPACE) },
modifier = Modifier.weight(1f),
badge = {
Text(
stringResource(R.string.routing_mode_beta),
style = ShxType.tag.copy(fontSize = 10.sp),
color = c.onWarning,
maxLines = 1,
softWrap = false,
modifier = Modifier.clip(RoundedCornerShape(6.dp)).background(c.warningStrong).padding(horizontal = 6.dp, vertical = 1.dp),
)
},
)
}
}
}
}
}
/** «Exceptions» card: direct apps (with an icon stack) and rules (with a count). */
@Composable
internal fun ExceptionsPanel(directPackages: List<String>, ruleCount: Int, onApps: () -> Unit, onRules: () -> Unit)
{
val c = LocalShxColors.current
ShxPanel()
{
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(14.dp),
modifier = Modifier.fillMaxWidth().heightIn(min = 76.dp).clickable(role = Role.Button, onClick = onApps).padding(start = 16.dp, end = 14.dp, top = 10.dp, bottom = 10.dp),
)
{
ShxIconWell(ShxIcons.Apps, c.accentContainer, c.onAccentContainer)
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp))
{
Text(stringResource(R.string.routing_apps_title), style = ShxType.rowTitle, color = c.text)
Text(
if (directPackages.isEmpty()) stringResource(R.string.routing_apps_none)
else pluralStringResource(R.plurals.routing_apps_direct_n, directPackages.size, directPackages.size),
style = ShxType.sub, color = c.textSub,
)
}
if (directPackages.isNotEmpty())
{
Row(verticalAlignment = Alignment.CenterVertically)
{
directPackages.take(2).forEachIndexed { i, pkg ->
Box(
Modifier.offset(x = (-8 * i).dp).size(26.dp).clip(CircleShape).background(c.card).border(2.dp, c.card, CircleShape),
)
{
AppIcon(pkg, 22.dp, Modifier.align(Alignment.Center))
}
}
if (directPackages.size > 2)
{
Text(
"+${directPackages.size - 2}",
style = ShxType.tag,
color = c.textSoft,
modifier = Modifier.offset(x = (-16).dp).height(26.dp).clip(RoundedCornerShape(13.dp)).background(c.tile)
.border(2.dp, c.card, RoundedCornerShape(13.dp)).padding(horizontal = 6.dp, vertical = 5.dp),
)
}
}
}
else
{
Icon(ShxIcons.Chevron, contentDescription = null, tint = c.textSub, modifier = Modifier.size(18.dp))
}
}
Box(Modifier.fillMaxWidth().height(1.dp).background(c.divider))
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(14.dp),
modifier = Modifier.fillMaxWidth().heightIn(min = 76.dp).clickable(role = Role.Button, onClick = onRules).padding(start = 16.dp, end = 14.dp, top = 10.dp, bottom = 10.dp),
)
{
ShxIconWell(ShxIcons.Lines, c.tile, c.onAccentContainer)
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp))
{
Text(stringResource(R.string.rules_title), style = ShxType.rowTitle, color = c.text)
Text(stringResource(R.string.routing_rules_sub), style = ShxType.sub, color = c.textSub)
}
if (ruleCount > 0)
{
Text(ruleCount.toString(), style = ShxType.body.copy(fontWeight = FontWeight.Bold), color = c.textSub)
}
Icon(ShxIcons.Chevron, contentDescription = null, tint = c.textSub, modifier = Modifier.size(18.dp))
}
}
}
/** One app of the «Recent apps» preview. */
data class RecentPreview(val packageName: String, val label: String, val direct: Boolean, val failing: Boolean)
/** «Recent apps» card: header with the failures badge and four app icons with route dots. */
@Composable
internal fun RecentAppsCard(apps: List<RecentPreview>, failing: Int, onClick: () -> Unit)
{
val c = LocalShxColors.current
ShxPanel(onClick = onClick, padding = androidx.compose.foundation.layout.PaddingValues(horizontal = 16.dp, vertical = 14.dp))
{
Column(verticalArrangement = Arrangement.spacedBy(12.dp))
{
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp))
{
Text(stringResource(R.string.recent_title), style = ShxType.rowTitle, color = c.text, modifier = Modifier.weight(1f))
if (failing > 0)
{
ShxBadge(pluralStringResource(R.plurals.routing_recent_failing_short, failing, failing), ShxTone.WARNING)
}
Icon(ShxIcons.Chevron, contentDescription = null, tint = c.textSub, modifier = Modifier.size(18.dp))
}
if (apps.isEmpty())
{
Text(stringResource(R.string.routing_recent_sub), style = ShxType.sub, color = c.textSub)
}
else
{
Row(horizontalArrangement = Arrangement.spacedBy(6.dp))
{
apps.take(4).forEach { app ->
Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(5.dp), modifier = Modifier.weight(1f))
{
Box(Modifier.size(44.dp))
{
Box(Modifier.size(44.dp).clip(RoundedCornerShape(14.dp)))
{
AppIcon(app.packageName, 44.dp)
}
Box(
Modifier.align(Alignment.BottomEnd).offset(x = 3.dp, y = 3.dp).size(14.dp).clip(CircleShape)
.background(c.card).padding(2.dp).clip(CircleShape)
.background(
when
{
app.failing -> c.warning
app.direct -> c.successBar
else -> c.accent
}
),
)
}
Text(app.label, style = ShxType.tag.copy(fontWeight = FontWeight.SemiBold), color = c.textSoft, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
repeat((4 - apps.size).coerceAtLeast(0)) { Box(Modifier.weight(1f)) }
}
}
}
}
}
@@ -0,0 +1,480 @@
/*
* Copyright (C) 2026 SHX VPN
*
* This program is free software; you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the
* Free Software Foundation; either version 2 of the License, or (at your
* option) any later version.
*/
package org.strongswan.android.ui.compose.routing
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.FlowRow
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.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.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
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.draw.drawBehind
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.PathEffect
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.strongswan.android.R
import org.strongswan.android.logic.CharonVpnService
import org.strongswan.android.logic.routing.RoutingConfig
import org.strongswan.android.logic.routing.RoutingManager
import org.strongswan.android.ui.compose.design.ShxButton
import org.strongswan.android.ui.compose.design.ShxButtonStyle
import org.strongswan.android.ui.compose.design.ShxChip
import org.strongswan.android.ui.compose.design.ShxChoiceCard
import org.strongswan.android.ui.compose.design.ShxChoiceTone
import org.strongswan.android.ui.compose.design.ShxField
import org.strongswan.android.ui.compose.design.ShxIcons
import org.strongswan.android.ui.compose.design.ShxIntro
import org.strongswan.android.ui.compose.design.ShxNote
import org.strongswan.android.ui.compose.design.ShxPageHeader
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.design.ShxTabs
import org.strongswan.android.ui.compose.design.ShxTone
import org.strongswan.android.ui.compose.theme.LocalShxColors
import org.strongswan.android.ui.compose.theme.ShxType
/** The three kinds of user rules. */
enum class RuleKind { SITE, ADDRESS, PACKAGE }
/** One row of the Rules list, built from the routing config. */
data class RuleRow(
val id: String,
val kind: RuleKind,
val value: String,
val direct: Boolean,
val enabled: Boolean,
/** resolved addresses of a site rule, -1 when not resolved yet */
val addresses: Int = -1,
)
/** Flattens the user rules into rows: "through the VPN" first, then "direct". */
fun buildRuleRows(config: RoutingConfig, resolved: Map<String, List<String>>): List<RuleRow> = buildList {
config.siteRules.forEach { add(RuleRow(it.id, RuleKind.SITE, it.domain, it.direct, it.enabled, resolved[it.domain]?.size ?: -1)) }
config.forceTunnelPrefixes.forEach { add(RuleRow(it.id, RuleKind.ADDRESS, it.prefix, false, it.enabled)) }
config.directPrefixes.forEach { add(RuleRow(it.id, RuleKind.ADDRESS, it.prefix, true, it.enabled)) }
config.packagePatterns.sorted().forEach { add(RuleRow(it, RuleKind.PACKAGE, it, true, it !in config.disabledPatterns)) }
}.sortedWith(compareBy<RuleRow> { it.direct }.thenBy { it.kind.ordinal }.thenBy { it.value })
/** «Rules» (F_Rules / F_RulesEmpty): sites, addresses and package templates that override the RU base. */
@Composable
internal fun RulesPage(onBack: () -> Unit)
{
val context = LocalContext.current
val c = LocalShxColors.current
val manager = remember { RoutingManager.getInstance(context) }
val state by manager.state.collectAsStateWithLifecycle()
val resolved by manager.resolvedSites.collectAsStateWithLifecycle()
val rows = remember(state.config, resolved) { buildRuleRows(state.config, resolved) }
var filter by rememberSaveable { mutableStateOf<RuleKind?>(null) }
var adding by remember { mutableStateOf(false) }
var actionsFor by remember { mutableStateOf<RuleRow?>(null) }
val rulesActive = remember { CharonVpnService.isUserspaceRoutingEnabled(context) }
Box(Modifier.fillMaxSize().background(c.background))
{
Column(Modifier.fillMaxSize().statusBarsPadding().verticalScroll(rememberScrollState()))
{
ShxPageHeader(stringResource(R.string.rules_title), onBack)
if (rows.isEmpty())
{
RulesEmpty(onIdea = { kind, value, direct -> addRule(manager, kind, value, direct) }, onCustom = { adding = true })
}
else
{
Column(
verticalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.padding(start = 16.dp, end = 16.dp, bottom = 104.dp),
)
{
ShxIntro(stringResource(R.string.rules_hint))
if (!rulesActive && rows.any { it.kind != RuleKind.PACKAGE })
{
ShxNote(stringResource(R.string.route_rules_need_flexible), tone = ShxTone.WARNING)
}
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp))
{
ShxChip(stringResource(R.string.rules_filter_all, rows.size), filter == null, { filter = null })
RuleKind.entries.forEach { kind ->
ShxChip(stringResource(kind.pluralTitleRes()), filter == kind, { filter = kind })
}
}
val shown = rows.filter { filter == null || it.kind == filter }
listOf(false, true).forEach { direct ->
val group = shown.filter { it.direct == direct }
if (group.isNotEmpty())
{
Column(verticalArrangement = Arrangement.spacedBy(6.dp))
{
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.padding(start = 6.dp, top = 6.dp))
{
Box(Modifier.size(8.dp).clip(CircleShape).background(if (direct) c.success else c.accent))
Text(
stringResource(if (direct) R.string.rules_group_direct else R.string.rules_group_vpn).uppercase(),
style = ShxType.section,
color = c.accentText,
)
}
ShxPanel()
{
group.forEachIndexed { i, row ->
RuleRowItem(row, onToggle = { toggle(manager, row, it) }, onClick = { actionsFor = row })
if (i < group.lastIndex)
{
Box(Modifier.fillMaxWidth().height(1.dp).background(c.divider))
}
}
}
}
}
}
}
}
}
if (rows.isNotEmpty())
{
AddRuleFab(Modifier.align(Alignment.BottomEnd).navigationBarsPadding().padding(end = 16.dp, bottom = 24.dp)) { adding = true }
}
}
if (adding)
{
RuleAddSheet(manager = manager, rulesActive = rulesActive, onDismiss = { adding = false })
}
actionsFor?.let { row ->
ShxSheet(onDismiss = { actionsFor = null })
{
ShxSheetTitle(row.value)
Text(
stringResource(if (row.direct) R.string.rules_to_direct else R.string.rules_to_vpn) + " · " + stringResource(row.kind.tagRes()),
style = ShxType.note.copy(fontSize = 14.sp),
color = c.textSub,
modifier = Modifier.padding(horizontal = 4.dp),
)
ShxButton(
stringResource(R.string.rules_delete),
{
delete(manager, row)
actionsFor = null
},
style = ShxButtonStyle.DANGER,
modifier = Modifier.fillMaxWidth(),
icon = ShxIcons.Trash,
)
}
}
}
private fun toggle(manager: RoutingManager, row: RuleRow, on: Boolean) = when (row.kind)
{
RuleKind.SITE -> manager.setSiteRuleEnabled(row.id, on)
RuleKind.ADDRESS -> if (row.direct) manager.setDirectPrefixEnabled(row.id, on) else manager.setForcePrefixEnabled(row.id, on)
RuleKind.PACKAGE -> manager.setPatternEnabled(row.id, on)
}
private fun delete(manager: RoutingManager, row: RuleRow) = when (row.kind)
{
RuleKind.SITE -> manager.deleteSiteRule(row.id)
RuleKind.ADDRESS -> if (row.direct) manager.deleteDirectPrefix(row.id) else manager.deleteForcePrefix(row.id)
RuleKind.PACKAGE -> manager.removePattern(row.id)
}
private fun addRule(manager: RoutingManager, kind: RuleKind, value: String, direct: Boolean): Boolean = when (kind)
{
RuleKind.SITE -> manager.addSiteRule(value, direct) != null
RuleKind.ADDRESS -> (if (direct) manager.addDirectPrefix(value) else manager.addForcePrefix(value)) != null
RuleKind.PACKAGE -> manager.addPattern(value)
}
private fun RuleKind.tagRes(): Int = when (this)
{
RuleKind.SITE -> R.string.rules_tag_site
RuleKind.ADDRESS -> R.string.rules_tag_address
RuleKind.PACKAGE -> R.string.rules_tag_package
}
private fun RuleKind.pluralTitleRes(): Int = when (this)
{
RuleKind.SITE -> R.string.rules_filter_sites
RuleKind.ADDRESS -> R.string.rules_filter_addresses
RuleKind.PACKAGE -> R.string.rules_filter_packages
}
/** Row of the mockup: tag, monospace value with a sub line, switch; 64dp. */
@Composable
private fun RuleRowItem(row: RuleRow, onToggle: (Boolean) -> Unit, onClick: () -> Unit)
{
val c = LocalShxColors.current
val (tagBg, tagFg) = when (row.kind)
{
RuleKind.SITE -> c.infoContainer to c.onInfo
RuleKind.ADDRESS -> c.tile to c.textSoft
RuleKind.PACKAGE -> c.successContainer to c.onSuccess
}
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.fillMaxWidth().height(64.dp).clickable(role = Role.Button, onClick = onClick).padding(horizontal = 14.dp),
)
{
Text(
stringResource(row.kind.tagRes()).uppercase(),
style = ShxType.tag.copy(fontSize = 10.sp),
color = tagFg,
modifier = Modifier.clip(RoundedCornerShape(8.dp)).background(tagBg).padding(horizontal = 7.dp, vertical = 3.dp),
)
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp))
{
Text(
row.value,
style = ShxType.mono.copy(fontWeight = FontWeight.Bold),
color = if (row.enabled) c.text else c.textSub,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
when (row.kind)
{
RuleKind.SITE -> if (row.addresses >= 0) pluralStringResource(R.plurals.rules_site_addresses, row.addresses, row.addresses)
else stringResource(R.string.rules_site_unresolved)
RuleKind.ADDRESS -> stringResource(if (row.direct) R.string.rules_address_direct else R.string.rules_address_vpn)
RuleKind.PACKAGE -> stringResource(R.string.rules_package_sub)
},
style = ShxType.meta.copy(fontWeight = FontWeight.Normal),
color = c.textSub,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
ShxSwitch(checked = row.enabled, onCheckedChange = onToggle)
}
}
/** The purple «+ Rule» button: 60dp, radius 20, purple shadow. */
@Composable
private fun AddRuleFab(modifier: Modifier, onClick: () -> Unit)
{
val c = LocalShxColors.current
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
modifier = modifier
.shadow(12.dp, RoundedCornerShape(20.dp), ambientColor = c.accent, spotColor = c.accent)
.height(60.dp)
.clip(RoundedCornerShape(20.dp))
.background(c.accent)
.clickable(role = Role.Button, onClick = onClick)
.padding(horizontal = 22.dp),
)
{
Icon(ShxIcons.Plus, contentDescription = null, tint = Color.White, modifier = Modifier.size(20.dp))
Text(stringResource(R.string.rules_add), style = ShxType.button.copy(fontSize = 16.sp), color = Color.White)
}
}
/** No rules yet: dashed card, ideas, and the button for your own rule. */
@Composable
private fun RulesEmpty(onIdea: (RuleKind, String, Boolean) -> Boolean, onCustom: () -> Unit)
{
val c = LocalShxColors.current
val ideas = listOf(
Triple(RuleKind.SITE, "chatgpt.com", false) to R.string.rules_idea_chatgpt,
Triple(RuleKind.SITE, "mos.ru", true) to R.string.rules_idea_mos,
Triple(RuleKind.PACKAGE, "ru.*", true) to R.string.rules_idea_ru,
)
Column(
verticalArrangement = Arrangement.spacedBy(14.dp),
modifier = Modifier.padding(start = 16.dp, end = 16.dp, bottom = 28.dp),
)
{
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(10.dp),
modifier = Modifier
.padding(top = 12.dp)
.fillMaxWidth()
.clip(RoundedCornerShape(28.dp))
.background(c.card)
.drawBehind {
drawRoundRect(
color = c.accentBorder,
cornerRadius = CornerRadius(28.dp.toPx()),
style = Stroke(width = 1.dp.toPx(), pathEffect = PathEffect.dashPathEffect(floatArrayOf(6f, 6f))),
)
}
.padding(horizontal = 20.dp, vertical = 28.dp),
)
{
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(56.dp).clip(RoundedCornerShape(18.dp)).background(c.accentContainer))
{
Icon(ShxIcons.Lines, contentDescription = null, tint = c.onAccentContainer, modifier = Modifier.size(26.dp))
}
Text(stringResource(R.string.rules_empty_title), style = ShxType.cardHeading.copy(fontSize = 18.sp), color = c.text, textAlign = TextAlign.Center)
Text(stringResource(R.string.rules_empty_text), style = ShxType.note.copy(fontSize = 14.sp), color = c.textSoft, textAlign = TextAlign.Center)
}
ShxSectionHeader(stringResource(R.string.rules_ideas))
ShxPanel()
{
ideas.forEachIndexed { i, (idea, why) ->
val (kind, value, direct) = idea
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.fillMaxWidth().height(64.dp).clickable { onIdea(kind, value, direct) }.padding(start = 16.dp, end = 14.dp),
)
{
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp))
{
Text(value, style = ShxType.mono.copy(fontWeight = FontWeight.Bold), color = c.text)
Text(stringResource(why), style = ShxType.meta.copy(fontWeight = FontWeight.Normal), color = c.textSub)
}
Text(
stringResource(if (direct) R.string.rules_badge_direct else R.string.rules_badge_vpn),
style = ShxType.meta.copy(fontWeight = FontWeight.ExtraBold),
color = if (direct) c.onSuccess else c.onAccentContainer,
modifier = Modifier.clip(RoundedCornerShape(999.dp)).background(if (direct) c.successContainer else c.accentContainer).padding(horizontal = 10.dp, vertical = 4.dp),
)
}
if (i < ideas.lastIndex)
{
Box(Modifier.fillMaxWidth().height(1.dp).background(c.divider))
}
}
}
Spacer(Modifier.height(8.dp))
ShxButton("+ " + stringResource(R.string.rules_add_custom), onCustom, modifier = Modifier.fillMaxWidth())
}
}
/** «New rule» sheet (F_RuleAdd). */
@Composable
private fun RuleAddSheet(manager: RoutingManager, rulesActive: Boolean, onDismiss: () -> Unit)
{
var kind by rememberSaveable { mutableStateOf(RuleKind.SITE) }
var value by rememberSaveable { mutableStateOf("") }
var direct by rememberSaveable { mutableStateOf(true) }
var error by remember { mutableStateOf(false) }
ShxSheet(onDismiss = onDismiss)
{
ShxSheetTitle(stringResource(R.string.rules_new))
ShxTabs(
options = listOf(stringResource(R.string.rules_tab_site), stringResource(R.string.rules_tab_address), stringResource(R.string.rules_tab_package)),
selected = kind.ordinal,
onSelect = {
kind = RuleKind.entries[it]
error = false
if (kind == RuleKind.PACKAGE)
{
direct = true
}
},
)
ShxField(
label = stringResource(
when (kind)
{
RuleKind.SITE -> R.string.rules_input_site
RuleKind.ADDRESS -> R.string.rules_input_address
RuleKind.PACKAGE -> R.string.rules_input_package
}
),
value = value,
onValueChange = {
value = it
error = false
},
mono = true,
isError = error,
errorText = stringResource(R.string.rules_invalid),
keyboardOptions = KeyboardOptions(keyboardType = if (kind == RuleKind.SITE) KeyboardType.Uri else KeyboardType.Ascii),
)
if (kind != RuleKind.PACKAGE)
{
Text(stringResource(R.string.rules_where), style = ShxType.sub.copy(fontWeight = FontWeight.Bold), color = LocalShxColors.current.textSoft, modifier = Modifier.padding(horizontal = 4.dp))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp))
{
ShxChoiceCard(
title = stringResource(R.string.rules_to_direct),
subtitle = stringResource(R.string.rules_direct_short),
selected = direct,
onClick = { direct = true },
tone = ShxChoiceTone.SUCCESS,
modifier = Modifier.weight(1f),
)
ShxChoiceCard(
title = stringResource(R.string.rules_to_vpn),
subtitle = stringResource(if (kind == RuleKind.SITE) R.string.rules_vpn_short_site else R.string.rules_vpn_short),
selected = !direct,
onClick = { direct = false },
modifier = Modifier.weight(1f),
)
}
ShxNote(
stringResource(if (kind == RuleKind.SITE) R.string.rules_site_note else if (direct) R.string.rules_direct_note else R.string.rules_vpn_note)
+ if (!rulesActive) "\n\n" + stringResource(R.string.route_rules_need_flexible) else "",
)
}
else
{
ShxNote(stringResource(R.string.rules_package_note))
}
Row(horizontalArrangement = Arrangement.spacedBy(10.dp))
{
ShxButton(stringResource(android.R.string.cancel), onDismiss, style = ShxButtonStyle.OUTLINE, modifier = Modifier.weight(1f))
ShxButton(
stringResource(R.string.rules_add_confirm),
{
if (addRule(manager, kind, value.trim(), direct)) onDismiss() else error = true
},
enabled = value.isNotBlank(),
modifier = Modifier.weight(2f),
)
}
}
}
@@ -0,0 +1,280 @@
/*
* Copyright (C) 2026 SHX VPN
*
* This program is free software; you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the
* Free Software Foundation; either version 2 of the License, or (at your
* option) any later version.
*/
package org.strongswan.android.ui.compose.theme
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.em
import androidx.compose.ui.unit.sp
import org.strongswan.android.R
/*
* Design tokens of the «Final 2026» mockup. Values are taken 1:1 from the
* mockup boards (light) and from the light->dark mapping used to draw the
* dark boards, so screens built from these tokens match the canvas.
*/
val Manrope = FontFamily(
Font(R.font.manrope_regular, FontWeight.Normal),
Font(R.font.manrope_medium, FontWeight.Medium),
Font(R.font.manrope_semibold, FontWeight.SemiBold),
Font(R.font.manrope_bold, FontWeight.Bold),
Font(R.font.manrope_extrabold, FontWeight.ExtraBold),
)
/** Colors of the mockup, by role. */
@Immutable
data class ShxColors(
/** page background */
val background: Color,
/** white cards */
val card: Color,
/** 1px border of cards */
val cardBorder: Color,
/** grey tiles, tonal buttons, nav bar, icon wells */
val tile: Color,
/** dividers inside cards */
val divider: Color,
/** outlines of inactive chips, outlined buttons, unselected choice cards, the off switch track */
val outline: Color,
val text: Color,
/** secondary text (#5E5A7D) */
val textSub: Color,
/** body text of notes and choice cards (#49454F) */
val textSoft: Color,
/** text on tonal buttons (#2A2640) */
val textTonal: Color,
/** purple fills: primary buttons, the on switch, the hero */
val accent: Color,
val onAccent: Color,
/** purple text: section headers, links, field labels */
val accentText: Color,
/** #E7DFFF: icon wells, the selected nav pill, the hero card */
val accentContainer: Color,
/** #3B2C88 on [accentContainer] */
val onAccentContainer: Color,
/** deep text on the hero card (#21005D) */
val onAccentContainerStrong: Color,
/** #F3EEFF: soft notes, selected choice cards */
val accentSoft: Color,
/** #3B2C88: the selected filter chip */
val chipSelected: Color,
val success: Color,
/** #DDF3E6 */
val successContainer: Color,
/** #EAF7EF: selected "direct" choice card */
val successSoft: Color,
val onSuccess: Color,
/** #08381C */
val onSuccessStrong: Color,
/** split bar "direct" part (#3FBF7F) */
val successBar: Color,
val warning: Color,
/** #FFF4E0 */
val warningContainer: Color,
/** #FFE2B8: badges and the "waiting" chip */
val warningStrong: Color,
/** #F3D3A6 */
val warningBorder: Color,
/** #7A3A06 */
val onWarning: Color,
/** #6B3A10 */
val onWarningContainer: Color,
val error: Color,
val errorContainer: Color,
val onErrorContainer: Color,
/** sheet scrim */
val scrim: Color,
/* hero rings: #F8F5FF, #F6F2FA, #FAF7FC, #FFEFD6, #FFF7EC */
val accentFaint: Color,
val tileSoft: Color,
val tileFaint: Color,
val warningRing: Color,
val warningFaint: Color,
/** #5C2A04: titles on amber */
val onWarningStrong: Color,
/** #79767F: icons of inactive states */
val iconMuted: Color,
/** #E4E0F9: the soft secondary button of the empty Home */
val accentMuted: Color,
/* error hero rings and the error card: #FFE9E7, #FFF4F3, #FFF1F0, #F5C2BE, #6B1A1F */
val errorRing: Color,
val errorFaint: Color,
val errorSoft: Color,
val errorBorder: Color,
val onErrorSoft: Color,
/** #E3ECFF / #1F4FA8: the blue "site" tag */
val infoContainer: Color,
val onInfo: Color,
/** #CFC3FF: dashed border of empty states */
val accentBorder: Color,
)
val LightShxColors = ShxColors(
background = Color(0xFFFCF8FF),
card = Color(0xFFFFFFFF),
cardBorder = Color(0xFFEDE8F2),
tile = Color(0xFFF0ECF4),
divider = Color(0xFFF0ECF4),
outline = Color(0xFFC9C5D0),
text = Color(0xFF1C1B20),
textSub = Color(0xFF5E5A7D),
textSoft = Color(0xFF49454F),
textTonal = Color(0xFF2A2640),
accent = Color(0xFF6D4AFF),
onAccent = Color(0xFFFFFFFF),
accentText = Color(0xFF6D4AFF),
accentContainer = Color(0xFFE7DFFF),
onAccentContainer = Color(0xFF3B2C88),
onAccentContainerStrong = Color(0xFF21005D),
accentSoft = Color(0xFFF3EEFF),
chipSelected = Color(0xFF3B2C88),
success = Color(0xFF148A45),
successContainer = Color(0xFFDDF3E6),
successSoft = Color(0xFFEAF7EF),
onSuccess = Color(0xFF0E6B35),
onSuccessStrong = Color(0xFF08381C),
successBar = Color(0xFF3FBF7F),
warning = Color(0xFFB45309),
warningContainer = Color(0xFFFFF4E0),
warningStrong = Color(0xFFFFE2B8),
warningBorder = Color(0xFFF3D3A6),
onWarning = Color(0xFF7A3A06),
onWarningContainer = Color(0xFF6B3A10),
error = Color(0xFFB3261E),
errorContainer = Color(0xFFFFDAD8),
onErrorContainer = Color(0xFF8C1D24),
scrim = Color(0x801C1B20),
accentFaint = Color(0xFFF8F5FF),
tileSoft = Color(0xFFF6F2FA),
tileFaint = Color(0xFFFAF7FC),
warningRing = Color(0xFFFFEFD6),
warningFaint = Color(0xFFFFF7EC),
onWarningStrong = Color(0xFF5C2A04),
iconMuted = Color(0xFF79767F),
accentMuted = Color(0xFFE4E0F9),
errorRing = Color(0xFFFFE9E7),
errorFaint = Color(0xFFFFF4F3),
errorSoft = Color(0xFFFFF1F0),
errorBorder = Color(0xFFF5C2BE),
onErrorSoft = Color(0xFF6B1A1F),
infoContainer = Color(0xFFE3ECFF),
onInfo = Color(0xFF1F4FA8),
accentBorder = Color(0xFFCFC3FF),
)
val DarkShxColors = ShxColors(
background = Color(0xFF141318),
card = Color(0xFF1F1D24),
cardBorder = Color(0xFF2E2B35),
tile = Color(0xFF2B2930),
divider = Color(0xFF2B2930),
outline = Color(0xFF48454E),
text = Color(0xFFE5E1E9),
textSub = Color(0xFFA9A3BF),
textSoft = Color(0xFFCAC4D0),
textTonal = Color(0xFFE0DAF5),
accent = Color(0xFF6D4AFF),
onAccent = Color(0xFFFFFFFF),
accentText = Color(0xFFB69DFF),
accentContainer = Color(0xFF3B2C88),
onAccentContainer = Color(0xFFD0C2FF),
onAccentContainerStrong = Color(0xFFEADDFF),
accentSoft = Color(0xFF2A2343),
chipSelected = Color(0xFFD0C2FF),
success = Color(0xFF7CE3B0),
successContainer = Color(0xFF12321F),
successSoft = Color(0xFF12321F),
onSuccess = Color(0xFF7CE3B0),
onSuccessStrong = Color(0xFFA8F0C8),
successBar = Color(0xFF3FBF7F),
warning = Color(0xFFFFB869),
warningContainer = Color(0xFF3A2A12),
warningStrong = Color(0xFF4A3416),
warningBorder = Color(0xFF5A4220),
onWarning = Color(0xFFFFD8A8),
onWarningContainer = Color(0xFFF5C892),
error = Color(0xFFFFB4AB),
errorContainer = Color(0xFF4A1C1C),
onErrorContainer = Color(0xFFFFB4AB),
scrim = Color(0x99000000),
accentFaint = Color(0xFF221D33),
tileSoft = Color(0xFF211F26),
tileFaint = Color(0xFF1A1820),
warningRing = Color(0xFF33260F),
warningFaint = Color(0xFF2A2010),
onWarningStrong = Color(0xFFFFD8A8),
iconMuted = Color(0xFF938F99),
accentMuted = Color(0xFF36334A),
errorRing = Color(0xFF3A1A1A),
errorFaint = Color(0xFF2C1616),
errorSoft = Color(0xFF3A1A1A),
errorBorder = Color(0xFF6B2A2A),
onErrorSoft = Color(0xFFFFB4AB),
infoContainer = Color(0xFF1E2A44),
onInfo = Color(0xFFA8C7FF),
accentBorder = Color(0xFF4B3D99),
)
val LocalShxColors = staticCompositionLocalOf { LightShxColors }
/** Text styles of the mockup. */
object ShxType
{
private fun s(size: Int, weight: FontWeight, spacing: Double = 0.0, lineHeight: Double? = null) = TextStyle(
fontFamily = Manrope,
fontSize = size.sp,
fontWeight = weight,
letterSpacing = spacing.em,
lineHeight = lineHeight?.let { (size * it).sp } ?: androidx.compose.ui.unit.TextUnit.Unspecified,
fontFeatureSettings = "tnum",
)
/** screen title of a tab: 26/800 */
val screenTitle = s(26, FontWeight.ExtraBold, -0.02)
/** title of a secondary screen next to the back arrow: 22/800 */
val pageTitle = s(22, FontWeight.ExtraBold)
/** sheet title and the hero state: 24/800 */
val sheetTitle = s(24, FontWeight.ExtraBold, -0.02)
val heroState = s(24, FontWeight.ExtraBold, -0.02)
/** big result numbers: 20/800 */
val number = s(20, FontWeight.ExtraBold)
/** hero card title: 17/800 */
val cardHeading = s(17, FontWeight.ExtraBold)
/** row title in lists: 16/700 */
val rowTitle = s(16, FontWeight.Bold)
/** card titles of alerts and events: 15/800 */
val cardTitle = s(15, FontWeight.ExtraBold)
/** plain body text: 14/600 */
val body = s(14, FontWeight.SemiBold, lineHeight = 1.4)
/** long text in notes and cards: 13/400, 1.45 */
val note = s(13, FontWeight.Normal, lineHeight = 1.45)
/** secondary line under a row title: 13/500 */
val sub = s(13, FontWeight.Medium)
/** small meta text: 12/600 */
val meta = s(12, FontWeight.SemiBold)
/** section headers above cards: 13/800, +0.04em, uppercase */
val section = s(13, FontWeight.ExtraBold, 0.04)
/** primary buttons: 15/800 */
val button = s(15, FontWeight.ExtraBold)
/** small buttons and chips: 13/700 */
val chip = s(13, FontWeight.Bold)
/** nav labels: 12 */
val nav = s(12, FontWeight.SemiBold)
/** tags on rows: 11/800 */
val tag = s(11, FontWeight.ExtraBold)
/** monospace values: addresses, domains */
val mono = TextStyle(fontFamily = FontFamily.Monospace, fontSize = 15.sp, fontWeight = FontWeight.SemiBold)
}
@@ -61,14 +61,15 @@ object ShxSpacing
/** Shape tokens. [scheme] feeds MaterialTheme.shapes; the rest are named radii. */
object ShxShapes
{
val cardRadius = 16.dp
/* radii of the mockup: cards 24, tiles/buttons 16-18, chips 12 */
val cardRadius = 24.dp
val scheme = Shapes(
extraSmall = RoundedCornerShape(6.dp),
small = RoundedCornerShape(10.dp),
medium = RoundedCornerShape(14.dp),
large = RoundedCornerShape(16.dp),
extraLarge = RoundedCornerShape(22.dp),
extraSmall = RoundedCornerShape(8.dp),
small = RoundedCornerShape(12.dp),
medium = RoundedCornerShape(16.dp),
large = RoundedCornerShape(24.dp),
extraLarge = RoundedCornerShape(32.dp),
)
/** Scalloped "cookie" used by the hero connect button (Expressive MaterialShapes). */
@@ -92,12 +93,12 @@ private val LightColors = lightColorScheme(
primary = ProtonPurpleLight,
onPrimary = Color.White,
primaryContainer = Color(0xFFE7DFFF),
onPrimaryContainer = Color(0xFF1F0A6B),
onPrimaryContainer = Color(0xFF3B2C88),
inversePrimary = ProtonPurpleDark,
secondary = Color(0xFF5E5A7D),
onSecondary = Color.White,
secondaryContainer = Color(0xFFE4E0F9),
onSecondaryContainer = Color(0xFF1A1833),
secondaryContainer = Color(0xFFE7DFFF),
onSecondaryContainer = Color(0xFF3B2C88),
tertiary = SuccessGreenLight,
onTertiary = Color.White,
tertiaryContainer = Color(0xFFC8F2DA),
@@ -107,35 +108,36 @@ private val LightColors = lightColorScheme(
surface = Color(0xFFFCF8FF),
onSurface = Color(0xFF1C1B20),
surfaceVariant = Color(0xFFE5E1EC),
onSurfaceVariant = Color(0xFF48454E),
onSurfaceVariant = Color(0xFF5E5A7D),
surfaceDim = Color(0xFFDCD8E0),
surfaceBright = Color(0xFFFCF8FF),
surfaceContainerLowest = Color(0xFFFFFFFF),
surfaceContainerLow = Color(0xFFF6F2FA),
surfaceContainerLow = Color(0xFFFFFFFF),
surfaceContainer = Color(0xFFF0ECF4),
surfaceContainerHigh = Color(0xFFEAE6EE),
surfaceContainerHigh = Color(0xFFF0ECF4),
surfaceContainerHighest = Color(0xFFE5E1E9),
outline = Color(0xFF79767F),
outlineVariant = Color(0xFFC9C5D0),
outline = Color(0xFFC9C5D0),
outlineVariant = Color(0xFFEDE8F2),
inverseSurface = Color(0xFF313036),
inverseOnSurface = Color(0xFFF3EFF7),
error = Color(0xFFD8303C),
error = Color(0xFFB3261E),
onError = Color.White,
errorContainer = Color(0xFFFFDAD6),
onErrorContainer = Color(0xFF410002),
errorContainer = Color(0xFFFFDAD8),
onErrorContainer = Color(0xFF8C1D24),
scrim = Color.Black,
)
private val DarkColors = darkColorScheme(
primary = ProtonPurpleDark,
onPrimary = Color(0xFF150F35),
/* fills stay #6D4AFF like on the dark boards; purple text uses ShxColors.accentText */
primary = ProtonPurpleLight,
onPrimary = Color.White,
primaryContainer = Color(0xFF3B2C88),
onPrimaryContainer = Color(0xFFEDE7FF),
onPrimaryContainer = Color(0xFFD0C2FF),
inversePrimary = ProtonPurpleLight,
secondary = Color(0xFFCBC4E8),
onSecondary = Color(0xFF322F4D),
secondaryContainer = Color(0xFF484564),
onSecondaryContainer = Color(0xFFE4E0F9),
secondaryContainer = Color(0xFF3B2C88),
onSecondaryContainer = Color(0xFFD0C2FF),
tertiary = SuccessGreenDark,
onTertiary = Color(0xFF00391B),
tertiaryContainer = Color(0xFF005228),
@@ -145,16 +147,16 @@ private val DarkColors = darkColorScheme(
surface = Color(0xFF141318),
onSurface = Color(0xFFE5E1E9),
surfaceVariant = Color(0xFF48454E),
onSurfaceVariant = Color(0xFFC9C5D0),
onSurfaceVariant = Color(0xFFA9A3BF),
surfaceDim = Color(0xFF141318),
surfaceBright = Color(0xFF3A383E),
surfaceContainerLowest = Color(0xFF0F0D13),
surfaceContainerLow = Color(0xFF1C1B20),
surfaceContainer = Color(0xFF201F24),
surfaceContainerHigh = Color(0xFF2B292F),
surfaceContainerHighest = Color(0xFF36343A),
outline = Color(0xFF938F99),
outlineVariant = Color(0xFF48454E),
surfaceContainerLow = Color(0xFF1F1D24),
surfaceContainer = Color(0xFF2B2930),
surfaceContainerHigh = Color(0xFF2B2930),
surfaceContainerHighest = Color(0xFF36333D),
outline = Color(0xFF48454E),
outlineVariant = Color(0xFF2E2B35),
inverseSurface = Color(0xFFE5E1E9),
inverseOnSurface = Color(0xFF313036),
error = Color(0xFFFF8A8C),
@@ -183,11 +185,11 @@ data class ShxStatusColors(
private val LightStatusColors = ShxStatusColors(
connected = SuccessGreenLight,
connectedContainer = Color(0xFFDCF3E5),
onConnectedContainer = Color(0xFF07391D),
connectedContainer = Color(0xFFDDF3E6),
onConnectedContainer = Color(0xFF0E6B35),
connecting = ProtonPurpleLight,
connectingContainer = Color(0xFFEDE7FF),
onConnectingContainer = Color(0xFF2E1A7A),
connectingContainer = Color(0xFFE7DFFF),
onConnectingContainer = Color(0xFF3B2C88),
disconnected = Color(0xFFC0362C),
disconnectedContainer = Color(0xFFFFE1DE),
onDisconnectedContainer = Color(0xFF410001),
@@ -197,12 +199,12 @@ private val LightStatusColors = ShxStatusColors(
)
private val DarkStatusColors = ShxStatusColors(
connected = SuccessGreenDark,
connectedContainer = Color(0xFF0B3B24),
onConnectedContainer = Color(0xFFC8F2DA),
connecting = ProtonPurpleDark,
connectingContainer = Color(0xFF2E2360),
onConnectingContainer = Color(0xFFEDE7FF),
connected = Color(0xFF7CE3B0),
connectedContainer = Color(0xFF12321F),
onConnectedContainer = Color(0xFF7CE3B0),
connecting = Color(0xFFB69DFF),
connectingContainer = Color(0xFF3B2C88),
onConnectingContainer = Color(0xFFD0C2FF),
disconnected = Color(0xFFED6B62),
disconnectedContainer = Color(0xFF4A1712),
onDisconnectedContainer = Color(0xFFFFE1DE),
@@ -273,28 +275,31 @@ object ShxThemeSettings
}
}
/** Tabular figures so ticking numbers (traffic, RTT, duration) don't jitter. */
private fun withTabularNumerals(style: TextStyle): TextStyle = style.copy(fontFeatureSettings = "tnum")
/** Manrope with tabular figures (ticking numbers do not jitter), weights as on the mockup. */
private fun m(style: TextStyle, weight: FontWeight, size: Int? = null): TextStyle =
style.copy(fontFamily = Manrope, fontWeight = weight, fontFeatureSettings = "tnum").let {
if (size != null) it.copy(fontSize = size.sp) else it
}
private fun buildTypography(): Typography
{
val base = Typography()
return base.copy(
displayLarge = withTabularNumerals(base.displayLarge),
displayMedium = withTabularNumerals(base.displayMedium),
displaySmall = withTabularNumerals(base.displaySmall),
headlineLarge = withTabularNumerals(base.headlineLarge),
headlineMedium = withTabularNumerals(base.headlineMedium).copy(fontWeight = FontWeight.SemiBold),
headlineSmall = withTabularNumerals(base.headlineSmall),
titleLarge = withTabularNumerals(base.titleLarge),
titleMedium = withTabularNumerals(base.titleMedium).copy(fontSize = 18.sp),
titleSmall = withTabularNumerals(base.titleSmall),
bodyLarge = withTabularNumerals(base.bodyLarge),
bodyMedium = withTabularNumerals(base.bodyMedium),
bodySmall = withTabularNumerals(base.bodySmall),
labelLarge = withTabularNumerals(base.labelLarge),
labelMedium = withTabularNumerals(base.labelMedium),
labelSmall = withTabularNumerals(base.labelSmall),
displayLarge = m(base.displayLarge, FontWeight.ExtraBold),
displayMedium = m(base.displayMedium, FontWeight.ExtraBold),
displaySmall = m(base.displaySmall, FontWeight.ExtraBold),
headlineLarge = m(base.headlineLarge, FontWeight.ExtraBold, 26),
headlineMedium = m(base.headlineMedium, FontWeight.ExtraBold, 24),
headlineSmall = m(base.headlineSmall, FontWeight.ExtraBold, 22),
titleLarge = m(base.titleLarge, FontWeight.ExtraBold, 22),
titleMedium = m(base.titleMedium, FontWeight.Bold, 16),
titleSmall = m(base.titleSmall, FontWeight.ExtraBold, 15),
bodyLarge = m(base.bodyLarge, FontWeight.SemiBold, 16),
bodyMedium = m(base.bodyMedium, FontWeight.Medium, 14),
bodySmall = m(base.bodySmall, FontWeight.Medium, 13),
labelLarge = m(base.labelLarge, FontWeight.ExtraBold, 14),
labelMedium = m(base.labelMedium, FontWeight.Bold, 12),
labelSmall = m(base.labelSmall, FontWeight.ExtraBold, 11),
)
}
@@ -337,7 +342,10 @@ fun ShxTheme(content: @Composable () -> Unit)
else -> LightColors
}
val statusColors = if (darkTheme) DarkStatusColors else LightStatusColors
CompositionLocalProvider(LocalShxStatusColors provides statusColors) {
CompositionLocalProvider(
LocalShxStatusColors provides statusColors,
LocalShxColors provides if (darkTheme) DarkShxColors else LightShxColors,
) {
MaterialExpressiveTheme(
colorScheme = colors,
motionScheme = MotionScheme.expressive(),
@@ -206,8 +206,8 @@
<string name="whitelist_direct_section">Напрямую (вне туннеля)</string>
<string name="whitelist_foreign_direct">Зарубежные хосты (напрямую)</string>
<string name="whitelist_domestic_direct">Яндекс (напрямую)</string>
<string name="nav_routing">Маршрут</string>
<string name="routing_title">Маршрутизация</string>
<string name="nav_routing">Маршруты</string>
<string name="routing_title">Маршруты</string>
<string name="routing_section_ru">Сайты и сервисы РФ</string>
<string name="routing_force_section">Всегда через VPN</string>
<string name="routing_force_summary">Адреса и подсети, которые всегда идут через туннель, даже если попадают в RU-диапазоны (режим userspace)</string>
@@ -296,17 +296,17 @@
<string name="rusplit_updating">Обновление…</string>
<string name="rusplit_updated">Список обновлён: %1$d диапазонов</string>
<string name="rusplit_update_failed">Не удалось обновить, используется сохранённая версия</string>
<string name="route_check_title">Проверка маршрута</string>
<string name="route_check_hint">Введите домен, чтобы увидеть, какие адреса он получает через DNS туннеля и через DNS прямой сети, и какой путь проходит каждый адрес при текущих исключениях.</string>
<string name="route_check_field">домен, например inet.ya.ru</string>
<string name="route_check_title">Как ходит трафик</string>
<string name="route_check_hint">Проверьте, каким путём идёт сайт, адрес или приложение — через VPN или напрямую, и почему.</string>
<string name="route_check_field">Сайт, например gosuslugi.ru</string>
<string name="route_check_button">Проверить</string>
<string name="route_check_checking">Проверяю…</string>
<string name="route_check_no_vpn">Туннель не подключён — просмотр маршрута недоступен</string>
<string name="route_check_via_vpn_dns">Через DNS туннеля</string>
<string name="route_check_via_direct_dns">Через DNS прямой сети</string>
<string name="route_check_via_vpn_dns">Через туннель</string>
<string name="route_check_via_direct_dns">Через провайдера</string>
<string name="route_check_routes">Маршруты</string>
<string name="route_verdict_tunnel">через туннель</string>
<string name="route_verdict_direct">напрямую (RU-исключение)</string>
<string name="route_verdict_tunnel">через VPN</string>
<string name="route_verdict_direct">напрямую</string>
<string name="route_verdict_dns">напрямую (DNS-сервер Персонального DNS)</string>
<string name="route_check_resolve_failed">Не удалось разрешить домен ни на одном пути</string>
<string name="route_check_none">нет</string>
@@ -412,8 +412,8 @@
<string name="tile_connect">Connect VPN</string>
<string name="tile_disconnect">Disconnect VPN</string>
<string name="nav_connect">Подключение</string>
<string name="nav_profiles">Профили</string>
<string name="nav_connect">Главная</string>
<string name="nav_profiles">Серверы</string>
<string name="nav_settings">Настройки</string>
<string name="status_unprotected">Не защищено</string>
<string name="status_protected">Защищено</string>
@@ -547,7 +547,7 @@
<string name="home_action_switch">Сменить сервер</string>
<string name="home_action_not_loading">Не открывается?</string>
<string name="home_pause_title">Пауза VPN</string>
<string name="home_pause_explain">VPN выключится и включится сам. Автоподключение подождёт, пока пауза не закончится.</string>
<string name="home_pause_explain">Весь трафик пойдёт напрямую. VPN включится сам.</string>
<plurals name="home_pause_minutes">
<item quantity="one">%d минута</item>
<item quantity="few">%d минуты</item>
@@ -558,7 +558,7 @@
<string name="home_paused_subtitle">VPN включится сам в %1$s</string>
<string name="home_pause_resume">Включить сейчас</string>
<string name="home_pause_extend">+15 минут</string>
<string name="home_pause_note">Пока VPN на паузе, трафик идёт без него.</string>
<string name="home_pause_note">Весь трафик сейчас идёт напрямую, без защиты. Банки и РФ-сервисы работают как обычно.</string>
<string name="home_problem_offline_title">Нет интернета</string>
<string name="home_problem_offline_subtitle">Проблема не в VPN. Подключимся сами, как только появится сеть</string>
<string name="home_problem_offline_card">У телефона нет сети</string>
@@ -627,4 +627,238 @@
<string name="event_pause_over_text">VPN снова включён.</string>
<string name="event_update_title">Доступна версия %1$s</string>
<string name="event_update_text">Скачайте и установите её из приложения.</string>
<!-- Route check: site / IP / app -->
<string name="route_check_field_ip">Адрес, например 95.163.0.12</string>
<string name="route_check_field_app">Название приложения</string>
<string name="route_check_mode_site">Сайт</string>
<string name="route_check_mode_ip">IP / сеть</string>
<string name="route_check_mode_app">Приложение</string>
<string name="route_check_bad_ip">Это не IP-адрес.</string>
<string name="route_check_bad_app">Среди приложений с доступом в сеть такого нет.</string>
<string name="route_check_result">Итог</string>
<string name="route_check_verdict_vpn">Через VPN</string>
<string name="route_check_verdict_direct">Напрямую</string>
<string name="route_check_verdict_mixed">Частично напрямую</string>
<string name="route_check_addresses">Адреса · %1$d</string>
<string name="route_check_dns_differ">Похоже, провайдер подменяет DNS — поэтому используем DNS туннеля.</string>
<string name="route_check_change_for">Изменить для %1$s</string>
<string name="route_check_always_direct">Всегда напрямую</string>
<string name="route_check_always_vpn">Всегда через VPN</string>
<string name="route_check_app_note">Приложение маршрутизируется целиком: «напрямую» — весь его трафик мимо VPN.</string>
<string name="route_reason_dns">DNS-сервер туннеля — всегда через него.</string>
<string name="route_reason_rule_vpn">Ваше правило отправляет его через VPN.</string>
<string name="route_reason_rule_direct">Ваше правило пускает его напрямую.</string>
<string name="route_reason_ru">Адрес есть в базе РФ — поэтому напрямую.</string>
<string name="route_reason_default">Адреса нет в базе РФ и своих правил нет — значит, по умолчанию через VPN.</string>
<string name="route_reason_app_routing_off">Раздельный туннель выключен: все приложения идут через VPN.</string>
<string name="route_reason_app_override">Вы оставили его в VPN, хотя шаблон пустил бы напрямую.</string>
<string name="route_reason_app_added">Вы добавили его в «Приложения напрямую».</string>
<string name="route_reason_app_template">Напрямую по шаблону «%1$s».</string>
<string name="route_reason_app_default">Не в списке «напрямую» — через VPN.</string>
<string name="route_rules_need_flexible">Правила для сайтов и адресов работают, когда «Сайты РФ напрямую» включены в режиме «Гибкий».</string>
<!-- Routing: rules, recent apps, sections -->
<string name="rules_title">Правила</string>
<string name="rules_hint">Куда идут сайты, адреса и приложения. Правило важнее базы РФ.</string>
<string name="rules_filter_all">Все · %1$d</string>
<string name="rules_filter_sites">Сайты</string>
<string name="rules_filter_addresses">Адреса</string>
<string name="rules_filter_packages">Пакеты</string>
<string name="rules_group_vpn">Через VPN</string>
<string name="rules_group_direct">Напрямую</string>
<string name="rules_add">Правило</string>
<string name="rules_tag_site">Сайт</string>
<string name="rules_tag_address">Адрес</string>
<string name="rules_tag_package">Пакет</string>
<string name="rules_site_unresolved">адреса ещё не определены</string>
<string name="rules_address_direct">всегда напрямую</string>
<string name="rules_address_vpn">всегда через VPN, даже если в базе РФ</string>
<string name="rules_package_sub">приложения с таким пакетом — напрямую</string>
<string name="rules_delete">Удалить правило</string>
<string name="rules_empty_title">Правил пока нет — и это нормально</string>
<string name="rules_empty_text">Сайты РФ и выбранные приложения уже идут напрямую. Правило нужно, только если какой-то сайт идёт не тем путём.</string>
<string name="rules_ideas">Часто добавляют</string>
<string name="rules_idea_chatgpt">не пускает с российских адресов</string>
<string name="rules_idea_mos">работает только из России</string>
<string name="rules_idea_ru">все пакеты, начинающиеся с ru.</string>
<string name="rules_to_direct">Напрямую</string>
<string name="rules_to_vpn">Через VPN</string>
<string name="rules_add_custom">Своё правило</string>
<string name="rules_new">Новое правило</string>
<string name="rules_input_site">Домен, например chatgpt.com</string>
<string name="rules_input_address">Адрес или сеть, например 95.163.0.0/16</string>
<string name="rules_input_package">Шаблон пакета, например ru.sberbank*</string>
<string name="rules_invalid">Похоже на ошибку. Проверьте написание.</string>
<string name="rules_site_note">Правило работает по адресам сайта (и www.); они обновляются при подключении и раз в 30 минут. Для поддоменов нужно отдельное правило.</string>
<string name="rules_where">Куда направлять</string>
<string name="rules_direct_note">Мимо VPN, даже если адреса нет в базе РФ.</string>
<string name="rules_vpn_note">Через VPN, даже если адрес в базе РФ.</string>
<string name="rules_package_note">Подходящие приложения идут напрямую целиком. * — любые символы.</string>
<string name="rules_add_confirm">Добавить правило</string>
<string name="recent_title">Недавние приложения</string>
<string name="recent_subtitle">за последний час · обновляется вживую</string>
<string name="recent_subtitle_no_access">Без доступа к статистике видны только приложения со сбоями соединений.</string>
<string name="recent_filter_all">Все · %1$d</string>
<string name="recent_filter_vpn">Через VPN</string>
<string name="recent_filter_direct">Напрямую</string>
<string name="recent_filter_failing">Со сбоями · %1$d</string>
<string name="recent_empty">За это время в сеть никто не выходил.</string>
<string name="recent_group_vpn">Через VPN</string>
<string name="recent_group_direct">Напрямую</string>
<string name="recent_note">Переключатель — «напрямую, без VPN». Трафик приложений считается по системной статистике.</string>
<string name="recent_fails">неудачных соединений: %1$d</string>
<string name="recent_failing_title">%1$s не открывается</string>
<string name="recent_failing_text">Неудачных соединений: %1$d — к адресам, которые должны работать напрямую. Обычно помогает пустить их через VPN.</string>
<string name="recent_failing_action">Через VPN</string>
<string name="recent_failing_details">Подробнее</string>
<string name="recent_access_title">Покажем, какие приложения выходят в сеть</string>
<string name="recent_access_text">И каким путём — через VPN или напрямую. Так проще найти приложение, которое не работает. Для этого нужен доступ «Статистика использования».</string>
<string name="recent_access_privacy">Мы видим только объём трафика по приложениям. Содержимое и сайты остаются на телефоне.</string>
<string name="recent_access_allow">Разрешить доступ</string>
<string name="recent_access_later">Не сейчас</string>
<string name="routing_section_exceptions">Исключения</string>
<string name="routing_rules_sub">Сайты, адреса и шаблоны пакетов</string>
<string name="routing_section_check">Проверка</string>
<string name="routing_check_row">Как ходит сайт, IP или приложение</string>
<string name="routing_check_row_sub">Через VPN или напрямую — и почему</string>
<string name="routing_recent_sub">Кто выходил в сеть и каким путём</string>
<string name="routing_split_vpn">Через VPN %1$d%%</string>
<string name="routing_split_direct">Напрямую %1$d%%</string>
<plurals name="rules_site_addresses">
<item quantity="one">%d адрес</item>
<item quantity="few">%d адреса</item>
<item quantity="many">%d адресов</item>
<item quantity="other">%d адреса</item>
</plurals>
<plurals name="routing_rules_n">
<item quantity="one">%d правило</item>
<item quantity="few">%d правила</item>
<item quantity="many">%d правил</item>
<item quantity="other">%d правила</item>
</plurals>
<plurals name="routing_recent_failing">
<item quantity="one">%d приложение со сбоями</item>
<item quantity="few">%d приложения со сбоями</item>
<item quantity="many">%d приложений со сбоями</item>
<item quantity="other">%d приложения со сбоями</item>
</plurals>
<!-- Events: routing suggestions -->
<string name="event_new_app_title">Новое приложение: %1$s</string>
<string name="event_new_app_text">Похоже на российский сервис. Пустить напрямую, мимо VPN?</string>
<string name="events_action_direct">Да, напрямую</string>
<string name="events_action_no">Нет</string>
<!-- Home, pause, diagnostics, route check (mockup texts) -->
<string name="home_empty_add">Добавить сервер</string>
<string name="home_empty_title">Добавьте сервер</string>
<string name="home_empty_status">Чтобы подключиться, нужен профиль .sswan</string>
<string name="home_empty_where_title">Где взять профиль?</string>
<string name="home_empty_where_text">Его присылает администратор вашего VPN — файлом .sswan, ссылкой или QR-кодом.</string>
<string name="home_connect_now">Подключить сейчас</string>
<string name="home_wl_status">Работают только российские сайты — банки и Госуслуги открываются. VPN подключится сам.</string>
<string name="home_unreachable_status">%1$s молчит · снова пробуем через %2$d с</string>
<string name="home_error_status">%1$s · %2$s</string>
<string name="home_status_ru">%1$s · РФ-сайты напрямую</string>
<string name="home_cancel_connect">Отменить подключение</string>
<string name="home_state_off">VPN выключен</string>
<string name="home_state_off_status">Нажмите, чтобы подключиться</string>
<string name="home_server_ip_rtt">IP %1$s · %2$d мс</string>
<string name="home_server_ip">IP %1$s</string>
<string name="home_server_change">Сменить</string>
<string name="home_wl_what">Что делать</string>
<string name="home_wl_wifi">Через Wi-Fi</string>
<string name="home_net_on">подключено</string>
<string name="home_net_off">выключено</string>
<string name="home_net_waiting">ждёт сеть</string>
<string name="home_fastest_server">Быстрейший сервер</string>
<string name="home_other_server">Подключиться к другому серверу</string>
<string name="home_action_pause15">Пауза 15 мин</string>
<string name="home_action_fastest">Быстрейший</string>
<string name="home_fastest_searching">Ищем…</string>
<string name="home_fastest_none">Ни один сервер не ответил</string>
<string name="home_fastest_already">Вы уже на самом быстром сервере</string>
<string name="home_traffic_session">за сессию</string>
<string name="home_traffic_split">%1$d%% через VPN · %2$d%% напрямую</string>
<string name="home_traffic_all_vpn">Весь трафик через VPN</string>
<string name="home_cancel">Отменить</string>
<string name="home_lockdown_off">Постоянный VPN выключен</string>
<string name="home_lockdown_enable">Включить</string>
<string name="home_check_row">Проверить, в чём дело</string>
<string name="home_check_row_sub">Сеть, сервер, DNS и белые списки за 10 секунд</string>
<string name="home_pause_morning">До утра</string>
<string name="home_pause_hours">%1$d час</string>
<string name="home_pause_min">%1$d мин</string>
<string name="home_pause_until">до %1$s</string>
<string name="home_pause_network">До смены сети</string>
<string name="home_pause_network_sub">Включится, когда уйдёте с этого Wi-Fi</string>
<string name="home_pause_go">Поставить на паузу %1$s</string>
<string name="home_pause_go_morning">Поставить на паузу до утра</string>
<string name="home_pause_footer">Отменить можно с главной, из шторки или виджета</string>
<string name="diag_title">Диагностика</string>
<string name="diag_log">Журнал</string>
<string name="diag_step_internet">Интернет</string>
<string name="diag_step_ru">Российские сайты</string>
<string name="diag_step_foreign">Зарубежные сайты</string>
<string name="diag_step_server">Сервер VPN</string>
<string name="diag_step_dns">DNS</string>
<string name="diag_none">нет сети</string>
<string name="diag_ms">%1$d мс</string>
<string name="diag_ok">работают</string>
<string name="diag_fail">не открываются</string>
<string name="diag_unknown">нет данных</string>
<string name="diag_whitelist">белые списки</string>
<string name="diag_via_vpn_ms">через VPN · %1$d мс</string>
<string name="diag_direct_ms">напрямую · %1$d мс</string>
<string name="diag_no_server">не выбран</string>
<string name="diag_no_answer">не отвечает</string>
<string name="diag_dns_tunnel">туннель</string>
<string name="diag_dns_provider">провайдер</string>
<string name="diag_checking">проверяем…</string>
<string name="diag_not_opening">Что-то не открывается?</string>
<string name="diag_site_hint">или сайт</string>
<string name="diag_dead_meta">отказов %1$d · пауза %2$s</string>
<string name="diag_send_report">Отправить отчёт</string>
<string name="diag_verdict_ok">VPN работает нормально</string>
<string name="diag_verdict_ok_no_vpn">Сеть работает, VPN выключен</string>
<string name="diag_verdict_problem">Проблема: %1$s</string>
<string name="diag_checking_title">Проверяем…</string>
<string name="diag_checking_sub">Сеть, сервер, DNS и белые списки</string>
<string name="diag_checked_at">Проверено %1$s · нажмите, чтобы повторить</string>
<string name="diag_ago_sec">%1$d с назад</string>
<string name="diag_ago_min">%1$d мин назад</string>
<string name="route_check_opens_ms">открывается · %1$d мс</string>
<string name="route_check_not_opens">не открывается</string>
<string name="route_check_node_phone">Телефон</string>
<string name="route_check_node_provider">Провайдер</string>
<string name="route_check_node_vpn">VPN</string>
<string name="route_check_why">Почему:</string>
<string name="route_check_compare">Прямой путь для сравнения</string>
<string name="route_check_dns_differ_title">Ответы различаются.</string>
<string name="routing_ru_title">Сайты РФ напрямую</string>
<string name="routing_db_short">База: %1$s сетей · %2$s</string>
<string name="routing_mode_stable_short">Новые адреса — после переподключения</string>
<string name="routing_mode_flexible_short">На лету, без переподключения</string>
<string name="routing_apps_title">Приложения напрямую</string>
<string name="routing_apps_none">Банки и сервисы РФ часто не работают через VPN</string>
<plurals name="routing_apps_direct_n">
<item quantity="one">%d работает без VPN</item>
<item quantity="few">%d работают без VPN</item>
<item quantity="many">%d работают без VPN</item>
<item quantity="other">%d работают без VPN</item>
</plurals>
<plurals name="routing_recent_failing_short">
<item quantity="one">%d с ошибками</item>
<item quantity="few">%d с ошибками</item>
<item quantity="many">%d с ошибками</item>
<item quantity="other">%d с ошибками</item>
</plurals>
<string name="rules_tab_site">Сайт</string>
<string name="rules_tab_address">Адрес / сеть</string>
<string name="rules_tab_package">Пакет</string>
<string name="rules_badge_direct">напрямую</string>
<string name="rules_badge_vpn">через VPN</string>
<string name="rules_direct_short">Мимо VPN</string>
<string name="rules_vpn_short">Даже если адрес в базе РФ</string>
<string name="rules_vpn_short_site">Даже если сайт в базе РФ</string>
<string name="recent_route_direct">напрямую</string>
<string name="recent_route_vpn">VPN</string>
<string name="events_read_all">Прочитать все</string>
</resources>
@@ -210,8 +210,8 @@
<string name="whitelist_direct_section">Direct (outside the tunnel)</string>
<string name="whitelist_foreign_direct">Foreign hosts (direct)</string>
<string name="whitelist_domestic_direct">Yandex (direct)</string>
<string name="nav_routing">Routing</string>
<string name="routing_title">Routing</string>
<string name="nav_routing">Routes</string>
<string name="routing_title">Routes</string>
<string name="routing_section_ru">RF sites and services</string>
<string name="routing_force_section">Always via VPN</string>
<string name="routing_force_summary">Addresses and subnets that are always routed through the tunnel, even when they fall into RU ranges (userspace mode)</string>
@@ -296,17 +296,17 @@
<string name="rusplit_updating">Updating…</string>
<string name="rusplit_updated">List updated: %1$d ranges</string>
<string name="rusplit_update_failed">Could not update, using the saved version</string>
<string name="route_check_title">Route check</string>
<string name="route_check_hint">Enter a domain to see which addresses it resolves to through the tunnel\'s DNS and the direct network\'s DNS, and whether each address goes through the tunnel or bypasses it under the applied exclusions.</string>
<string name="route_check_field">domain, e.g. inet.ya.ru</string>
<string name="route_check_title">How traffic goes</string>
<string name="route_check_hint">Check which way a site, an address or an app goes: through the VPN or direct, and why.</string>
<string name="route_check_field">Site, e.g. gosuslugi.ru</string>
<string name="route_check_button">Check</string>
<string name="route_check_checking">Checking…</string>
<string name="route_check_no_vpn">The tunnel is not connected — the route view is unavailable</string>
<string name="route_check_via_vpn_dns">Resolved through the tunnel\'s DNS</string>
<string name="route_check_via_direct_dns">Resolved through the direct network\'s DNS</string>
<string name="route_check_via_vpn_dns">Through the tunnel</string>
<string name="route_check_via_direct_dns">Through the provider</string>
<string name="route_check_routes">Routes</string>
<string name="route_verdict_tunnel">through the tunnel</string>
<string name="route_verdict_direct">direct (RU exclusion)</string>
<string name="route_verdict_tunnel">via VPN</string>
<string name="route_verdict_direct">direct</string>
<string name="route_verdict_dns">direct (private DNS server)</string>
<string name="route_check_resolve_failed">Could not resolve the domain on either path</string>
<string name="route_check_none">nothing</string>
@@ -413,8 +413,8 @@
<string name="tile_disconnect">Disconnect VPN</string>
<!-- Compose home -->
<string name="nav_connect">Connect</string>
<string name="nav_profiles">Profiles</string>
<string name="nav_connect">Home</string>
<string name="nav_profiles">Servers</string>
<string name="nav_settings">Settings</string>
<string name="status_unprotected">Unprotected</string>
<string name="status_protected">Protected</string>
@@ -548,7 +548,7 @@
<string name="home_action_switch">Switch server</string>
<string name="home_action_not_loading">Site not loading?</string>
<string name="home_pause_title">Pause VPN</string>
<string name="home_pause_explain">The VPN turns off and comes back by itself. Auto-connect waits until the pause ends.</string>
<string name="home_pause_explain">All traffic goes direct. The VPN turns back on by itself.</string>
<plurals name="home_pause_minutes">
<item quantity="one">%d minute</item>
<item quantity="other">%d minutes</item>
@@ -557,7 +557,7 @@
<string name="home_paused_subtitle">The VPN turns back on at %1$s</string>
<string name="home_pause_resume">Turn on now</string>
<string name="home_pause_extend">+15 minutes</string>
<string name="home_pause_note">While paused, traffic goes without the VPN.</string>
<string name="home_pause_note">All traffic goes direct now, without protection. Banks and Russian services work as usual.</string>
<string name="home_problem_offline_title">No internet</string>
<string name="home_problem_offline_subtitle">Not a VPN problem. We connect as soon as a network is back</string>
<string name="home_problem_offline_card">The phone has no network</string>
@@ -624,4 +624,228 @@
<string name="event_pause_over_text">The VPN turned back on.</string>
<string name="event_update_title">Version %1$s is available</string>
<string name="event_update_text">Download and install it from the app.</string>
<!-- Route check: site / IP / app -->
<string name="route_check_field_ip">Address, e.g. 95.163.0.12</string>
<string name="route_check_field_app">App name</string>
<string name="route_check_mode_site">Site</string>
<string name="route_check_mode_ip">IP / network</string>
<string name="route_check_mode_app">App</string>
<string name="route_check_bad_ip">This is not an IP address.</string>
<string name="route_check_bad_app">No such app among those that use the network.</string>
<string name="route_check_result">Result</string>
<string name="route_check_verdict_vpn">Through the VPN</string>
<string name="route_check_verdict_direct">Direct</string>
<string name="route_check_verdict_mixed">Partly direct</string>
<string name="route_check_addresses">Addresses · %1$d</string>
<string name="route_check_dns_differ">The provider seems to substitute DNS, so the tunnel\'s DNS is used.</string>
<string name="route_check_change_for">Change for %1$s</string>
<string name="route_check_always_direct">Always direct</string>
<string name="route_check_always_vpn">Always through VPN</string>
<string name="route_check_app_note">Apps are routed as a whole: a direct app bypasses the VPN for all its traffic.</string>
<string name="route_reason_dns">A DNS server of the tunnel: always through it.</string>
<string name="route_reason_rule_vpn">Your rule sends it through the VPN.</string>
<string name="route_reason_rule_direct">Your rule sends it direct.</string>
<string name="route_reason_ru">The address is in the RU base, so it goes direct.</string>
<string name="route_reason_default">Not in the RU base and no rule of yours: through the VPN by default.</string>
<string name="route_reason_app_routing_off">Split tunnel is off: every app goes through the VPN.</string>
<string name="route_reason_app_override">You kept it on the VPN although a template would let it go direct.</string>
<string name="route_reason_app_added">You added it to the direct apps.</string>
<string name="route_reason_app_template">Direct by the template «%1$s».</string>
<string name="route_reason_app_default">Not in the direct apps: through the VPN.</string>
<string name="route_rules_need_flexible">Rules for sites and addresses work when «Russian sites direct» is on in the «Flexible» mode.</string>
<!-- Routing: rules, recent apps, sections -->
<string name="rules_title">Rules</string>
<string name="rules_hint">Where sites, addresses and apps go. A rule wins over the RU base.</string>
<string name="rules_filter_all">All · %1$d</string>
<string name="rules_filter_sites">Sites</string>
<string name="rules_filter_addresses">Addresses</string>
<string name="rules_filter_packages">Packages</string>
<string name="rules_group_vpn">Through the VPN</string>
<string name="rules_group_direct">Direct</string>
<string name="rules_add">Rule</string>
<string name="rules_tag_site">Site</string>
<string name="rules_tag_address">Address</string>
<string name="rules_tag_package">Package</string>
<string name="rules_site_unresolved">addresses not resolved yet</string>
<string name="rules_address_direct">always direct</string>
<string name="rules_address_vpn">always through the VPN, even inside the RU base</string>
<string name="rules_package_sub">apps whose package matches go direct</string>
<string name="rules_delete">Delete rule</string>
<string name="rules_empty_title">No rules yet, and that is fine</string>
<string name="rules_empty_text">Russian sites and the selected apps already go direct. A rule is only needed when some site takes the wrong way.</string>
<string name="rules_ideas">Often added</string>
<string name="rules_idea_chatgpt">does not let in Russian addresses</string>
<string name="rules_idea_mos">works only from Russia</string>
<string name="rules_idea_ru">every package starting with ru.</string>
<string name="rules_to_direct">Direct</string>
<string name="rules_to_vpn">Through VPN</string>
<string name="rules_add_custom">Your own rule</string>
<string name="rules_new">New rule</string>
<string name="rules_input_site">Domain, e.g. chatgpt.com</string>
<string name="rules_input_address">Address or network, e.g. 95.163.0.0/16</string>
<string name="rules_input_package">Package template, e.g. ru.sberbank*</string>
<string name="rules_invalid">This does not look right. Check the spelling.</string>
<string name="rules_site_note">The rule works by the site\'s addresses (and www.); they are refreshed on connect and every 30 minutes. Subdomains need their own rule.</string>
<string name="rules_where">Where to send it</string>
<string name="rules_direct_note">Bypasses the VPN, even if it is not in the RU base.</string>
<string name="rules_vpn_note">Through the VPN, even if it is in the RU base.</string>
<string name="rules_package_note">Matching apps go direct as a whole. * stands for any characters.</string>
<string name="rules_add_confirm">Add rule</string>
<string name="recent_title">Recent apps</string>
<string name="recent_subtitle">last hour · updates live</string>
<string name="recent_subtitle_no_access">Without usage access only apps with failing connections are shown.</string>
<string name="recent_filter_all">All · %1$d</string>
<string name="recent_filter_vpn">Through VPN</string>
<string name="recent_filter_direct">Direct</string>
<string name="recent_filter_failing">Failing · %1$d</string>
<string name="recent_empty">No apps used the network in this time.</string>
<string name="recent_group_vpn">Through the VPN</string>
<string name="recent_group_direct">Direct</string>
<string name="recent_note">The switch means «direct, without the VPN». Traffic per app comes from the system statistics.</string>
<string name="recent_fails">%1$d failed connections</string>
<string name="recent_failing_title">%1$s cannot connect</string>
<string name="recent_failing_text">%1$d failed connections to addresses that should work direct. Sending them through the VPN usually helps.</string>
<string name="recent_failing_action">Through VPN</string>
<string name="recent_failing_details">Details</string>
<string name="recent_access_title">See which apps use the network</string>
<string name="recent_access_text">And which way: through the VPN or direct. This makes it easy to find the app that does not work. It needs «Usage access».</string>
<string name="recent_access_privacy">We only see the amount of traffic per app. Content and sites stay on the phone.</string>
<string name="recent_access_allow">Allow access</string>
<string name="recent_access_later">Not now</string>
<string name="routing_section_exceptions">Exceptions</string>
<string name="routing_rules_sub">Sites, addresses and package templates</string>
<string name="routing_section_check">Check</string>
<string name="routing_check_row">How a site, IP or app goes</string>
<string name="routing_check_row_sub">Through the VPN or direct, and why</string>
<string name="routing_recent_sub">Who used the network and which way</string>
<string name="routing_split_vpn">Through VPN %1$d%%</string>
<string name="routing_split_direct">Direct %1$d%%</string>
<plurals name="rules_site_addresses">
<item quantity="one">%d address</item>
<item quantity="other">%d addresses</item>
</plurals>
<plurals name="routing_rules_n">
<item quantity="one">%d rule</item>
<item quantity="other">%d rules</item>
</plurals>
<plurals name="routing_recent_failing">
<item quantity="one">%d app with failures</item>
<item quantity="other">%d apps with failures</item>
</plurals>
<!-- Events: routing suggestions -->
<string name="event_new_app_title">New app: %1$s</string>
<string name="event_new_app_text">Looks like a Russian service. Let it go direct, bypassing the VPN?</string>
<string name="events_action_direct">Yes, direct</string>
<string name="events_action_no">No</string>
<!-- Home, pause, diagnostics, route check (mockup texts) -->
<string name="home_empty_add">Add a server</string>
<string name="home_empty_title">Add a server</string>
<string name="home_empty_status">To connect you need an .sswan profile</string>
<string name="home_empty_where_title">Where to get a profile?</string>
<string name="home_empty_where_text">The admin of your VPN sends it: as an .sswan file, a link or a QR code.</string>
<string name="home_connect_now">Connect now</string>
<string name="home_wl_status">Only Russian sites work: banks and Gosuslugi open. The VPN connects by itself.</string>
<string name="home_unreachable_status">%1$s is silent · next try in %2$d s</string>
<string name="home_error_status">%1$s · %2$s</string>
<string name="home_status_ru">%1$s · RU sites direct</string>
<string name="home_cancel_connect">Cancel connecting</string>
<string name="home_state_off">VPN is off</string>
<string name="home_state_off_status">Tap to connect</string>
<string name="home_server_ip_rtt">IP %1$s · %2$d ms</string>
<string name="home_server_ip">IP %1$s</string>
<string name="home_server_change">Change</string>
<string name="home_wl_what">What to do</string>
<string name="home_wl_wifi">Use Wi-Fi</string>
<string name="home_net_on">connected</string>
<string name="home_net_off">off</string>
<string name="home_net_waiting">waits for network</string>
<string name="home_fastest_server">Fastest server</string>
<string name="home_other_server">Connect to another server</string>
<string name="home_action_pause15">Pause 15 min</string>
<string name="home_action_fastest">Fastest</string>
<string name="home_fastest_searching">Searching…</string>
<string name="home_fastest_none">No server answered</string>
<string name="home_fastest_already">You are on the fastest server already</string>
<string name="home_traffic_session">this session</string>
<string name="home_traffic_split">%1$d%% through VPN · %2$d%% direct</string>
<string name="home_traffic_all_vpn">All traffic through the VPN</string>
<string name="home_cancel">Cancel</string>
<string name="home_lockdown_off">Always-on VPN is off</string>
<string name="home_lockdown_enable">Turn on</string>
<string name="home_check_row">Find out what is wrong</string>
<string name="home_check_row_sub">Network, server, DNS and whitelists in 10 seconds</string>
<string name="home_pause_morning">Until morning</string>
<string name="home_pause_hours">%1$d h</string>
<string name="home_pause_min">%1$d min</string>
<string name="home_pause_until">until %1$s</string>
<string name="home_pause_network">Until the network changes</string>
<string name="home_pause_network_sub">Turns on when you leave this Wi-Fi</string>
<string name="home_pause_go">Pause for %1$s</string>
<string name="home_pause_go_morning">Pause until morning</string>
<string name="home_pause_footer">You can cancel it from Home, the notification or the widget</string>
<string name="diag_title">Diagnostics</string>
<string name="diag_log">Log</string>
<string name="diag_step_internet">Internet</string>
<string name="diag_step_ru">Russian sites</string>
<string name="diag_step_foreign">Foreign sites</string>
<string name="diag_step_server">VPN server</string>
<string name="diag_step_dns">DNS</string>
<string name="diag_none">no network</string>
<string name="diag_ms">%1$d ms</string>
<string name="diag_ok">work</string>
<string name="diag_fail">do not open</string>
<string name="diag_unknown">no data</string>
<string name="diag_whitelist">whitelists</string>
<string name="diag_via_vpn_ms">via VPN · %1$d ms</string>
<string name="diag_direct_ms">direct · %1$d ms</string>
<string name="diag_no_server">not chosen</string>
<string name="diag_no_answer">no answer</string>
<string name="diag_dns_tunnel">tunnel</string>
<string name="diag_dns_provider">provider</string>
<string name="diag_checking">checking…</string>
<string name="diag_not_opening">Something does not open?</string>
<string name="diag_site_hint">or a site</string>
<string name="diag_dead_meta">%1$d failures · pause %2$s</string>
<string name="diag_send_report">Send a report</string>
<string name="diag_verdict_ok">The VPN works fine</string>
<string name="diag_verdict_ok_no_vpn">The network works, the VPN is off</string>
<string name="diag_verdict_problem">Problem: %1$s</string>
<string name="diag_checking_title">Checking…</string>
<string name="diag_checking_sub">Network, server, DNS and whitelists</string>
<string name="diag_checked_at">Checked %1$s · tap to repeat</string>
<string name="diag_ago_sec">%1$d s ago</string>
<string name="diag_ago_min">%1$d min ago</string>
<string name="route_check_opens_ms">opens · %1$d ms</string>
<string name="route_check_not_opens">does not open</string>
<string name="route_check_node_phone">Phone</string>
<string name="route_check_node_provider">Provider</string>
<string name="route_check_node_vpn">VPN</string>
<string name="route_check_why">Why:</string>
<string name="route_check_compare">Direct path for comparison</string>
<string name="route_check_dns_differ_title">The answers differ.</string>
<string name="routing_ru_title">RU sites direct</string>
<string name="routing_db_short">Base: %1$s networks · %2$s</string>
<string name="routing_mode_stable_short">New addresses after a reconnect</string>
<string name="routing_mode_flexible_short">On the fly, without a reconnect</string>
<string name="routing_apps_title">Apps direct</string>
<string name="routing_apps_none">Banks and RU services often do not work through a VPN</string>
<plurals name="routing_apps_direct_n">
<item quantity="one">%d works without the VPN</item>
<item quantity="other">%d work without the VPN</item>
</plurals>
<plurals name="routing_recent_failing_short">
<item quantity="one">%d with errors</item>
<item quantity="other">%d with errors</item>
</plurals>
<string name="rules_tab_site">Site</string>
<string name="rules_tab_address">Address / network</string>
<string name="rules_tab_package">Package</string>
<string name="rules_badge_direct">direct</string>
<string name="rules_badge_vpn">via VPN</string>
<string name="rules_direct_short">Bypassing the VPN</string>
<string name="rules_vpn_short">Even inside the RU base</string>
<string name="rules_vpn_short_site">Even if the site is in the RU base</string>
<string name="recent_route_direct">direct</string>
<string name="recent_route_vpn">VPN</string>
<string name="events_read_all">Read all</string>
</resources>
@@ -0,0 +1,104 @@
/*
* 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.routing
import org.assertj.core.api.Assertions.assertThat
import org.junit.Test
import org.strongswan.android.ui.compose.DeadTargetUi
import org.strongswan.android.ui.compose.routing.RuleKind
import org.strongswan.android.ui.compose.routing.buildRecentApps
import org.strongswan.android.ui.compose.routing.buildRuleRows
import org.strongswan.android.utils.IPRange
import java.net.InetAddress
class RoutingRulesTest
{
@Test
fun normalizesDomains()
{
assertThat(RoutingManager.normalizeDomain("https://www.Kinopoisk.ru/film/1")).isEqualTo("kinopoisk.ru")
assertThat(RoutingManager.normalizeDomain("*.mos.ru")).isEqualTo("mos.ru")
assertThat(RoutingManager.normalizeDomain("chatgpt.com.")).isEqualTo("chatgpt.com")
assertThat(RoutingManager.normalizeDomain("localhost")).isNull()
assertThat(RoutingManager.normalizeDomain("95.163.0.12")).isNull()
assertThat(RoutingManager.normalizeDomain("bad_name.ru")).isNull()
}
private val ru = listOf(IPRange("95.163.0.0/16"))
private fun addr(ip: String) = InetAddress.getByName(ip)
@Test
fun rulesWinOverTheRuBase()
{
val force = listOf(IPRange("95.163.1.1/32"))
val direct = listOf(IPRange("142.250.0.0/16"))
fun verdict(ip: String, active: Boolean = true) =
RouteCheck.verdict(addr(ip), ru, emptyList(), emptySet(), force, direct, active)
assertThat(verdict("95.163.2.2")).isEqualTo(RouteCheck.Verdict(true, RouteCheck.Reason.RU_LIST))
assertThat(verdict("95.163.1.1")).isEqualTo(RouteCheck.Verdict(false, RouteCheck.Reason.RULE_TUNNEL))
assertThat(verdict("142.250.3.4")).isEqualTo(RouteCheck.Verdict(true, RouteCheck.Reason.RULE_DIRECT))
assertThat(verdict("8.8.8.8")).isEqualTo(RouteCheck.Verdict(false, RouteCheck.Reason.DEFAULT))
/* without the userspace engine the rules do nothing */
assertThat(verdict("95.163.1.1", active = false)).isEqualTo(RouteCheck.Verdict(true, RouteCheck.Reason.RU_LIST))
assertThat(verdict("142.250.3.4", active = false)).isEqualTo(RouteCheck.Verdict(false, RouteCheck.Reason.DEFAULT))
}
@Test
fun forceWinsOverDirect()
{
val both = listOf(IPRange("1.2.3.4/32"))
assertThat(RouteCheck.verdict(addr("1.2.3.4"), ru, emptyList(), emptySet(), both, both, true).reason)
.isEqualTo(RouteCheck.Reason.RULE_TUNNEL)
}
@Test
fun ruleRowsGroupVpnFirst()
{
val config = RoutingConfig(
packagePatterns = setOf("ru.*"),
forceTunnelPrefixes = listOf(ForceTunnelPrefix("f", "1.1.1.1/32")),
directPrefixes = listOf(ForceTunnelPrefix("d", "2.2.2.0/24", enabled = false)),
siteRules = listOf(SiteRule("s", "chatgpt.com", direct = false), SiteRule("m", "mos.ru", direct = true)),
)
val rows = buildRuleRows(config, mapOf("chatgpt.com" to listOf("1.2.3.4/32", "1.2.3.5/32")))
assertThat(rows.map { it.value }).containsExactly("chatgpt.com", "1.1.1.1/32", "mos.ru", "2.2.2.0/24", "ru.*")
assertThat(rows.map { it.direct }).containsExactly(false, false, true, true, true)
assertThat(rows.first { it.value == "chatgpt.com" }.addresses).isEqualTo(2)
assertThat(rows.first { it.value == "mos.ru" }.addresses).isEqualTo(-1)
assertThat(rows.first { it.kind == RuleKind.PACKAGE }.enabled).isTrue()
assertThat(rows.first { it.value == "2.2.2.0/24" }.enabled).isFalse()
}
@Test
fun recentAppsPutFailuresFirst()
{
val dead = listOf(
DeadTargetUi("95.163.1.1:443", fails = 7, windowMs = 0, leftMs = 0, owner = "ru.bank"),
DeadTargetUi("95.163.1.2:443", fails = 3, windowMs = 0, leftMs = 0, owner = "ru.bank"),
DeadTargetUi("95.163.1.3:443", fails = 9, windowMs = 0, leftMs = 0, owner = "ru.shop", viaTunnel = true),
)
val apps = buildRecentApps(
traffic = mapOf("com.video" to 5_000_000L, "ru.bank" to 10L, "gone.app" to 1L),
excluded = setOf("ru.bank"),
dead = dead,
label = { if (it == "gone.app") null else it },
)
assertThat(apps.map { it.packageName }).containsExactly("ru.bank", "com.video")
assertThat(apps[0].fails).isEqualTo(10)
assertThat(apps[0].direct).isTrue()
assertThat(apps[1].fails).isZero()
}
}