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 000000000..746764de0 Binary files /dev/null and b/src/frontends/android/app/src/main/res/font/manrope_bold.ttf differ 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 000000000..d324b62f0 Binary files /dev/null and b/src/frontends/android/app/src/main/res/font/manrope_extrabold.ttf differ 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 000000000..2a169e6b9 Binary files /dev/null and b/src/frontends/android/app/src/main/res/font/manrope_medium.ttf differ 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 000000000..859456924 Binary files /dev/null and b/src/frontends/android/app/src/main/res/font/manrope_regular.ttf differ 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 000000000..595e7dc69 Binary files /dev/null and b/src/frontends/android/app/src/main/res/font/manrope_semibold.ttf differ diff --git a/src/frontends/android/app/src/main/res/values-ru/strings.xml b/src/frontends/android/app/src/main/res/values-ru/strings.xml index 886b25f2a..5b196b214 100644 --- a/src/frontends/android/app/src/main/res/values-ru/strings.xml +++ b/src/frontends/android/app/src/main/res/values-ru/strings.xml @@ -206,8 +206,8 @@ Напрямую (вне туннеля) Зарубежные хосты (напрямую) Яндекс (напрямую) - Маршрут - Маршрутизация + Маршруты + Маршруты Сайты и сервисы РФ Всегда через 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() + } +}