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 <[email protected]>
This commit is contained in:
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" >
|
||||
|
||||
+23
@@ -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)
|
||||
|
||||
+102
-2
@@ -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))
|
||||
|
||||
+18
-4
@@ -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)
|
||||
{
|
||||
|
||||
+114
@@ -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()
|
||||
}
|
||||
}
|
||||
+57
@@ -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(
|
||||
|
||||
+248
-7
@@ -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)
|
||||
|
||||
+52
@@ -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)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+12
-13
@@ -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))
|
||||
|
||||
+21
-9
@@ -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)
|
||||
}
|
||||
}
|
||||
+407
@@ -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)
|
||||
}
|
||||
+81
-236
@@ -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()
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+94
-4
@@ -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> =
|
||||
|
||||
+140
-109
@@ -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
|
||||
}
|
||||
+679
-115
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+159
-179
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
+9
-12
@@ -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,
|
||||
)
|
||||
|
||||
+42
-25
@@ -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)
|
||||
|
||||
+51
-70
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
-8
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
/*
|
||||
* Copyright (C) 2026 SHX VPN
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or modify it
|
||||
* under the terms of the GNU General Public License as published by the
|
||||
* Free Software Foundation; either version 2 of the License, or (at your
|
||||
* option) any later version.
|
||||
*/
|
||||
|
||||
package org.strongswan.android.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)
|
||||
}
|
||||
+618
@@ -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))
|
||||
}
|
||||
+390
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
+418
@@ -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()
|
||||
}
|
||||
}
|
||||
+89
-327
@@ -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
|
||||
}
|
||||
-537
@@ -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))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
+123
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+392
@@ -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)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
+5
-1
@@ -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)
|
||||
|
||||
+309
@@ -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)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+480
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+280
@@ -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)
|
||||
}
|
||||
+65
-57
@@ -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(),
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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>
|
||||
+104
@@ -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()
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user