From 4ff5ed11bb1c6b424d06c3f482c54c462c60d857 Mon Sep 17 00:00:00 2001 From: Denozordec Date: Sun, 11 Oct 2026 22:46:27 +0700 Subject: [PATCH] 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 --- .../android/app/src/main/AndroidManifest.xml | 8 + .../android/logic/CharonVpnService.java | 23 + .../strongswan/android/logic/PauseManager.kt | 104 ++- .../android/logic/events/EventLog.kt | 22 +- .../strongswan/android/logic/events/Events.kt | 10 + .../android/logic/routing/AppTraffic.kt | 114 +++ .../android/logic/routing/RouteCheck.kt | 57 ++ .../android/logic/routing/RoutingManager.kt | 255 +++++- .../android/ui/DiagnosticsActivity.kt | 52 ++ .../strongswan/android/ui/InboxActivity.kt | 25 +- .../android/ui/RouteCheckActivity.kt | 30 +- .../android/ui/compose/DiagnosticsScreen.kt | 407 +++++++++ .../android/ui/compose/HomeScreen.kt | 317 ++----- .../android/ui/compose/HomeViewModel.kt | 98 ++- .../android/ui/compose/InboxScreen.kt | 249 +++--- .../android/ui/compose/RouteCheckScreen.kt | 794 +++++++++++++++--- .../android/ui/compose/RoutingScreen.kt | 338 ++++---- .../android/ui/compose/SecondaryScaffold.kt | 21 +- .../strongswan/android/ui/compose/ShxApp.kt | 67 +- .../ui/compose/components/ShxComponents.kt | 121 ++- .../ui/compose/components/ShxSectionCard.kt | 19 +- .../android/ui/compose/design/ShxIcons.kt | 66 ++ .../android/ui/compose/design/ShxKit.kt | 618 ++++++++++++++ .../android/ui/compose/home/HomeContent.kt | 390 +++++++++ .../android/ui/compose/home/HomeParts.kt | 418 +++++++++ .../android/ui/compose/home/HomeStatus.kt | 416 ++------- .../ui/compose/routing/AdvancedRouting.kt | 537 ------------ .../ui/compose/routing/DeadHostsPage.kt | 123 +++ .../ui/compose/routing/RecentAppsScreen.kt | 392 +++++++++ .../android/ui/compose/routing/RoutingHero.kt | 6 +- .../android/ui/compose/routing/RoutingHub.kt | 309 +++++++ .../android/ui/compose/routing/RulesScreen.kt | 480 +++++++++++ .../android/ui/compose/theme/ShxDesign.kt | 280 ++++++ .../android/ui/compose/theme/ShxTheme.kt | 122 +-- .../app/src/main/res/font/manrope_bold.ttf | Bin 0 -> 94928 bytes .../src/main/res/font/manrope_extrabold.ttf | Bin 0 -> 95688 bytes .../app/src/main/res/font/manrope_medium.ttf | Bin 0 -> 95060 bytes .../app/src/main/res/font/manrope_regular.ttf | Bin 0 -> 94972 bytes .../src/main/res/font/manrope_semibold.ttf | Bin 0 -> 95096 bytes .../app/src/main/res/values-ru/strings.xml | 260 +++++- .../app/src/main/res/values/strings.xml | 250 +++++- .../android/logic/routing/RoutingRulesTest.kt | 104 +++ 42 files changed, 6161 insertions(+), 1741 deletions(-) create mode 100644 src/frontends/android/app/src/main/java/org/strongswan/android/logic/routing/AppTraffic.kt create mode 100644 src/frontends/android/app/src/main/java/org/strongswan/android/ui/DiagnosticsActivity.kt create mode 100644 src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/DiagnosticsScreen.kt create mode 100644 src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/design/ShxIcons.kt create mode 100644 src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/design/ShxKit.kt create mode 100644 src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/home/HomeContent.kt create mode 100644 src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/home/HomeParts.kt delete mode 100644 src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/routing/AdvancedRouting.kt create mode 100644 src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/routing/DeadHostsPage.kt create mode 100644 src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/routing/RecentAppsScreen.kt create mode 100644 src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/routing/RoutingHub.kt create mode 100644 src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/routing/RulesScreen.kt create mode 100644 src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/theme/ShxDesign.kt create mode 100644 src/frontends/android/app/src/main/res/font/manrope_bold.ttf create mode 100644 src/frontends/android/app/src/main/res/font/manrope_extrabold.ttf create mode 100644 src/frontends/android/app/src/main/res/font/manrope_medium.ttf create mode 100644 src/frontends/android/app/src/main/res/font/manrope_regular.ttf create mode 100644 src/frontends/android/app/src/main/res/font/manrope_semibold.ttf create mode 100644 src/frontends/android/app/src/test/java/org/strongswan/android/logic/routing/RoutingRulesTest.kt diff --git a/src/frontends/android/app/src/main/AndroidManifest.xml b/src/frontends/android/app/src/main/AndroidManifest.xml index 367097696..2d1804056 100644 --- a/src/frontends/android/app/src/main/AndroidManifest.xml +++ b/src/frontends/android/app/src/main/AndroidManifest.xml @@ -30,6 +30,10 @@ + + @@ -133,6 +137,10 @@ android:name=".ui.InboxActivity" android:label="@string/events_title"> + + diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/logic/CharonVpnService.java b/src/frontends/android/app/src/main/java/org/strongswan/android/logic/CharonVpnService.java index e199e2e83..251c0aceb 100644 --- a/src/frontends/android/app/src/main/java/org/strongswan/android/logic/CharonVpnService.java +++ b/src/frontends/android/app/src/main/java/org/strongswan/android/logic/CharonVpnService.java @@ -1170,6 +1170,29 @@ public class CharonVpnService extends VpnService implements Runnable, VpnStateSe RuIpListManager list = RuIpListManager.getInstance(context); List v4 = enabled ? list.getExcludedV4() : Collections.emptyList(); List v6 = enabled ? list.getExcludedV6() : Collections.emptyList(); + if (enabled) + { /* user rules: addresses and resolved sites that always go direct */ + List direct = org.strongswan.android.logic.routing.RoutingManager.getInstance(context).directCidrs(); + if (!direct.isEmpty()) + { + ArrayList d4 = new ArrayList<>(v4); + ArrayList 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 force = new ArrayList<>(); if (enabled) diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/logic/PauseManager.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/logic/PauseManager.kt index 471b06c7f..905ba4463 100644 --- a/src/frontends/android/app/src/main/java/org/strongswan/android/logic/PauseManager.kt +++ b/src/frontends/android/app/src/main/java/org/strongswan/android/logic/PauseManager.kt @@ -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)) diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/logic/events/EventLog.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/logic/events/EventLog.kt index 6587511a6..905136d85 100644 --- a/src/frontends/android/app/src/main/java/org/strongswan/android/logic/events/EventLog.kt +++ b/src/frontends/android/app/src/main/java/org/strongswan/android/logic/events/EventLog.kt @@ -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) diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/logic/events/Events.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/logic/events/Events.kt index c691c4447..b323fc17e 100644 --- a/src/frontends/android/app/src/main/java/org/strongswan/android/logic/events/Events.kt +++ b/src/frontends/android/app/src/main/java/org/strongswan/android/logic/events/Events.kt @@ -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) { diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/logic/routing/AppTraffic.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/logic/routing/AppTraffic.kt new file mode 100644 index 000000000..a29b6e170 --- /dev/null +++ b/src/frontends/android/app/src/main/java/org/strongswan/android/logic/routing/AppTraffic.kt @@ -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 . + * + * 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 + { + if (!hasAccess(context)) + { + return emptyMap() + } + val manager = context.getSystemService(Context.NETWORK_STATS_SERVICE) as? NetworkStatsManager ?: return emptyMap() + val now = System.currentTimeMillis() + val perUid = HashMap() + @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() + 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) + { + val bucket = NetworkStats.Bucket() + while (stats.hasNextBucket()) + { + stats.getNextBucket(bucket) + into[bucket.uid] = (into[bucket.uid] ?: 0L) + bucket.rxBytes + bucket.txBytes + } + } + + private inline fun NetworkStats.use(block: (NetworkStats) -> R): R = try + { + block(this) + } + finally + { + close() + } +} diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/logic/routing/RouteCheck.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/logic/routing/RouteCheck.kt index 9aa1aa42d..c728d83f3 100644 --- a/src/frontends/android/app/src/main/java/org/strongswan/android/logic/routing/RouteCheck.kt +++ b/src/frontends/android/app/src/main/java/org/strongswan/android/logic/routing/RouteCheck.kt @@ -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, + exclusionsV6: List, + dnsPunchBlocks: Set, + forceRanges: List, + directRanges: List, + 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): List = cidrs.mapNotNull { runCatching { IPRange(it) }.getOrNull() } + /** Route verdict for one address under the applied exclusion set. */ @JvmStatic fun classify( diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/logic/routing/RoutingManager.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/logic/routing/RoutingManager.kt index 7b8945711..897d1c7a4 100644 --- a/src/frontends/android/app/src/main/java/org/strongswan/android/logic/routing/RoutingManager.kt +++ b/src/frontends/android/app/src/main/java/org/strongswan/android/logic/routing/RoutingManager.kt @@ -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 = emptySet(), /** User package patterns, see [PackagePatterns]. */ val packagePatterns: Set = emptySet(), + /** Patterns the user switched off without deleting them. */ + val disabledPatterns: Set = emptySet(), val forceTunnelPrefixes: List = emptyList(), + /** User addresses that always go direct (same shape as the force-tunnel entries). */ + val directPrefixes: List = emptyList(), + /** Per-site rules; the engine routes by address, so sites are resolved, see [SiteRule]. */ + val siteRules: List = 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 = _state + /* resolved addresses of the site rules, domain -> CIDRs; read by the push on any thread */ + @Volatile + private var siteAddresses: Map> = emptyMap() + private val _siteAddresses = MutableStateFlow>>(emptyMap()) + /** Resolved addresses per site rule domain, for the Rules screen. */ + val resolvedSites: StateFlow>> = _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 = - 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 = + config.directPrefixes.filter { it.enabled }.map { it.prefix } + siteCidrs(direct = true) + + private fun siteCidrs(direct: Boolean): List + { + 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 = - obj.optJSONArray("forceTunnelPrefixes")?.let { arr -> + private fun parseForcePrefixes(obj: JSONObject, key: String = "forceTunnelPrefixes"): List = + 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 = + 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 = 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() 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) diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/DiagnosticsActivity.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/DiagnosticsActivity.kt new file mode 100644 index 000000000..b39aed357 --- /dev/null +++ b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/DiagnosticsActivity.kt @@ -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 . + * + * 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)) }, + ) + } + } + } + } +} diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/InboxActivity.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/InboxActivity.kt index f8ce5e4d4..e5e766909 100644 --- a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/InboxActivity.kt +++ b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/InboxActivity.kt @@ -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)) diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/RouteCheckActivity.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/RouteCheckActivity.kt index c53af2863..bf50623ce 100644 --- a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/RouteCheckActivity.kt +++ b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/RouteCheckActivity.kt @@ -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) + } } diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/DiagnosticsScreen.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/DiagnosticsScreen.kt new file mode 100644 index 000000000..02e0ae882 --- /dev/null +++ b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/DiagnosticsScreen.kt @@ -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, 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(null, round) { + value = null + value = runDiagnostics(context) + } + val lifecycle = LocalLifecycleOwner.current.lifecycle + val dead by produceState(emptyList(), 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()) { + 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(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) +} diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/HomeScreen.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/HomeScreen.kt index 6209228f1..d14fe7d1b 100644 --- a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/HomeScreen.kt +++ b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/HomeScreen.kt @@ -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(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() - val spatial = MaterialTheme.motionScheme.defaultSpatialSpec() - - 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() + }, ) } diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/HomeViewModel.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/HomeViewModel.kt index 610407329..d1d07eba7 100644 --- a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/HomeViewModel.kt +++ b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/HomeViewModel.kt @@ -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 = _networkAvailable - private val underlyingNetworks = HashSet() + /* (Wi-Fi up, mobile data up) for the "no internet" checklist */ + private val _networkKinds = MutableStateFlow(false to false) + val networkKinds: StateFlow> = _networkKinds + private val underlyingNetworks = HashMap() 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 = _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?): List = diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/InboxScreen.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/InboxScreen.kt index 050c86ceb..cd7aad94f 100644 --- a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/InboxScreen.kt +++ b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/InboxScreen.kt @@ -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 } diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/RouteCheckScreen.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/RouteCheckScreen.kt index 631f6c539..87dcaa3d9 100644 --- a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/RouteCheckScreen.kt +++ b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/RouteCheckScreen.kt @@ -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, 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(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(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>(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 +{ + 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) +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(), 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) +{ + 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, 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)) + } + } + } } diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/RoutingScreen.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/RoutingScreen.kt index 2e951d515..4185245ce 100644 --- a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/RoutingScreen.kt +++ b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/RoutingScreen.kt @@ -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(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(), 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(), lifecycle) { @@ -186,6 +209,43 @@ private fun RoutingPageContent( } } } + val split by produceState?>(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(), 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, - forceCount: Int, - patternCount: Int, + ruleCount: Int, + directRuleCount: Int, deadCount: Int, + split: Pair?, + recent: List, 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, - ) } } diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/SecondaryScaffold.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/SecondaryScaffold.kt index 9ac2c07c3..36515a202 100644 --- a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/SecondaryScaffold.kt +++ b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/SecondaryScaffold.kt @@ -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, ) diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/ShxApp.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/ShxApp.kt index b48de0386..b18a245ce 100644 --- a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/ShxApp.kt +++ b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/ShxApp.kt @@ -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) diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/components/ShxComponents.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/components/ShxComponents.kt index 27becdf7c..7810991a6 100644 --- a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/components/ShxComponents.kt +++ b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/components/ShxComponents.kt @@ -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) } } } diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/components/ShxSectionCard.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/components/ShxSectionCard.kt index fbe4dbe8a..02cfc6224 100644 --- a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/components/ShxSectionCard.kt +++ b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/components/ShxSectionCard.kt @@ -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() } } diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/design/ShxIcons.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/design/ShxIcons.kt new file mode 100644 index 000000000..ffdffef04 --- /dev/null +++ b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/design/ShxIcons.kt @@ -0,0 +1,66 @@ +/* + * Copyright (C) 2026 SHX VPN + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the + * Free Software Foundation; either version 2 of the License, or (at your + * option) any later version. + */ + +package org.strongswan.android.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) +} diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/design/ShxKit.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/design/ShxKit.kt new file mode 100644 index 000000000..935cde663 --- /dev/null +++ b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/design/ShxKit.kt @@ -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, 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)) +} diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/home/HomeContent.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/home/HomeContent.kt new file mode 100644 index 000000000..55064544a --- /dev/null +++ b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/home/HomeContent.kt @@ -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, + 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, + ) + } +} diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/home/HomeParts.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/home/HomeParts.kt new file mode 100644 index 000000000..d58948a2d --- /dev/null +++ b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/home/HomeParts.kt @@ -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 Unit>? = null, + secondary: Pair 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() + } +} diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/home/HomeStatus.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/home/HomeStatus.kt index 2c91dcc97..e136e5097 100644 --- a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/home/HomeStatus.kt +++ b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/home/HomeStatus.kt @@ -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 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 -} diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/routing/AdvancedRouting.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/routing/AdvancedRouting.kt deleted file mode 100644 index 016b96f07..000000000 --- a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/routing/AdvancedRouting.kt +++ /dev/null @@ -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 . - * - * 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, - 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, - 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, 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)) - } - }, - ) -} diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/routing/DeadHostsPage.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/routing/DeadHostsPage.kt new file mode 100644 index 000000000..338d17888 --- /dev/null +++ b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/routing/DeadHostsPage.kt @@ -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 . + * + * 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, 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)) + } + } + } + } + } + } +} diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/routing/RecentAppsScreen.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/routing/RecentAppsScreen.kt new file mode 100644 index 000000000..8025703cd --- /dev/null +++ b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/routing/RecentAppsScreen.kt @@ -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, + excluded: Set, + dead: List, + label: (String) -> String?, +): List +{ + val fails = HashMap() + 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 { it.fails > 0 }.thenByDescending { it.bytes }) +} + +/** «Recent apps» (F_Recent / F_RecentEmpty). */ +@Composable +internal fun RecentAppsPage(deadTargets: List, 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(), 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(), 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) + { + } + } +} diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/routing/RoutingHero.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/routing/RoutingHero.kt index 1c5779471..ef9e0dd6d 100644 --- a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/routing/RoutingHero.kt +++ b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/routing/RoutingHero.kt @@ -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) diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/routing/RoutingHub.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/routing/RoutingHub.kt new file mode 100644 index 000000000..9c4e5d74a --- /dev/null +++ b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/routing/RoutingHub.kt @@ -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?, 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, 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, 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)) } + } + } + } + } +} + diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/routing/RulesScreen.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/routing/RulesScreen.kt new file mode 100644 index 000000000..485c143db --- /dev/null +++ b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/routing/RulesScreen.kt @@ -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>): List = 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 { 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(null) } + var adding by remember { mutableStateOf(false) } + var actionsFor by remember { mutableStateOf(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), + ) + } + } +} diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/theme/ShxDesign.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/theme/ShxDesign.kt new file mode 100644 index 000000000..d48b40dcb --- /dev/null +++ b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/theme/ShxDesign.kt @@ -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) +} diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/theme/ShxTheme.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/theme/ShxTheme.kt index 5d5f060f9..1a788b193 100644 --- a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/theme/ShxTheme.kt +++ b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/theme/ShxTheme.kt @@ -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(), diff --git a/src/frontends/android/app/src/main/res/font/manrope_bold.ttf b/src/frontends/android/app/src/main/res/font/manrope_bold.ttf new file mode 100644 index 0000000000000000000000000000000000000000..746764de030c95266dd33fa1f440795ed80bbc90 GIT binary patch literal 94928 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 RxaxUe6CQz8oW<11006QG`YZqd literal 0 HcmV?d00001 diff --git a/src/frontends/android/app/src/main/res/font/manrope_extrabold.ttf b/src/frontends/android/app/src/main/res/font/manrope_extrabold.ttf new file mode 100644 index 0000000000000000000000000000000000000000..d324b62f053c0aa0061d35298d14cdbeb6274847 GIT binary patch literal 95688 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 z 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 Tsyf^)S`c*%7yeu4CpG^cmn!zY literal 0 HcmV?d00001 diff --git a/src/frontends/android/app/src/main/res/font/manrope_medium.ttf b/src/frontends/android/app/src/main/res/font/manrope_medium.ttf new file mode 100644 index 0000000000000000000000000000000000000000..2a169e6b902fc2d8fe470d0da04162de933de134 GIT binary patch literal 95060 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 literal 0 HcmV?d00001 diff --git a/src/frontends/android/app/src/main/res/font/manrope_regular.ttf b/src/frontends/android/app/src/main/res/font/manrope_regular.ttf new file mode 100644 index 0000000000000000000000000000000000000000..8594569243efb72d1c156a8d8acb9b5f4eb28e4a GIT binary patch literal 94972 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 JXEAj$000(zNTC1# literal 0 HcmV?d00001 diff --git a/src/frontends/android/app/src/main/res/font/manrope_semibold.ttf b/src/frontends/android/app/src/main/res/font/manrope_semibold.ttf new file mode 100644 index 0000000000000000000000000000000000000000..595e7dc699c8e2a30e765691fe70174bb388ce73 GIT binary patch literal 95096 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 Xp>5SoMg@fGC14YdgH@cx)X4wНапрямую (вне туннеля) Зарубежные хосты (напрямую) Яндекс (напрямую) - Маршрут - Маршрутизация + Маршруты + Маршруты Сайты и сервисы РФ Всегда через VPN Адреса и подсети, которые всегда идут через туннель, даже если попадают в RU-диапазоны (режим userspace) @@ -296,17 +296,17 @@ Обновление… Список обновлён: %1$d диапазонов Не удалось обновить, используется сохранённая версия - Проверка маршрута - Введите домен, чтобы увидеть, какие адреса он получает через DNS туннеля и через DNS прямой сети, и какой путь проходит каждый адрес при текущих исключениях. - домен, например inet.ya.ru + Как ходит трафик + Проверьте, каким путём идёт сайт, адрес или приложение — через VPN или напрямую, и почему. + Сайт, например gosuslugi.ru Проверить Проверяю… Туннель не подключён — просмотр маршрута недоступен - Через DNS туннеля - Через DNS прямой сети + Через туннель + Через провайдера Маршруты - через туннель - напрямую (RU-исключение) + через VPN + напрямую напрямую (DNS-сервер Персонального DNS) Не удалось разрешить домен ни на одном пути нет @@ -412,8 +412,8 @@ Connect VPN Disconnect VPN - Подключение - Профили + Главная + Серверы Настройки Не защищено Защищено @@ -547,7 +547,7 @@ Сменить сервер Не открывается? Пауза VPN - VPN выключится и включится сам. Автоподключение подождёт, пока пауза не закончится. + Весь трафик пойдёт напрямую. VPN включится сам. %d минута %d минуты @@ -558,7 +558,7 @@ VPN включится сам в %1$s Включить сейчас +15 минут - Пока VPN на паузе, трафик идёт без него. + Весь трафик сейчас идёт напрямую, без защиты. Банки и РФ-сервисы работают как обычно. Нет интернета Проблема не в VPN. Подключимся сами, как только появится сеть У телефона нет сети @@ -627,4 +627,238 @@ VPN снова включён. Доступна версия %1$s Скачайте и установите её из приложения. + + Адрес, например 95.163.0.12 + Название приложения + Сайт + IP / сеть + Приложение + Это не IP-адрес. + Среди приложений с доступом в сеть такого нет. + Итог + Через VPN + Напрямую + Частично напрямую + Адреса · %1$d + Похоже, провайдер подменяет DNS — поэтому используем DNS туннеля. + Изменить для %1$s + Всегда напрямую + Всегда через VPN + Приложение маршрутизируется целиком: «напрямую» — весь его трафик мимо VPN. + DNS-сервер туннеля — всегда через него. + Ваше правило отправляет его через VPN. + Ваше правило пускает его напрямую. + Адрес есть в базе РФ — поэтому напрямую. + Адреса нет в базе РФ и своих правил нет — значит, по умолчанию через VPN. + Раздельный туннель выключен: все приложения идут через VPN. + Вы оставили его в VPN, хотя шаблон пустил бы напрямую. + Вы добавили его в «Приложения напрямую». + Напрямую по шаблону «%1$s». + Не в списке «напрямую» — через VPN. + Правила для сайтов и адресов работают, когда «Сайты РФ напрямую» включены в режиме «Гибкий». + + Правила + Куда идут сайты, адреса и приложения. Правило важнее базы РФ. + Все · %1$d + Сайты + Адреса + Пакеты + Через VPN + Напрямую + Правило + Сайт + Адрес + Пакет + адреса ещё не определены + всегда напрямую + всегда через VPN, даже если в базе РФ + приложения с таким пакетом — напрямую + Удалить правило + Правил пока нет — и это нормально + Сайты РФ и выбранные приложения уже идут напрямую. Правило нужно, только если какой-то сайт идёт не тем путём. + Часто добавляют + не пускает с российских адресов + работает только из России + все пакеты, начинающиеся с ru. + Напрямую + Через VPN + Своё правило + Новое правило + Домен, например chatgpt.com + Адрес или сеть, например 95.163.0.0/16 + Шаблон пакета, например ru.sberbank* + Похоже на ошибку. Проверьте написание. + Правило работает по адресам сайта (и www.); они обновляются при подключении и раз в 30 минут. Для поддоменов нужно отдельное правило. + Куда направлять + Мимо VPN, даже если адреса нет в базе РФ. + Через VPN, даже если адрес в базе РФ. + Подходящие приложения идут напрямую целиком. * — любые символы. + Добавить правило + Недавние приложения + за последний час · обновляется вживую + Без доступа к статистике видны только приложения со сбоями соединений. + Все · %1$d + Через VPN + Напрямую + Со сбоями · %1$d + За это время в сеть никто не выходил. + Через VPN + Напрямую + Переключатель — «напрямую, без VPN». Трафик приложений считается по системной статистике. + неудачных соединений: %1$d + %1$s не открывается + Неудачных соединений: %1$d — к адресам, которые должны работать напрямую. Обычно помогает пустить их через VPN. + Через VPN + Подробнее + Покажем, какие приложения выходят в сеть + И каким путём — через VPN или напрямую. Так проще найти приложение, которое не работает. Для этого нужен доступ «Статистика использования». + Мы видим только объём трафика по приложениям. Содержимое и сайты остаются на телефоне. + Разрешить доступ + Не сейчас + Исключения + Сайты, адреса и шаблоны пакетов + Проверка + Как ходит сайт, IP или приложение + Через VPN или напрямую — и почему + Кто выходил в сеть и каким путём + Через VPN %1$d%% + Напрямую %1$d%% + + %d адрес + %d адреса + %d адресов + %d адреса + + + %d правило + %d правила + %d правил + %d правила + + + %d приложение со сбоями + %d приложения со сбоями + %d приложений со сбоями + %d приложения со сбоями + + + Новое приложение: %1$s + Похоже на российский сервис. Пустить напрямую, мимо VPN? + Да, напрямую + Нет + + Добавить сервер + Добавьте сервер + Чтобы подключиться, нужен профиль .sswan + Где взять профиль? + Его присылает администратор вашего VPN — файлом .sswan, ссылкой или QR-кодом. + Подключить сейчас + Работают только российские сайты — банки и Госуслуги открываются. VPN подключится сам. + %1$s молчит · снова пробуем через %2$d с + %1$s · %2$s + %1$s · РФ-сайты напрямую + Отменить подключение + VPN выключен + Нажмите, чтобы подключиться + IP %1$s · %2$d мс + IP %1$s + Сменить + Что делать + Через Wi-Fi + подключено + выключено + ждёт сеть + Быстрейший сервер + Подключиться к другому серверу + Пауза 15 мин + Быстрейший + Ищем… + Ни один сервер не ответил + Вы уже на самом быстром сервере + за сессию + %1$d%% через VPN · %2$d%% напрямую + Весь трафик через VPN + Отменить + Постоянный VPN выключен + Включить + Проверить, в чём дело + Сеть, сервер, DNS и белые списки за 10 секунд + До утра + %1$d час + %1$d мин + до %1$s + До смены сети + Включится, когда уйдёте с этого Wi-Fi + Поставить на паузу %1$s + Поставить на паузу до утра + Отменить можно с главной, из шторки или виджета + Диагностика + Журнал + Интернет + Российские сайты + Зарубежные сайты + Сервер VPN + DNS + нет сети + %1$d мс + работают + не открываются + нет данных + белые списки + через VPN · %1$d мс + напрямую · %1$d мс + не выбран + не отвечает + туннель + провайдер + проверяем… + Что-то не открывается? + или сайт + отказов %1$d · пауза %2$s + Отправить отчёт + VPN работает нормально + Сеть работает, VPN выключен + Проблема: %1$s + Проверяем… + Сеть, сервер, DNS и белые списки + Проверено %1$s · нажмите, чтобы повторить + %1$d с назад + %1$d мин назад + открывается · %1$d мс + не открывается + Телефон + Провайдер + VPN + Почему: + Прямой путь для сравнения + Ответы различаются. + Сайты РФ напрямую + База: %1$s сетей · %2$s + Новые адреса — после переподключения + На лету, без переподключения + Приложения напрямую + Банки и сервисы РФ часто не работают через VPN + + %d работает без VPN + %d работают без VPN + %d работают без VPN + %d работают без VPN + + + %d с ошибками + %d с ошибками + %d с ошибками + %d с ошибками + + Сайт + Адрес / сеть + Пакет + напрямую + через VPN + Мимо VPN + Даже если адрес в базе РФ + Даже если сайт в базе РФ + напрямую + VPN + Прочитать все diff --git a/src/frontends/android/app/src/main/res/values/strings.xml b/src/frontends/android/app/src/main/res/values/strings.xml index 446e74b57..bf6659a00 100644 --- a/src/frontends/android/app/src/main/res/values/strings.xml +++ b/src/frontends/android/app/src/main/res/values/strings.xml @@ -210,8 +210,8 @@ Direct (outside the tunnel) Foreign hosts (direct) Yandex (direct) - Routing - Routing + Routes + Routes RF sites and services Always via VPN Addresses and subnets that are always routed through the tunnel, even when they fall into RU ranges (userspace mode) @@ -296,17 +296,17 @@ Updating… List updated: %1$d ranges Could not update, using the saved version - Route check - 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. - domain, e.g. inet.ya.ru + How traffic goes + Check which way a site, an address or an app goes: through the VPN or direct, and why. + Site, e.g. gosuslugi.ru Check Checking… The tunnel is not connected — the route view is unavailable - Resolved through the tunnel\'s DNS - Resolved through the direct network\'s DNS + Through the tunnel + Through the provider Routes - through the tunnel - direct (RU exclusion) + via VPN + direct direct (private DNS server) Could not resolve the domain on either path nothing @@ -413,8 +413,8 @@ Disconnect VPN - Connect - Profiles + Home + Servers Settings Unprotected Protected @@ -548,7 +548,7 @@ Switch server Site not loading? Pause VPN - The VPN turns off and comes back by itself. Auto-connect waits until the pause ends. + All traffic goes direct. The VPN turns back on by itself. %d minute %d minutes @@ -557,7 +557,7 @@ The VPN turns back on at %1$s Turn on now +15 minutes - While paused, traffic goes without the VPN. + All traffic goes direct now, without protection. Banks and Russian services work as usual. No internet Not a VPN problem. We connect as soon as a network is back The phone has no network @@ -624,4 +624,228 @@ The VPN turned back on. Version %1$s is available Download and install it from the app. + + Address, e.g. 95.163.0.12 + App name + Site + IP / network + App + This is not an IP address. + No such app among those that use the network. + Result + Through the VPN + Direct + Partly direct + Addresses · %1$d + The provider seems to substitute DNS, so the tunnel\'s DNS is used. + Change for %1$s + Always direct + Always through VPN + Apps are routed as a whole: a direct app bypasses the VPN for all its traffic. + A DNS server of the tunnel: always through it. + Your rule sends it through the VPN. + Your rule sends it direct. + The address is in the RU base, so it goes direct. + Not in the RU base and no rule of yours: through the VPN by default. + Split tunnel is off: every app goes through the VPN. + You kept it on the VPN although a template would let it go direct. + You added it to the direct apps. + Direct by the template «%1$s». + Not in the direct apps: through the VPN. + Rules for sites and addresses work when «Russian sites direct» is on in the «Flexible» mode. + + Rules + Where sites, addresses and apps go. A rule wins over the RU base. + All · %1$d + Sites + Addresses + Packages + Through the VPN + Direct + Rule + Site + Address + Package + addresses not resolved yet + always direct + always through the VPN, even inside the RU base + apps whose package matches go direct + Delete rule + No rules yet, and that is fine + Russian sites and the selected apps already go direct. A rule is only needed when some site takes the wrong way. + Often added + does not let in Russian addresses + works only from Russia + every package starting with ru. + Direct + Through VPN + Your own rule + New rule + Domain, e.g. chatgpt.com + Address or network, e.g. 95.163.0.0/16 + Package template, e.g. ru.sberbank* + This does not look right. Check the spelling. + The rule works by the site\'s addresses (and www.); they are refreshed on connect and every 30 minutes. Subdomains need their own rule. + Where to send it + Bypasses the VPN, even if it is not in the RU base. + Through the VPN, even if it is in the RU base. + Matching apps go direct as a whole. * stands for any characters. + Add rule + Recent apps + last hour · updates live + Without usage access only apps with failing connections are shown. + All · %1$d + Through VPN + Direct + Failing · %1$d + No apps used the network in this time. + Through the VPN + Direct + The switch means «direct, without the VPN». Traffic per app comes from the system statistics. + %1$d failed connections + %1$s cannot connect + %1$d failed connections to addresses that should work direct. Sending them through the VPN usually helps. + Through VPN + Details + See which apps use the network + And which way: through the VPN or direct. This makes it easy to find the app that does not work. It needs «Usage access». + We only see the amount of traffic per app. Content and sites stay on the phone. + Allow access + Not now + Exceptions + Sites, addresses and package templates + Check + How a site, IP or app goes + Through the VPN or direct, and why + Who used the network and which way + Through VPN %1$d%% + Direct %1$d%% + + %d address + %d addresses + + + %d rule + %d rules + + + %d app with failures + %d apps with failures + + + New app: %1$s + Looks like a Russian service. Let it go direct, bypassing the VPN? + Yes, direct + No + + Add a server + Add a server + To connect you need an .sswan profile + Where to get a profile? + The admin of your VPN sends it: as an .sswan file, a link or a QR code. + Connect now + Only Russian sites work: banks and Gosuslugi open. The VPN connects by itself. + %1$s is silent · next try in %2$d s + %1$s · %2$s + %1$s · RU sites direct + Cancel connecting + VPN is off + Tap to connect + IP %1$s · %2$d ms + IP %1$s + Change + What to do + Use Wi-Fi + connected + off + waits for network + Fastest server + Connect to another server + Pause 15 min + Fastest + Searching… + No server answered + You are on the fastest server already + this session + %1$d%% through VPN · %2$d%% direct + All traffic through the VPN + Cancel + Always-on VPN is off + Turn on + Find out what is wrong + Network, server, DNS and whitelists in 10 seconds + Until morning + %1$d h + %1$d min + until %1$s + Until the network changes + Turns on when you leave this Wi-Fi + Pause for %1$s + Pause until morning + You can cancel it from Home, the notification or the widget + Diagnostics + Log + Internet + Russian sites + Foreign sites + VPN server + DNS + no network + %1$d ms + work + do not open + no data + whitelists + via VPN · %1$d ms + direct · %1$d ms + not chosen + no answer + tunnel + provider + checking… + Something does not open? + or a site + %1$d failures · pause %2$s + Send a report + The VPN works fine + The network works, the VPN is off + Problem: %1$s + Checking… + Network, server, DNS and whitelists + Checked %1$s · tap to repeat + %1$d s ago + %1$d min ago + opens · %1$d ms + does not open + Phone + Provider + VPN + Why: + Direct path for comparison + The answers differ. + RU sites direct + Base: %1$s networks · %2$s + New addresses after a reconnect + On the fly, without a reconnect + Apps direct + Banks and RU services often do not work through a VPN + + %d works without the VPN + %d work without the VPN + + + %d with errors + %d with errors + + Site + Address / network + Package + direct + via VPN + Bypassing the VPN + Even inside the RU base + Even if the site is in the RU base + direct + VPN + Read all diff --git a/src/frontends/android/app/src/test/java/org/strongswan/android/logic/routing/RoutingRulesTest.kt b/src/frontends/android/app/src/test/java/org/strongswan/android/logic/routing/RoutingRulesTest.kt new file mode 100644 index 000000000..ce5bf22ba --- /dev/null +++ b/src/frontends/android/app/src/test/java/org/strongswan/android/logic/routing/RoutingRulesTest.kt @@ -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 . + * + * 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() + } +}