From c53d9431504faf726d4639da68226ecc11ac808c Mon Sep 17 00:00:00 2001 From: Sing Dev Date: Sat, 4 Apr 2026 05:00:40 +0800 Subject: [PATCH] Add unified rule management with rule set catalog and smart dedup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New RulesScreen with two tabs: Custom Rules (structured CRUD with type/pattern/outbound, enable/disable, filter chips) and Rule Sets (28 built-in + remote URL support, per-service toggle) - Rule sets now loaded natively by engine via rule_set declarations instead of Kotlin-side inline expansion — simpler and more efficient - Remote rule sets downloaded through proxy (download_detour: "proxy") - ProxyProtocol enum as single source of truth for supported protocols, fixes NodeTester excluding hysteria2/tuic/wireguard/socks5 - Quick Rule auto-detects type (IP_CIDR, DOMAIN_SUFFIX, DOMAIN_KEYWORD) - Logs screen: add open/share button via FileProvider - Build time shown in settings --- app/build.gradle.kts | 9 + app/src/main/AndroidManifest.xml | 10 + .../main/java/com/sing/vpn/MainActivity.kt | 8 +- app/src/main/java/com/sing/vpn/MiniSing.kt | 4 + app/src/main/java/com/sing/vpn/Node.kt | 27 +- app/src/main/java/com/sing/vpn/NodeTester.kt | 2 +- app/src/main/java/com/sing/vpn/Rule.kt | 213 ++++++ app/src/main/java/com/sing/vpn/RuleSet.kt | 184 +++++ app/src/main/java/com/sing/vpn/SingConfig.kt | 382 +++++----- .../com/sing/vpn/ui/screens/ConfigScreen.kt | 32 +- .../sing/vpn/ui/screens/ConnectionsScreen.kt | 35 +- .../sing/vpn/ui/screens/EditRulesScreen.kt | 104 --- .../vpn/ui/screens/GeneralSettingsScreen.kt | 6 +- .../com/sing/vpn/ui/screens/LogsScreen.kt | 43 +- .../com/sing/vpn/ui/screens/RulesScreen.kt | 653 ++++++++++++++++++ app/src/main/res/values/strings.xml | 34 +- app/src/main/res/xml/file_paths.xml | 4 + 17 files changed, 1387 insertions(+), 363 deletions(-) create mode 100644 app/src/main/java/com/sing/vpn/Rule.kt create mode 100644 app/src/main/java/com/sing/vpn/RuleSet.kt delete mode 100644 app/src/main/java/com/sing/vpn/ui/screens/EditRulesScreen.kt create mode 100644 app/src/main/java/com/sing/vpn/ui/screens/RulesScreen.kt create mode 100644 app/src/main/res/xml/file_paths.xml diff --git a/app/build.gradle.kts b/app/build.gradle.kts index d332e16..43740e8 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,3 +1,6 @@ +import java.text.SimpleDateFormat +import java.util.Date + plugins { id("com.android.application") id("org.jetbrains.kotlin.android") @@ -14,6 +17,12 @@ android { versionCode = 5 versionName = "0.5" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + val buildTime = SimpleDateFormat("yyyy-MM-dd HH:mm").format(Date()) + buildConfigField("String", "BUILD_TIME", "\"$buildTime\"") + } + + buildFeatures { + buildConfig = true } buildTypes { diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 6ffd18b..84d01ee 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -51,5 +51,15 @@ + + + + diff --git a/app/src/main/java/com/sing/vpn/MainActivity.kt b/app/src/main/java/com/sing/vpn/MainActivity.kt index 254cbbc..d3e4e0b 100644 --- a/app/src/main/java/com/sing/vpn/MainActivity.kt +++ b/app/src/main/java/com/sing/vpn/MainActivity.kt @@ -219,7 +219,7 @@ private fun SingApp( ConfigScreen( onNavigateToConnections = { navController.navigate("connections") }, onNavigateToLogs = { navController.navigate("logs") }, - onNavigateToRules = { type -> navController.navigate("rules/$type") }, + onNavigateToRules = { navController.navigate("rules") }, onNavigateToGeneralSettings = { navController.navigate("settings/general") }, onNavigateToNetworkSettings = { navController.navigate("settings/network") }, onNavigateToTailscaleSettings = { navController.navigate("settings/tailscale") } @@ -234,10 +234,8 @@ private fun SingApp( onBack = { navController.popBackStack() } ) } - composable("rules/{type}") { backStackEntry -> - val ruleType = backStackEntry.arguments?.getString("type") ?: "proxy" - EditRulesScreen( - ruleType = ruleType, + composable("rules") { + RulesScreen( onBack = { navController.popBackStack() } ) } diff --git a/app/src/main/java/com/sing/vpn/MiniSing.kt b/app/src/main/java/com/sing/vpn/MiniSing.kt index 3860bdc..375a775 100644 --- a/app/src/main/java/com/sing/vpn/MiniSing.kt +++ b/app/src/main/java/com/sing/vpn/MiniSing.kt @@ -19,6 +19,7 @@ object MiniSing { private external fun nativeStop() private external fun nativeIsRunning(): Boolean private external fun nativeGetPeers(): String + private external fun nativeGetBuildInfo(): String /** Register VpnService instance for socket protection */ fun setVpnService(service: android.net.VpnService) { @@ -50,4 +51,7 @@ object MiniSing { /** Get tailscale peers as JSON string */ fun getPeers(): String = nativeGetPeers() + + /** Get core build info as JSON: {"build_time":"...","build_hash":"..."} */ + fun getBuildInfo(): String = try { nativeGetBuildInfo() } catch (_: Exception) { "{}" } } diff --git a/app/src/main/java/com/sing/vpn/Node.kt b/app/src/main/java/com/sing/vpn/Node.kt index badd879..08228f2 100644 --- a/app/src/main/java/com/sing/vpn/Node.kt +++ b/app/src/main/java/com/sing/vpn/Node.kt @@ -2,10 +2,35 @@ package com.sing.vpn import org.json.JSONObject +/** + * Supported proxy protocol types. + * Single source of truth — all protocol checks should use this enum. + */ +enum class ProxyProtocol(val id: String) { + SOCKS5("socks5"), + SHADOWSOCKS("shadowsocks"), + VMESS("vmess"), + VLESS("vless"), + TROJAN("trojan"), + HYSTERIA2("hysteria2"), + TUIC("tuic"), + WIREGUARD("wireguard"); + + companion object { + private val byId = entries.associateBy { it.id } + + /** Resolve a type string to enum, or null if unknown. */ + fun fromId(id: String): ProxyProtocol? = byId[id] + + /** All supported type id strings. */ + val allIds: Set = entries.map { it.id }.toSet() + } +} + data class Node( val id: String, val name: String, - val type: String, // socks5, shadowsocks, vmess, vless, trojan, hysteria2, tuic, wireguard + val type: String, // one of ProxyProtocol.id values val host: String, val port: Int, val username: String = "", diff --git a/app/src/main/java/com/sing/vpn/NodeTester.kt b/app/src/main/java/com/sing/vpn/NodeTester.kt index 39ccd63..9576c75 100644 --- a/app/src/main/java/com/sing/vpn/NodeTester.kt +++ b/app/src/main/java/com/sing/vpn/NodeTester.kt @@ -28,7 +28,7 @@ object NodeTester { private set private fun supportsHelperOutbound(node: Node): Boolean { - return node.type in setOf("shadowsocks", "vmess", "vless", "trojan") + return ProxyProtocol.fromId(node.type) != null } /** Start the helper process with all nodes configured */ diff --git a/app/src/main/java/com/sing/vpn/Rule.kt b/app/src/main/java/com/sing/vpn/Rule.kt new file mode 100644 index 0000000..e66dc20 --- /dev/null +++ b/app/src/main/java/com/sing/vpn/Rule.kt @@ -0,0 +1,213 @@ +package com.sing.vpn + +import android.content.Context +import org.json.JSONArray +import org.json.JSONObject + +/** Rule match type — maps to sing-box route rule fields. */ +enum class RuleType(val label: String, val hint: String) { + DOMAIN("Domain", "example.com"), + DOMAIN_SUFFIX("Domain Suffix", ".google.com"), + DOMAIN_KEYWORD("Domain Keyword", "youtube"), + IP_CIDR("IP CIDR", "10.0.0.0/8"), + PROCESS_NAME("Process", "com.telegram.messenger"); + + companion object { + fun fromString(s: String): RuleType = entries.firstOrNull { it.name == s } ?: DOMAIN_KEYWORD + } +} + +/** Outbound action for a matched rule. */ +enum class RuleOutbound(val label: String) { + PROXY("Proxy"), + DIRECT("Direct"), + BLOCK("Block"); + + /** The sing-box outbound tag. */ + val tag: String get() = name.lowercase() + + companion object { + fun fromString(s: String): RuleOutbound = entries.firstOrNull { it.name == s } ?: PROXY + } +} + +/** A single user-defined route rule. */ +data class Rule( + val id: Long, + val type: RuleType, + val pattern: String, + val outbound: RuleOutbound, + val enabled: Boolean = true, + val order: Int = 0 +) { + fun toJson(): JSONObject = JSONObject().apply { + put("id", id) + put("type", type.name) + put("pattern", pattern) + put("outbound", outbound.name) + put("enabled", enabled) + put("order", order) + } + + companion object { + fun fromJson(j: JSONObject): Rule = Rule( + id = j.getLong("id"), + type = RuleType.fromString(j.getString("type")), + pattern = j.getString("pattern"), + outbound = RuleOutbound.fromString(j.getString("outbound")), + enabled = j.optBoolean("enabled", true), + order = j.optInt("order", 0) + ) + } +} + +/** + * Persists structured rules as JSON in SharedPreferences. + * Single source of truth for user-defined route rules. + */ +object RuleRepository { + private const val PREFS_NAME = "sing_settings" + private const val KEY_RULES = "structured_rules" + private const val KEY_MIGRATED = "rules_migrated_v1" + + private var nextId: Long = System.currentTimeMillis() + + fun generateId(): Long = nextId++ + + /** Load all rules, sorted by order. Migrates legacy format on first call. */ + fun getAll(ctx: Context): List { + val prefs = ctx.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + migrateIfNeeded(ctx) + val json = prefs.getString(KEY_RULES, "[]") ?: "[]" + return try { + val arr = JSONArray(json) + (0 until arr.length()).map { Rule.fromJson(arr.getJSONObject(it)) } + .sortedBy { it.order } + } catch (_: Exception) { + emptyList() + } + } + + /** Get only enabled rules, sorted by order. */ + fun getEnabled(ctx: Context): List = getAll(ctx).filter { it.enabled } + + /** Save the full rule list (order is derived from list index). */ + fun saveAll(ctx: Context, rules: List) { + val ordered = rules.mapIndexed { i, r -> r.copy(order = i) } + val arr = JSONArray().apply { ordered.forEach { put(it.toJson()) } } + ctx.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + .edit().putString(KEY_RULES, arr.toString()).apply() + } + + /** Add a rule at the top of the list. Returns the new rule. */ + fun add(ctx: Context, type: RuleType, pattern: String, outbound: RuleOutbound): Rule { + val rules = getAll(ctx).toMutableList() + // Duplicate check + val exists = rules.any { + it.type == type && it.pattern.equals(pattern, ignoreCase = true) && it.outbound == outbound + } + if (exists) return rules.first { + it.type == type && it.pattern.equals(pattern, ignoreCase = true) && it.outbound == outbound + } + val rule = Rule( + id = generateId(), + type = type, + pattern = pattern.trim(), + outbound = outbound, + enabled = true, + order = 0 + ) + rules.add(0, rule) + saveAll(ctx, rules) + return rule + } + + /** Update an existing rule by id. */ + fun update(ctx: Context, updated: Rule) { + val rules = getAll(ctx).toMutableList() + val idx = rules.indexOfFirst { it.id == updated.id } + if (idx >= 0) { + rules[idx] = updated + saveAll(ctx, rules) + } + } + + /** Delete a rule by id. */ + fun delete(ctx: Context, id: Long) { + val rules = getAll(ctx).filterNot { it.id == id } + saveAll(ctx, rules) + } + + /** Move a rule from oldIndex to newIndex. */ + fun reorder(ctx: Context, oldIndex: Int, newIndex: Int) { + val rules = getAll(ctx).toMutableList() + if (oldIndex in rules.indices && newIndex in rules.indices) { + val item = rules.removeAt(oldIndex) + rules.add(newIndex, item) + saveAll(ctx, rules) + } + } + + /** Toggle enabled state for a rule. */ + fun toggleEnabled(ctx: Context, id: Long) { + val rules = getAll(ctx).toMutableList() + val idx = rules.indexOfFirst { it.id == id } + if (idx >= 0) { + rules[idx] = rules[idx].copy(enabled = !rules[idx].enabled) + saveAll(ctx, rules) + } + } + + /** Migrate legacy newline-separated domain lists to structured rules. */ + private fun migrateIfNeeded(ctx: Context) { + val prefs = ctx.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + if (prefs.getBoolean(KEY_MIGRATED, false)) return + + val existing = try { + val json = prefs.getString(KEY_RULES, "[]") ?: "[]" + val arr = JSONArray(json) + (0 until arr.length()).map { Rule.fromJson(arr.getJSONObject(it)) } + } catch (_: Exception) { + emptyList() + } + + if (existing.isNotEmpty()) { + // Already has structured rules, just mark migrated + prefs.edit().putBoolean(KEY_MIGRATED, true).apply() + return + } + + val rules = mutableListOf() + var order = 0 + + fun migrateDomains(key: String, outbound: RuleOutbound) { + val raw = prefs.getString(key, "") ?: "" + raw.split("\n").filter { it.isNotBlank() }.forEach { line -> + val pattern = line.trim() + // Guess type: if it looks like an IP/CIDR use IP_CIDR, otherwise DOMAIN_KEYWORD + val type = when { + pattern.contains("/") && pattern.any { it.isDigit() } -> RuleType.IP_CIDR + pattern.all { it.isDigit() || it == '.' || it == ':' } -> RuleType.IP_CIDR + pattern.startsWith(".") -> RuleType.DOMAIN_SUFFIX + pattern.contains(".") && !pattern.contains(" ") -> RuleType.DOMAIN_KEYWORD + else -> RuleType.DOMAIN_KEYWORD + } + rules.add(Rule(generateId(), type, pattern, outbound, true, order++)) + } + } + + migrateDomains("block_domains", RuleOutbound.BLOCK) + migrateDomains("proxy_domains", RuleOutbound.PROXY) + migrateDomains("direct_domains", RuleOutbound.DIRECT) + + if (rules.isNotEmpty()) { + val arr = JSONArray().apply { rules.forEach { put(it.toJson()) } } + prefs.edit() + .putString(KEY_RULES, arr.toString()) + .putBoolean(KEY_MIGRATED, true) + .apply() + } else { + prefs.edit().putBoolean(KEY_MIGRATED, true).apply() + } + } +} diff --git a/app/src/main/java/com/sing/vpn/RuleSet.kt b/app/src/main/java/com/sing/vpn/RuleSet.kt new file mode 100644 index 0000000..d3bef7d --- /dev/null +++ b/app/src/main/java/com/sing/vpn/RuleSet.kt @@ -0,0 +1,184 @@ +package com.sing.vpn + +import android.content.Context +import org.json.JSONArray +import org.json.JSONObject + +/** Source type for a rule set. */ +enum class RuleSetSource { + BUILTIN, // Shipped in assets/rules/ + REMOTE; // User-provided URL, downloaded through proxy + + companion object { + fun fromString(s: String): RuleSetSource = entries.firstOrNull { it.name == s } ?: BUILTIN + } +} + +/** A rule set — either built-in (local JSON) or remote (URL). */ +data class RuleSet( + val id: String, + val name: String, + val source: RuleSetSource, + val outbound: RuleOutbound, + val enabled: Boolean = true, + val url: String = "", // Remote only + val updateInterval: String = "1d" // Remote only +) { + /** The assets filename for built-in rule sets (e.g. "builtin-google" → "google.json"). */ + val filename: String + get() = if (source == RuleSetSource.BUILTIN) id.removePrefix("builtin-") + ".json" else "" + + fun toJson(): JSONObject = JSONObject().apply { + put("id", id) + put("name", name) + put("source", source.name) + put("outbound", outbound.name) + put("enabled", enabled) + if (url.isNotEmpty()) put("url", url) + if (source == RuleSetSource.REMOTE) put("update_interval", updateInterval) + } + + companion object { + fun fromJson(j: JSONObject): RuleSet = RuleSet( + id = j.getString("id"), + name = j.getString("name"), + source = RuleSetSource.fromString(j.getString("source")), + outbound = RuleOutbound.fromString(j.getString("outbound")), + enabled = j.optBoolean("enabled", true), + url = j.optString("url", ""), + updateInterval = j.optString("update_interval", "1d") + ) + } +} + +/** + * Persists the rule set catalog (built-in + custom remote) in SharedPreferences. + */ +object RuleSetRepository { + private const val PREFS_NAME = "sing_settings" + private const val KEY_RULE_SETS = "rule_sets_catalog" + private const val KEY_INITIALIZED = "rule_sets_initialized_v1" + + /** Built-in rule sets shipped with the app. */ + private val BUILTIN_DEFAULTS = listOf( + // Proxy services + RuleSet("builtin-google", "Google", RuleSetSource.BUILTIN, RuleOutbound.PROXY), + RuleSet("builtin-youtube", "YouTube", RuleSetSource.BUILTIN, RuleOutbound.PROXY), + RuleSet("builtin-telegram", "Telegram", RuleSetSource.BUILTIN, RuleOutbound.PROXY), + RuleSet("builtin-netflix", "Netflix", RuleSetSource.BUILTIN, RuleOutbound.PROXY), + RuleSet("builtin-openai", "OpenAI", RuleSetSource.BUILTIN, RuleOutbound.PROXY), + RuleSet("builtin-tiktok", "TikTok", RuleSetSource.BUILTIN, RuleOutbound.PROXY), + RuleSet("builtin-github", "GitHub", RuleSetSource.BUILTIN, RuleOutbound.PROXY), + RuleSet("builtin-facebook", "Facebook", RuleSetSource.BUILTIN, RuleOutbound.PROXY), + RuleSet("builtin-twitter", "Twitter/X", RuleSetSource.BUILTIN, RuleOutbound.PROXY), + RuleSet("builtin-amazon", "Amazon", RuleSetSource.BUILTIN, RuleOutbound.PROXY), + RuleSet("builtin-steam", "Steam", RuleSetSource.BUILTIN, RuleOutbound.PROXY), + RuleSet("builtin-microsoft", "Microsoft", RuleSetSource.BUILTIN, RuleOutbound.PROXY), + RuleSet("builtin-apple", "Apple", RuleSetSource.BUILTIN, RuleOutbound.PROXY), + // CN Direct + RuleSet("builtin-cn-direct", "CN Direct", RuleSetSource.BUILTIN, RuleOutbound.DIRECT), + RuleSet("builtin-bilibili", "Bilibili", RuleSetSource.BUILTIN, RuleOutbound.DIRECT), + RuleSet("builtin-douyin", "Douyin", RuleSetSource.BUILTIN, RuleOutbound.DIRECT), + RuleSet("builtin-baidu", "Baidu", RuleSetSource.BUILTIN, RuleOutbound.DIRECT), + RuleSet("builtin-taobao", "Taobao", RuleSetSource.BUILTIN, RuleOutbound.DIRECT), + RuleSet("builtin-wechat", "WeChat", RuleSetSource.BUILTIN, RuleOutbound.DIRECT), + RuleSet("builtin-jd", "JD", RuleSetSource.BUILTIN, RuleOutbound.DIRECT), + RuleSet("builtin-weibo", "Weibo", RuleSetSource.BUILTIN, RuleOutbound.DIRECT), + RuleSet("builtin-zhihu", "Zhihu", RuleSetSource.BUILTIN, RuleOutbound.DIRECT), + RuleSet("builtin-netease-music", "Netease Music", RuleSetSource.BUILTIN, RuleOutbound.DIRECT), + RuleSet("builtin-iqiyi", "iQIYI", RuleSetSource.BUILTIN, RuleOutbound.DIRECT), + RuleSet("builtin-cn-media", "CN Media", RuleSetSource.BUILTIN, RuleOutbound.DIRECT), + RuleSet("builtin-meituan", "Meituan", RuleSetSource.BUILTIN, RuleOutbound.DIRECT), + RuleSet("builtin-cn-ip", "CN IP", RuleSetSource.BUILTIN, RuleOutbound.DIRECT), + // Ads block (default disabled) + RuleSet("builtin-ads", "Ads Block", RuleSetSource.BUILTIN, RuleOutbound.BLOCK, enabled = false), + ) + + /** Initialize defaults on first run. Migrate ads_block_enabled. */ + private fun initIfNeeded(ctx: Context) { + val prefs = ctx.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + if (prefs.getBoolean(KEY_INITIALIZED, false)) return + + val defaults = BUILTIN_DEFAULTS.toMutableList() + + // Migrate: if user had ads block enabled, enable it in catalog + if (prefs.getBoolean("ads_block_enabled", false)) { + val idx = defaults.indexOfFirst { it.id == "builtin-ads" } + if (idx >= 0) defaults[idx] = defaults[idx].copy(enabled = true) + } + + val arr = JSONArray().apply { defaults.forEach { put(it.toJson()) } } + prefs.edit() + .putString(KEY_RULE_SETS, arr.toString()) + .putBoolean(KEY_INITIALIZED, true) + .apply() + } + + /** Load all rule sets from catalog. */ + fun getAll(ctx: Context): List { + initIfNeeded(ctx) + val prefs = ctx.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + val json = prefs.getString(KEY_RULE_SETS, "[]") ?: "[]" + return try { + val arr = JSONArray(json) + (0 until arr.length()).map { RuleSet.fromJson(arr.getJSONObject(it)) } + } catch (_: Exception) { + emptyList() + } + } + + /** Get only enabled rule sets. */ + fun getEnabled(ctx: Context): List = getAll(ctx).filter { it.enabled } + + /** Save the full catalog. */ + fun saveAll(ctx: Context, sets: List) { + val arr = JSONArray().apply { sets.forEach { put(it.toJson()) } } + ctx.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + .edit().putString(KEY_RULE_SETS, arr.toString()).apply() + } + + /** Toggle enabled state for a rule set by id. */ + fun toggleEnabled(ctx: Context, id: String) { + val sets = getAll(ctx).toMutableList() + val idx = sets.indexOfFirst { it.id == id } + if (idx >= 0) { + sets[idx] = sets[idx].copy(enabled = !sets[idx].enabled) + saveAll(ctx, sets) + } + } + + /** Check if a specific rule set is enabled. */ + fun isEnabled(ctx: Context, id: String): Boolean { + return getAll(ctx).firstOrNull { it.id == id }?.enabled ?: false + } + + /** Change outbound for a rule set. */ + fun changeOutbound(ctx: Context, id: String, outbound: RuleOutbound) { + val sets = getAll(ctx).toMutableList() + val idx = sets.indexOfFirst { it.id == id } + if (idx >= 0) { + sets[idx] = sets[idx].copy(outbound = outbound) + saveAll(ctx, sets) + } + } + + /** Add a remote rule set. Returns the new rule set. */ + fun addRemote(ctx: Context, name: String, url: String, outbound: RuleOutbound, updateInterval: String = "1d"): RuleSet { + val sets = getAll(ctx).toMutableList() + val id = "remote-${System.currentTimeMillis()}" + val rs = RuleSet(id, name.trim(), RuleSetSource.REMOTE, outbound, true, url.trim(), updateInterval) + sets.add(rs) + saveAll(ctx, sets) + return rs + } + + /** Delete a remote rule set. Built-in rule sets cannot be deleted. */ + fun deleteRemote(ctx: Context, id: String) { + val sets = getAll(ctx).toMutableList() + val idx = sets.indexOfFirst { it.id == id && it.source == RuleSetSource.REMOTE } + if (idx >= 0) { + sets.removeAt(idx) + saveAll(ctx, sets) + } + } +} diff --git a/app/src/main/java/com/sing/vpn/SingConfig.kt b/app/src/main/java/com/sing/vpn/SingConfig.kt index 5457eec..23b8f7f 100644 --- a/app/src/main/java/com/sing/vpn/SingConfig.kt +++ b/app/src/main/java/com/sing/vpn/SingConfig.kt @@ -27,189 +27,22 @@ object SingConfig { return rulesDir } - /** Load a sing-box v3 rule-set JSON and return merged domain/suffix/keyword/cidr */ - data class RuleSetData( - val domains: List = emptyList(), - val domainSuffixes: List = emptyList(), - val domainKeywords: List = emptyList(), - val ipCidrs: List = emptyList() - ) - - private data class CoverageIndex( - val domains: MutableSet = linkedSetOf(), - val domainSuffixes: MutableSet = linkedSetOf(), - val domainKeywords: MutableSet = linkedSetOf(), - val ipCidrs: MutableList> = mutableListOf() - ) - - private fun loadRuleSet(rulesDir: File, name: String): RuleSetData { - val file = File(rulesDir, name) - if (!file.exists()) return RuleSetData() - return try { - val json = JSONObject(file.readText()) - val rules = json.optJSONArray("rules") ?: return RuleSetData() - val domains = mutableListOf() - val suffixes = mutableListOf() - val keywords = mutableListOf() - val cidrs = mutableListOf() - for (i in 0 until rules.length()) { - val rule = rules.getJSONObject(i) - rule.optJSONArray("domain")?.let { a -> for (j in 0 until a.length()) domains.add(a.getString(j)) } - rule.optJSONArray("domain_suffix")?.let { a -> for (j in 0 until a.length()) suffixes.add(a.getString(j)) } - rule.optJSONArray("domain_keyword")?.let { a -> for (j in 0 until a.length()) keywords.add(a.getString(j)) } - rule.optJSONArray("ip_cidr")?.let { a -> for (j in 0 until a.length()) cidrs.add(a.getString(j)) } - } - RuleSetData(domains, suffixes, keywords, cidrs) - } catch (e: Exception) { - RuleSetData() - } - } - - private fun loadMultipleRuleSets(rulesDir: File, names: List): RuleSetData { - val domains = mutableListOf() - val suffixes = mutableListOf() - val keywords = mutableListOf() - val cidrs = mutableListOf() - for (name in names) { - val data = loadRuleSet(rulesDir, name) - domains.addAll(data.domains) - suffixes.addAll(data.domainSuffixes) - keywords.addAll(data.domainKeywords) - cidrs.addAll(data.ipCidrs) - } - return RuleSetData(domains, suffixes, keywords, cidrs) - } - - private fun loadPreferredRuleSet(rulesDir: File, preferred: String, fallbackNames: List): RuleSetData { - val preferredData = loadRuleSet(rulesDir, preferred) - return if (preferredData.isEmpty()) loadMultipleRuleSets(rulesDir, fallbackNames) else preferredData - } - - private fun RuleSetData.isEmpty(): Boolean { - return domains.isEmpty() && domainSuffixes.isEmpty() && domainKeywords.isEmpty() && ipCidrs.isEmpty() - } - - private fun RuleSetData.filterCoveredBy(index: CoverageIndex): RuleSetData { - val keptDomains = domains.filterNot { index.coversDomain(it) } - val keptSuffixes = domainSuffixes.filterNot { index.coversSuffix(it) } - val keptKeywords = domainKeywords.filterNot { index.domainKeywords.contains(it) } - val keptCidrs = ipCidrs.filterNot { index.coversCidr(it) } - return RuleSetData( - domains = keptDomains.distinct(), - domainSuffixes = keptSuffixes.distinct(), - domainKeywords = keptKeywords.distinct(), - ipCidrs = keptCidrs.distinct() - ) - } - - private fun CoverageIndex.addAll(data: RuleSetData) { - domains.addAll(data.domains) - domainSuffixes.addAll(data.domainSuffixes) - domainKeywords.addAll(data.domainKeywords) - data.ipCidrs.mapNotNullTo(ipCidrs) { parseCidr(it) } - } - - private fun CoverageIndex.coversDomain(domain: String): Boolean { - if (domains.contains(domain)) return true - if (coversSuffix(domain)) return true - return domainKeywords.any { keyword -> domain.contains(keyword) } - } - - private fun CoverageIndex.coversSuffix(suffix: String): Boolean { - if (domainSuffixes.contains(suffix)) return true - return suffix.split(".").drop(1).any { parent -> domainSuffixes.contains(parent) } || - domainKeywords.any { keyword -> suffix.contains(keyword) } - } - - private fun CoverageIndex.coversCidr(cidr: String): Boolean { - val target = parseCidr(cidr) ?: return false - return ipCidrs.any { parent -> containsRange(parent, target) } - } - - private fun parseCidr(cidr: String): Pair? { - val parts = cidr.split("/", limit = 2) - if (parts.size != 2) return null - return try { - InetAddress.getByName(parts[0]) to parts[1].toInt() - } catch (_: Exception) { - null - } - } - - private fun containsRange(parent: Pair, child: Pair): Boolean { - val (parentIp, parentPrefix) = parent - val (childIp, childPrefix) = child - val parentBytes = parentIp.address - val childBytes = childIp.address - if (parentBytes.size != childBytes.size) return false - if (parentPrefix > childPrefix) return false - val fullBytes = parentPrefix / 8 - val partialBits = parentPrefix % 8 - for (i in 0 until fullBytes) { - if (parentBytes[i] != childBytes[i]) return false - } - if (partialBits == 0) return true - val mask = (0xFF shl (8 - partialBits)) and 0xFF - return (parentBytes[fullBytes].toInt() and mask) == (childBytes[fullBytes].toInt() and mask) - } - - private fun resolveRuleSets( - directRules: RuleSetData, - blockRules: RuleSetData, - proxyRules: RuleSetData - ): Triple { - val directIndex = CoverageIndex().apply { addAll(directRules) } - val filteredBlockRules = blockRules.filterCoveredBy(directIndex) - val combinedIndex = CoverageIndex().apply { - addAll(directRules) - addAll(filteredBlockRules) - } - val filteredProxyRules = proxyRules.filterCoveredBy(combinedIndex) - return Triple(directRules, filteredBlockRules, filteredProxyRules) - } - - /** Build a route rule JSONObject from RuleSetData */ - private fun ruleSetToRouteRule(data: RuleSetData, outbound: String): JSONObject? { - if (data.isEmpty()) return null - return JSONObject().apply { - if (data.domains.isNotEmpty()) put("domain", JSONArray(data.domains)) - if (data.domainSuffixes.isNotEmpty()) put("domain_suffix", JSONArray(data.domainSuffixes)) - if (data.domainKeywords.isNotEmpty()) put("domain_keyword", JSONArray(data.domainKeywords)) - if (data.ipCidrs.isNotEmpty()) put("ip_cidr", JSONArray(data.ipCidrs)) - put("outbound", outbound) - } - } - fun generate(ctx: Context, node: Node, tunFd: Int): File { val prefs = ctx.getSharedPreferences("sing_settings", Context.MODE_PRIVATE) val remoteDns = prefs.getString("dns_remote", "8.8.8.8") ?: "8.8.8.8" val localDns = prefs.getString("dns_local", "223.5.5.5") ?: "223.5.5.5" val routingMode = prefs.getInt("routing_mode", ROUTE_RULE) - val adsBlockEnabled = prefs.getBoolean("ads_block_enabled", false) - val proxyDomains = prefs.getString("proxy_domains", "")?.split("\n")?.filter { it.isNotBlank() } ?: emptyList() - val directDomains = prefs.getString("direct_domains", "")?.split("\n")?.filter { it.isNotBlank() } ?: emptyList() - val blockDomains = prefs.getString("block_domains", "")?.split("\n")?.filter { it.isNotBlank() } ?: emptyList() + // Load structured user rules (enabled only) + val userRules = RuleRepository.getEnabled(ctx) + // Load rule set catalog (enabled only) + val enabledRuleSets = RuleSetRepository.getEnabled(ctx) val tailscaleEnabled = prefs.getBoolean("tailscale_enabled", false) val tailscaleAuthKey = prefs.getString("tailscale_auth_key", "") ?: "" val tailscaleHostname = prefs.getString("tailscale_hostname", "") ?: "" val tailscaleAcceptRoutes = prefs.getBoolean("tailscale_accept_routes", true) - // Load rule sets from assets + // Copy built-in rule set files from assets to filesDir/rules/ val rulesDir = ensureRuleSets(ctx) - val cnDirectRules = loadPreferredRuleSet(rulesDir, "resolved-direct.json", listOf( - "cn-direct.json", "bilibili.json", "douyin.json", "baidu.json", - "taobao.json", "wechat.json", "jd.json", "weibo.json", "zhihu.json", - "netease-music.json", "iqiyi.json", "cn-media.json", "meituan.json", - "cn-ip.json" - )) - val adsRules = loadPreferredRuleSet(rulesDir, "resolved-reject.json", listOf("ads.json")) - val proxyRules = loadPreferredRuleSet(rulesDir, "resolved-proxy.json", listOf( - "telegram.json", "google.json", "youtube.json", "netflix.json", - "openai.json", "tiktok.json", "github.json", "facebook.json", - "twitter.json", "amazon.json", "steam.json" - )) - val (resolvedDirectRules, resolvedAdsRules, resolvedProxyRules) = - resolveRuleSets(cnDirectRules, adsRules, proxyRules) val config = JSONObject() @@ -306,8 +139,31 @@ object SingConfig { } config.put("route", JSONObject().apply { + // Declare rule_set entries (engine handles loading, caching, matching) + val ruleSetDecls = JSONArray() + for (rs in enabledRuleSets) { + ruleSetDecls.put(JSONObject().apply { + put("tag", rs.id) + when (rs.source) { + RuleSetSource.BUILTIN -> { + put("type", "local") + put("path", File(rulesDir, rs.filename).absolutePath) + put("format", "source") + } + RuleSetSource.REMOTE -> { + put("type", "remote") + put("url", rs.url) + put("format", "source") + put("download_detour", "proxy") + put("update_interval", rs.updateInterval) + } + } + }) + } + if (ruleSetDecls.length() > 0) put("rule_set", ruleSetDecls) + put("rules", JSONArray().apply { - // Proxy server direct (avoid routing loop) + // 1. Proxy server direct (avoid routing loop) if (!isIPAddress(node.host)) { put(JSONObject().apply { put("domain", JSONArray().put(node.host)) @@ -319,30 +175,16 @@ object SingConfig { put("outbound", "direct") }) } - // User custom block domains - if (blockDomains.isNotEmpty()) { - put(JSONObject().apply { - put("domain_keyword", JSONArray().apply { blockDomains.forEach { put(it) } }) - put("outbound", "block") - }) + // 2. User custom rules (structured, deduplicated) + val dedupedRules = deduplicateUserRules(userRules) + for (ob in listOf(RuleOutbound.BLOCK, RuleOutbound.PROXY, RuleOutbound.DIRECT)) { + val group = dedupedRules.filter { it.outbound == ob } + if (group.isEmpty()) continue + val ruleObj = userRulesToRouteRule(group, ob.tag) + if (ruleObj != null) put(ruleObj) } - // User custom proxy domains - if (proxyDomains.isNotEmpty()) { - put(JSONObject().apply { - put("domain_keyword", JSONArray().apply { proxyDomains.forEach { put(it) } }) - put("outbound", "proxy") - }) - } - // User custom direct domains - if (directDomains.isNotEmpty()) { - put(JSONObject().apply { - put("domain_keyword", JSONArray().apply { directDomains.forEach { put(it) } }) - put("outbound", "direct") - }) - } - // Tailscale: control plane direct, .ts.net + CGNAT via tailnet + // 3. Tailscale: control plane direct, .ts.net + CGNAT via tailnet if (tailscaleEnabled && tailscaleAuthKey.isNotEmpty()) { - // Control plane + DERP servers must bypass VPN tunnel put(JSONObject().apply { put("domain_suffix", JSONArray() .put("tailscale.com") @@ -358,20 +200,22 @@ object SingConfig { put("outbound", "tailnet") }) } - // Ads block is opt-in from settings to avoid surprising default breakage. - if (adsBlockEnabled) { - ruleSetToRouteRule(resolvedAdsRules, "block")?.let { put(it) } + // 4. Rule set references, grouped by outbound (block > proxy > direct) + for (ob in listOf(RuleOutbound.BLOCK, RuleOutbound.PROXY, RuleOutbound.DIRECT)) { + val tags = enabledRuleSets.filter { it.outbound == ob }.map { it.id } + if (tags.isNotEmpty()) { + put(JSONObject().apply { + put("rule_set", JSONArray(tags)) + put("outbound", ob.tag) + }) + } } - // Proxy services (from rule sets) - ruleSetToRouteRule(resolvedProxyRules, "proxy")?.let { put(it) } - // CN domains direct (from rule sets) - ruleSetToRouteRule(resolvedDirectRules, "direct")?.let { put(it) } - // .cn suffix fallback + // 5. .cn suffix fallback put(JSONObject().apply { put("domain_suffix", JSONArray().put(".cn")) put("outbound", "direct") }) - // Private IPs direct + // 6. Private IPs direct put(JSONObject().apply { put("ip_is_private", true) put("outbound", "direct") @@ -502,6 +346,136 @@ object SingConfig { } } + /** Convert a group of user rules to a sing-box route rule JSONObject. */ + private fun userRulesToRouteRule(rules: List, outbound: String): JSONObject? { + if (rules.isEmpty()) return null + val domains = mutableListOf() + val suffixes = mutableListOf() + val keywords = mutableListOf() + val cidrs = mutableListOf() + val processes = mutableListOf() + for (r in rules) { + when (r.type) { + RuleType.DOMAIN -> domains.add(r.pattern) + RuleType.DOMAIN_SUFFIX -> suffixes.add(r.pattern) + RuleType.DOMAIN_KEYWORD -> keywords.add(r.pattern) + RuleType.IP_CIDR -> cidrs.add(r.pattern) + RuleType.PROCESS_NAME -> processes.add(r.pattern) + } + } + return JSONObject().apply { + if (domains.isNotEmpty()) put("domain", JSONArray(domains)) + if (suffixes.isNotEmpty()) put("domain_suffix", JSONArray(suffixes)) + if (keywords.isNotEmpty()) put("domain_keyword", JSONArray(keywords)) + if (cidrs.isNotEmpty()) put("ip_cidr", JSONArray(cidrs)) + if (processes.isNotEmpty()) put("process_name", JSONArray(processes)) + put("outbound", outbound) + } + } + + /** + * Smart deduplication of user rules: + * 1. Remove exact duplicates (same type+pattern, keep first by order) + * 2. Remove domain rules covered by a broader rule in same outbound + * e.g. DOMAIN "mail.google.com" is redundant if DOMAIN_SUFFIX ".google.com" exists + * e.g. DOMAIN_SUFFIX ".cdn.example.com" is redundant if DOMAIN_SUFFIX ".example.com" exists + * e.g. DOMAIN "youtube.com" is redundant if DOMAIN_KEYWORD "youtube" exists + * 3. Remove IP_CIDR rules contained by a broader CIDR in same outbound + */ + private fun deduplicateUserRules(rules: List): List { + // Step 1: Remove exact duplicates (same type + pattern, case-insensitive) + val seen = mutableSetOf() + val unique = rules.filter { r -> + val key = "${r.type}|${r.pattern.lowercase()}|${r.outbound}" + seen.add(key) + } + + // Step 2: Per-outbound semantic dedup + return unique.filter { rule -> + val siblings = unique.filter { it.outbound == rule.outbound && it.id != rule.id } + !isCoveredBy(rule, siblings) + } + } + + /** Check if a rule is semantically covered by any of the sibling rules. */ + private fun isCoveredBy(rule: Rule, siblings: List): Boolean { + val pattern = rule.pattern.lowercase() + when (rule.type) { + RuleType.DOMAIN -> { + // Covered if any sibling DOMAIN_SUFFIX matches + for (s in siblings) { + if (s.type == RuleType.DOMAIN_SUFFIX) { + val suffix = s.pattern.lowercase() + if (pattern == suffix || pattern.endsWith(".$suffix") || + pattern.endsWith(suffix)) return true + } + if (s.type == RuleType.DOMAIN_KEYWORD && pattern.contains(s.pattern.lowercase())) return true + } + } + RuleType.DOMAIN_SUFFIX -> { + // Covered if a broader suffix exists + for (s in siblings) { + if (s.type == RuleType.DOMAIN_SUFFIX) { + val otherSuffix = s.pattern.lowercase() + if (otherSuffix != pattern && (pattern.endsWith(".$otherSuffix") || + pattern.endsWith(otherSuffix))) return true + } + if (s.type == RuleType.DOMAIN_KEYWORD && pattern.contains(s.pattern.lowercase())) return true + } + } + RuleType.DOMAIN_KEYWORD -> { + // Covered if another keyword is a substring of this one + for (s in siblings) { + if (s.type == RuleType.DOMAIN_KEYWORD) { + val other = s.pattern.lowercase() + if (other != pattern && pattern.contains(other)) return true + } + } + } + RuleType.IP_CIDR -> { + // Covered if a broader CIDR contains this one + val target = parseCidr(rule.pattern) ?: return false + for (s in siblings) { + if (s.type == RuleType.IP_CIDR) { + val parent = parseCidr(s.pattern) ?: continue + if (parent != target && containsRange(parent, target)) return true + } + } + } + RuleType.PROCESS_NAME -> { + // No semantic dedup for process names + } + } + return false + } + + private fun parseCidr(cidr: String): Pair? { + val parts = cidr.split("/", limit = 2) + if (parts.size != 2) return null + return try { + InetAddress.getByName(parts[0]) to parts[1].toInt() + } catch (_: Exception) { + null + } + } + + private fun containsRange(parent: Pair, child: Pair): Boolean { + val (parentIp, parentPrefix) = parent + val (childIp, childPrefix) = child + val parentBytes = parentIp.address + val childBytes = childIp.address + if (parentBytes.size != childBytes.size) return false + if (parentPrefix > childPrefix) return false + val fullBytes = parentPrefix / 8 + val partialBits = parentPrefix % 8 + for (i in 0 until fullBytes) { + if (parentBytes[i] != childBytes[i]) return false + } + if (partialBits == 0) return true + val mask = (0xFF shl (8 - partialBits)) and 0xFF + return (parentBytes[fullBytes].toInt() and mask) == (childBytes[fullBytes].toInt() and mask) + } + fun isIPAddress(str: String): Boolean { return str.matches(Regex("^\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}$")) || str.contains(':') // IPv6 diff --git a/app/src/main/java/com/sing/vpn/ui/screens/ConfigScreen.kt b/app/src/main/java/com/sing/vpn/ui/screens/ConfigScreen.kt index f6e19e5..77ec762 100644 --- a/app/src/main/java/com/sing/vpn/ui/screens/ConfigScreen.kt +++ b/app/src/main/java/com/sing/vpn/ui/screens/ConfigScreen.kt @@ -4,19 +4,21 @@ import androidx.compose.foundation.background import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.material3.* -import androidx.compose.runtime.Composable +import androidx.compose.runtime.* import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp +import com.sing.vpn.MiniSing import com.sing.vpn.R import com.sing.vpn.ui.components.* import com.sing.vpn.ui.theme.* +import org.json.JSONObject @Composable fun ConfigScreen( onNavigateToConnections: () -> Unit, onNavigateToLogs: () -> Unit, - onNavigateToRules: (String) -> Unit, + onNavigateToRules: () -> Unit, onNavigateToGeneralSettings: () -> Unit, onNavigateToNetworkSettings: () -> Unit, onNavigateToTailscaleSettings: () -> Unit, @@ -69,18 +71,8 @@ fun ConfigScreen( ) InlineDivider() NavigationRow( - label = stringResource(R.string.proxy_domains), - onClick = { onNavigateToRules("proxy") } - ) - InlineDivider() - NavigationRow( - label = stringResource(R.string.direct_domains), - onClick = { onNavigateToRules("direct") } - ) - InlineDivider() - NavigationRow( - label = stringResource(R.string.block_domains), - onClick = { onNavigateToRules("block") } + label = stringResource(R.string.settings_rules), + onClick = onNavigateToRules ) InlineDivider() NavigationRow( @@ -92,6 +84,13 @@ fun ConfigScreen( // ABOUT item { + val coreBuild = remember { + try { + val j = JSONObject(MiniSing.getBuildInfo()) + j.optString("build_time", "unknown") + } catch (_: Exception) { "unknown" } + } + SectionHeader(stringResource(R.string.section_about)) InfoCard { KeyValueRow( @@ -102,6 +101,11 @@ fun ConfigScreen( stringResource(R.string.engine_label), stringResource(R.string.engine_value) ) + KeyValueRow( + stringResource(R.string.build_time_label), + com.sing.vpn.BuildConfig.BUILD_TIME + ) + KeyValueRow("Core", coreBuild) } } diff --git a/app/src/main/java/com/sing/vpn/ui/screens/ConnectionsScreen.kt b/app/src/main/java/com/sing/vpn/ui/screens/ConnectionsScreen.kt index aa09ee8..9e6a38a 100644 --- a/app/src/main/java/com/sing/vpn/ui/screens/ConnectionsScreen.kt +++ b/app/src/main/java/com/sing/vpn/ui/screens/ConnectionsScreen.kt @@ -21,6 +21,9 @@ import com.sing.vpn.R import com.sing.vpn.ClashApi import com.sing.vpn.ClashConnection import com.sing.vpn.ClashSnapshot +import com.sing.vpn.RuleOutbound +import com.sing.vpn.RuleRepository +import com.sing.vpn.RuleType import com.sing.vpn.SingVpnService import com.sing.vpn.Util import com.sing.vpn.ui.components.ConnectionRow @@ -159,26 +162,24 @@ private fun extractDomain(host: String): String { return if (parts.size > 2) parts.takeLast(2).joinToString(".") else h } -/** Add a domain keyword to a rule category */ +/** Add a structured rule via RuleRepository */ private fun addQuickRule(context: Context, domain: String, ruleType: String) { - val prefs = context.getSharedPreferences("sing_settings", Context.MODE_PRIVATE) - val key = "${ruleType}_domains" - val existing = prefs.getString(key, "") ?: "" - // Check for duplicates - val lines = existing.split("\n").filter { it.isNotBlank() } - if (lines.any { it.trim().equals(domain, ignoreCase = true) }) { - Toast.makeText(context, "Rule already exists: $domain", Toast.LENGTH_SHORT).show() - return + val outbound = when (ruleType) { + "proxy" -> RuleOutbound.PROXY + "direct" -> RuleOutbound.DIRECT + "block" -> RuleOutbound.BLOCK + else -> RuleOutbound.PROXY } - val updated = if (existing.isBlank()) domain else "$existing\n$domain" - prefs.edit().putString(key, updated).apply() - val label = when (ruleType) { - "proxy" -> "Proxy" - "direct" -> "Direct" - "block" -> "Block" - else -> ruleType + // Auto-detect rule type + val type = when { + domain.contains("/") && domain.any { it.isDigit() } -> RuleType.IP_CIDR + domain.all { it.isDigit() || it == '.' || it == ':' } -> RuleType.IP_CIDR + domain.startsWith(".") -> RuleType.DOMAIN_SUFFIX + domain.contains(".") -> RuleType.DOMAIN_SUFFIX + else -> RuleType.DOMAIN_KEYWORD } - Toast.makeText(context, context.getString(R.string.rule_added, domain, label), Toast.LENGTH_SHORT).show() + RuleRepository.add(context, type, domain, outbound) + Toast.makeText(context, context.getString(R.string.rule_added, domain, outbound.label), Toast.LENGTH_SHORT).show() } @OptIn(ExperimentalMaterial3Api::class) diff --git a/app/src/main/java/com/sing/vpn/ui/screens/EditRulesScreen.kt b/app/src/main/java/com/sing/vpn/ui/screens/EditRulesScreen.kt deleted file mode 100644 index ee755a7..0000000 --- a/app/src/main/java/com/sing/vpn/ui/screens/EditRulesScreen.kt +++ /dev/null @@ -1,104 +0,0 @@ -package com.sing.vpn.ui.screens - -import android.content.Context -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material3.* -import androidx.compose.runtime.* -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp -import com.sing.vpn.R -import com.sing.vpn.ui.theme.* - -@Composable -fun EditRulesScreen( - ruleType: String, - onBack: () -> Unit, - modifier: Modifier = Modifier -) { - val context = LocalContext.current - val prefs = remember { context.getSharedPreferences("sing_settings", Context.MODE_PRIVATE) } - - val prefKey = "${ruleType}_domains" - var text by remember { mutableStateOf(prefs.getString(prefKey, "") ?: "") } - - val title = when (ruleType) { - "proxy" -> stringResource(R.string.title_proxy_domains) - "direct" -> stringResource(R.string.title_direct_domains) - "block" -> stringResource(R.string.title_block_domains) - else -> stringResource(R.string.title_rules) - } - - // Save on leave - DisposableEffect(Unit) { - onDispose { - prefs.edit().putString(prefKey, text).apply() - } - } - - Column( - modifier = modifier - .fillMaxSize() - .background(Background) - ) { - // Header - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 8.dp, vertical = 8.dp), - verticalAlignment = Alignment.CenterVertically - ) { - IconButton(onClick = onBack) { - Icon( - Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = stringResource(R.string.cd_back), - tint = TextPrimary - ) - } - Text( - text = title, - style = MaterialTheme.typography.headlineLarge, - color = TextPrimary - ) - } - - Text( - text = stringResource(R.string.rules_description), - style = MaterialTheme.typography.bodySmall, - color = TextTertiary, - modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp) - ) - - OutlinedTextField( - value = text, - onValueChange = { text = it }, - modifier = Modifier - .fillMaxWidth() - .weight(1f) - .padding(16.dp), - placeholder = { - Text( - stringResource(R.string.placeholder_rules), - color = TextTertiary, - style = MaterialTheme.typography.bodySmall - ) - }, - textStyle = MaterialTheme.typography.bodyMedium.copy( - fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace, - color = TextPrimary - ), - colors = OutlinedTextFieldDefaults.colors( - focusedBorderColor = AccentPurple, - unfocusedBorderColor = Border, - cursorColor = AccentPurple - ), - shape = RoundedCornerShape(6.dp) - ) - } -} diff --git a/app/src/main/java/com/sing/vpn/ui/screens/GeneralSettingsScreen.kt b/app/src/main/java/com/sing/vpn/ui/screens/GeneralSettingsScreen.kt index 5e5a707..c9127d0 100644 --- a/app/src/main/java/com/sing/vpn/ui/screens/GeneralSettingsScreen.kt +++ b/app/src/main/java/com/sing/vpn/ui/screens/GeneralSettingsScreen.kt @@ -15,6 +15,7 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import com.sing.vpn.R +import com.sing.vpn.RuleSetRepository import com.sing.vpn.ui.components.* import com.sing.vpn.ui.theme.* @@ -27,7 +28,7 @@ fun GeneralSettingsScreen(onBack: () -> Unit) { var routingMode by remember { mutableIntStateOf(prefs.getInt("routing_mode", 0)) } var autoConnect by remember { mutableStateOf(prefs.getBoolean("autoconnect", false)) } var ipv6 by remember { mutableStateOf(prefs.getBoolean("ipv6", false)) } - var adsBlock by remember { mutableStateOf(prefs.getBoolean("ads_block_enabled", false)) } + var adsBlock by remember { mutableStateOf(RuleSetRepository.isEnabled(context, "builtin-ads")) } var showRoutingDropdown by remember { mutableStateOf(false) } fun save() { @@ -35,7 +36,6 @@ fun GeneralSettingsScreen(onBack: () -> Unit) { .putInt("routing_mode", routingMode) .putBoolean("autoconnect", autoConnect) .putBoolean("ipv6", ipv6) - .putBoolean("ads_block_enabled", adsBlock) .apply() } @@ -152,7 +152,7 @@ fun GeneralSettingsScreen(onBack: () -> Unit) { adsBlock, { adsBlock = it - prefs.edit().putBoolean("ads_block_enabled", it).apply() + RuleSetRepository.toggleEnabled(context, "builtin-ads") } ) } diff --git a/app/src/main/java/com/sing/vpn/ui/screens/LogsScreen.kt b/app/src/main/java/com/sing/vpn/ui/screens/LogsScreen.kt index 03a6db2..3f5c223 100644 --- a/app/src/main/java/com/sing/vpn/ui/screens/LogsScreen.kt +++ b/app/src/main/java/com/sing/vpn/ui/screens/LogsScreen.kt @@ -1,5 +1,6 @@ package com.sing.vpn.ui.screens +import android.content.Intent import androidx.compose.foundation.background import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.* @@ -7,14 +8,17 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.FolderOpen import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.core.content.FileProvider import com.sing.vpn.R import com.sing.vpn.ui.components.InlineDivider import com.sing.vpn.ui.theme.* @@ -94,12 +98,39 @@ fun LogsScreen( color = TextPrimary ) } - TextButton(onClick = { - val logFile = File(filesDir, "sing-box.log") - clearOffset = if (logFile.exists()) logFile.length() else 0L - logText = noLogsText - }) { - Text(stringResource(R.string.clear), color = AccentPurple) + Row { + val context = LocalContext.current + IconButton(onClick = { + val logFile = File(filesDir, "sing-box.log") + if (logFile.exists()) { + try { + val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", logFile) + val intent = Intent(Intent.ACTION_VIEW).apply { + setDataAndType(uri, "text/plain") + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + context.startActivity(Intent.createChooser(intent, "Open log")) + } catch (_: Exception) { + // Fallback: share the file + val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", logFile) + val intent = Intent(Intent.ACTION_SEND).apply { + type = "text/plain" + putExtra(Intent.EXTRA_STREAM, uri) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + context.startActivity(Intent.createChooser(intent, "Share log")) + } + } + }) { + Icon(Icons.Default.FolderOpen, contentDescription = stringResource(R.string.open_log_folder), tint = AccentPurple) + } + TextButton(onClick = { + val logFile = File(filesDir, "sing-box.log") + clearOffset = if (logFile.exists()) logFile.length() else 0L + logText = noLogsText + }) { + Text(stringResource(R.string.clear), color = AccentPurple) + } } } diff --git a/app/src/main/java/com/sing/vpn/ui/screens/RulesScreen.kt b/app/src/main/java/com/sing/vpn/ui/screens/RulesScreen.kt new file mode 100644 index 0000000..a6c7e93 --- /dev/null +++ b/app/src/main/java/com/sing/vpn/ui/screens/RulesScreen.kt @@ -0,0 +1,653 @@ +package com.sing.vpn.ui.screens + +import android.content.Context +import android.widget.Toast +import androidx.compose.animation.animateColorAsState +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material3.* +import androidx.compose.runtime.* +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.text.font.FontFamily +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.sing.vpn.R +import com.sing.vpn.Rule +import com.sing.vpn.RuleOutbound +import com.sing.vpn.RuleRepository +import com.sing.vpn.RuleSet +import com.sing.vpn.RuleSetRepository +import com.sing.vpn.RuleSetSource +import com.sing.vpn.RuleType +import com.sing.vpn.ui.components.InlineDivider +import com.sing.vpn.ui.components.SectionHeader +import com.sing.vpn.ui.theme.* + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun RulesScreen( + onBack: () -> Unit, + modifier: Modifier = Modifier +) { + val context = LocalContext.current + var selectedTab by remember { mutableIntStateOf(0) } + // Custom rules state + var rules by remember { mutableStateOf(RuleRepository.getAll(context)) } + var showAddDialog by remember { mutableStateOf(false) } + var editingRule by remember { mutableStateOf(null) } + // Rule sets state + var ruleSets by remember { mutableStateOf(RuleSetRepository.getAll(context)) } + var showAddRuleSetDialog by remember { mutableStateOf(false) } + + fun refreshRules() { rules = RuleRepository.getAll(context) } + fun refreshRuleSets() { ruleSets = RuleSetRepository.getAll(context) } + + Column( + modifier = modifier + .fillMaxSize() + .background(Background) + ) { + // Header + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + IconButton(onClick = onBack) { + Icon( + Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(R.string.cd_back), + tint = TextPrimary + ) + } + Text( + text = stringResource(R.string.title_rules), + style = MaterialTheme.typography.headlineLarge, + color = TextPrimary, + modifier = Modifier.weight(1f) + ) + IconButton(onClick = { + if (selectedTab == 0) showAddDialog = true else showAddRuleSetDialog = true + }) { + Icon( + Icons.Default.Add, + contentDescription = stringResource(R.string.rule_add), + tint = AccentPurple + ) + } + } + + // Tabs + TabRow( + selectedTabIndex = selectedTab, + containerColor = Background, + contentColor = AccentPurple + ) { + Tab(selected = selectedTab == 0, onClick = { selectedTab = 0 }, + text = { Text(stringResource(R.string.tab_custom_rules)) }, + selectedContentColor = AccentPurple, + unselectedContentColor = TextTertiary + ) + Tab(selected = selectedTab == 1, onClick = { selectedTab = 1 }, + text = { Text(stringResource(R.string.tab_rule_sets)) }, + selectedContentColor = AccentPurple, + unselectedContentColor = TextTertiary + ) + } + + when (selectedTab) { + 0 -> CustomRulesTab( + rules = rules, + onToggle = { id -> RuleRepository.toggleEnabled(context, id); refreshRules() }, + onEdit = { rule -> editingRule = rule; showAddDialog = true }, + onDelete = { id -> RuleRepository.delete(context, id); refreshRules() } + ) + 1 -> RuleSetsTab( + ruleSets = ruleSets, + onToggle = { id -> RuleSetRepository.toggleEnabled(context, id); refreshRuleSets() }, + onDelete = { id -> RuleSetRepository.deleteRemote(context, id); refreshRuleSets() } + ) + } + } + + // Add/Edit custom rule dialog + if (showAddDialog) { + AddEditRuleDialog( + existing = editingRule, + onDismiss = { showAddDialog = false; editingRule = null }, + onSave = { type, pattern, outbound -> + if (editingRule != null) { + RuleRepository.update(context, editingRule!!.copy(type = type, pattern = pattern, outbound = outbound)) + } else { + RuleRepository.add(context, type, pattern, outbound) + } + showAddDialog = false; editingRule = null; refreshRules() + } + ) + } + + // Add remote rule set dialog + if (showAddRuleSetDialog) { + AddRuleSetDialog( + onDismiss = { showAddRuleSetDialog = false }, + onSave = { name, url, outbound, interval -> + RuleSetRepository.addRemote(context, name, url, outbound, interval) + showAddRuleSetDialog = false; refreshRuleSets() + } + ) + } +} + +@Composable +private fun CustomRulesTab( + rules: List, + onToggle: (Long) -> Unit, + onEdit: (Rule) -> Unit, + onDelete: (Long) -> Unit +) { + var filterOutbound by remember { mutableStateOf(null) } + + // Filter chips + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + FilterChipItem("All", filterOutbound == null) { filterOutbound = null } + FilterChipItem("Proxy", filterOutbound == RuleOutbound.PROXY, AccentPurple) { filterOutbound = RuleOutbound.PROXY } + FilterChipItem("Direct", filterOutbound == RuleOutbound.DIRECT, Green) { filterOutbound = RuleOutbound.DIRECT } + FilterChipItem("Block", filterOutbound == RuleOutbound.BLOCK, Red) { filterOutbound = RuleOutbound.BLOCK } + } + + val filtered = if (filterOutbound != null) rules.filter { it.outbound == filterOutbound } else rules + + if (filtered.isEmpty()) { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text(stringResource(R.string.rules_empty), color = TextTertiary, style = MaterialTheme.typography.bodyMedium) + } + } else { + LazyColumn(Modifier.fillMaxSize()) { + itemsIndexed(filtered, key = { _, rule -> rule.id }) { _, rule -> + RuleItem( + rule = rule, + onToggle = { onToggle(rule.id) }, + onClick = { onEdit(rule) }, + onDelete = { onDelete(rule.id) } + ) + InlineDivider(Modifier.padding(horizontal = 16.dp)) + } + item { Spacer(Modifier.height(80.dp)) } + } + } +} + +@Composable +private fun RuleSetsTab( + ruleSets: List, + onToggle: (String) -> Unit, + onDelete: (String) -> Unit +) { + val proxyList = ruleSets.filter { it.outbound == RuleOutbound.PROXY } + val directList = ruleSets.filter { it.outbound == RuleOutbound.DIRECT } + val blockList = ruleSets.filter { it.outbound == RuleOutbound.BLOCK } + + LazyColumn( + Modifier.fillMaxSize().padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + item { Spacer(Modifier.height(8.dp)) } + + if (proxyList.isNotEmpty()) { + item { SectionHeader(stringResource(R.string.rule_set_section_proxy)) } + proxyList.forEach { rs -> + item(key = rs.id) { + RuleSetItem(rs, onToggle = { onToggle(rs.id) }, + onDelete = if (rs.source == RuleSetSource.REMOTE) {{ onDelete(rs.id) }} else null) + } + } + } + + if (directList.isNotEmpty()) { + item { Spacer(Modifier.height(8.dp)) } + item { SectionHeader(stringResource(R.string.rule_set_section_direct)) } + directList.forEach { rs -> + item(key = rs.id) { + RuleSetItem(rs, onToggle = { onToggle(rs.id) }, + onDelete = if (rs.source == RuleSetSource.REMOTE) {{ onDelete(rs.id) }} else null) + } + } + } + + if (blockList.isNotEmpty()) { + item { Spacer(Modifier.height(8.dp)) } + item { SectionHeader(stringResource(R.string.rule_set_section_block)) } + blockList.forEach { rs -> + item(key = rs.id) { + RuleSetItem(rs, onToggle = { onToggle(rs.id) }, + onDelete = if (rs.source == RuleSetSource.REMOTE) {{ onDelete(rs.id) }} else null) + } + } + } + + item { Spacer(Modifier.height(80.dp)) } + } +} + +@Composable +private fun RuleSetItem( + ruleSet: RuleSet, + onToggle: () -> Unit, + onDelete: (() -> Unit)? = null +) { + val outboundColor = when (ruleSet.outbound) { + RuleOutbound.PROXY -> AccentPurple + RuleOutbound.DIRECT -> Green + RuleOutbound.BLOCK -> Red + } + val alpha = if (ruleSet.enabled) 1f else 0.4f + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = Modifier + .width(3.dp) + .height(32.dp) + .clip(RoundedCornerShape(2.dp)) + .background(outboundColor.copy(alpha = alpha)) + ) + Spacer(Modifier.width(12.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = ruleSet.name, + style = MaterialTheme.typography.bodyMedium, + color = TextPrimary.copy(alpha = alpha), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Text( + text = if (ruleSet.source == RuleSetSource.BUILTIN) stringResource(R.string.rule_set_source_builtin) + else ruleSet.url.take(40), + style = MaterialTheme.typography.bodySmall, + color = TextTertiary.copy(alpha = alpha), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + Switch( + checked = ruleSet.enabled, + onCheckedChange = { onToggle() }, + modifier = Modifier.size(36.dp), + colors = SwitchDefaults.colors( + checkedThumbColor = Surface, + checkedTrackColor = outboundColor, + uncheckedThumbColor = Surface, + uncheckedTrackColor = Border + ) + ) + if (onDelete != null) { + Spacer(Modifier.width(4.dp)) + IconButton(onClick = onDelete, modifier = Modifier.size(32.dp)) { + Icon(Icons.Default.Delete, contentDescription = stringResource(R.string.rule_delete), + tint = TextTertiary, modifier = Modifier.size(18.dp)) + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun AddRuleSetDialog( + onDismiss: () -> Unit, + onSave: (String, String, RuleOutbound, String) -> Unit +) { + var name by remember { mutableStateOf("") } + var url by remember { mutableStateOf("") } + var selectedOutbound by remember { mutableStateOf(RuleOutbound.PROXY) } + var interval by remember { mutableStateOf("1d") } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.rule_set_add), style = MaterialTheme.typography.titleLarge, color = TextPrimary) }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + OutlinedTextField( + value = name, onValueChange = { name = it }, + label = { Text(stringResource(R.string.rule_set_name_label)) }, + singleLine = true, modifier = Modifier.fillMaxWidth(), + colors = OutlinedTextFieldDefaults.colors(focusedBorderColor = AccentPurple, unfocusedBorderColor = Border, cursorColor = AccentPurple, focusedLabelColor = AccentPurple) + ) + OutlinedTextField( + value = url, onValueChange = { url = it }, + label = { Text(stringResource(R.string.rule_set_url_label)) }, + placeholder = { Text(stringResource(R.string.rule_set_url_hint), color = TextTertiary) }, + singleLine = true, modifier = Modifier.fillMaxWidth(), + textStyle = MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace), + colors = OutlinedTextFieldDefaults.colors(focusedBorderColor = AccentPurple, unfocusedBorderColor = Border, cursorColor = AccentPurple, focusedLabelColor = AccentPurple) + ) + Text(stringResource(R.string.rule_outbound_label), style = MaterialTheme.typography.bodySmall, color = TextSecondary) + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + OutboundChip("Proxy", AccentPurple, selectedOutbound == RuleOutbound.PROXY, { selectedOutbound = RuleOutbound.PROXY }, Modifier.weight(1f)) + OutboundChip("Direct", Green, selectedOutbound == RuleOutbound.DIRECT, { selectedOutbound = RuleOutbound.DIRECT }, Modifier.weight(1f)) + OutboundChip("Block", Red, selectedOutbound == RuleOutbound.BLOCK, { selectedOutbound = RuleOutbound.BLOCK }, Modifier.weight(1f)) + } + } + }, + confirmButton = { + TextButton(onClick = { if (name.isNotBlank() && url.isNotBlank()) onSave(name.trim(), url.trim(), selectedOutbound, interval) }, + enabled = name.isNotBlank() && url.isNotBlank()) { + Text(stringResource(R.string.save), color = if (name.isNotBlank() && url.isNotBlank()) AccentPurple else TextTertiary) + } + }, + dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.cancel), color = TextSecondary) } } + ) +} + +@Composable +private fun FilterChipItem( + label: String, + selected: Boolean, + activeColor: androidx.compose.ui.graphics.Color = AccentPurple, + onClick: () -> Unit +) { + val bg by animateColorAsState( + if (selected) activeColor.copy(alpha = 0.12f) else SurfaceHover, + label = "chip_bg" + ) + val textColor by animateColorAsState( + if (selected) activeColor else TextSecondary, + label = "chip_text" + ) + Box( + modifier = Modifier + .clip(RoundedCornerShape(16.dp)) + .background(bg) + .clickable(onClick = onClick) + .padding(horizontal = 12.dp, vertical = 6.dp) + ) { + Text(label, style = MaterialTheme.typography.labelMedium, color = textColor) + } +} + +@Composable +private fun RuleItem( + rule: Rule, + onToggle: () -> Unit, + onClick: () -> Unit, + onDelete: () -> Unit +) { + val outboundColor = when (rule.outbound) { + RuleOutbound.PROXY -> AccentPurple + RuleOutbound.DIRECT -> Green + RuleOutbound.BLOCK -> Red + } + val alpha = if (rule.enabled) 1f else 0.4f + + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(horizontal = 16.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically + ) { + // Color indicator + Box( + modifier = Modifier + .width(3.dp) + .height(32.dp) + .clip(RoundedCornerShape(2.dp)) + .background(outboundColor.copy(alpha = alpha)) + ) + Spacer(Modifier.width(12.dp)) + + // Content + Column(modifier = Modifier.weight(1f)) { + Text( + text = rule.pattern, + style = MaterialTheme.typography.bodyMedium.copy( + fontFamily = FontFamily.Monospace + ), + color = TextPrimary.copy(alpha = alpha), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = rule.type.label, + style = MaterialTheme.typography.bodySmall, + color = TextTertiary.copy(alpha = alpha) + ) + Text( + text = rule.outbound.label, + style = MaterialTheme.typography.bodySmall, + color = outboundColor.copy(alpha = alpha) + ) + } + } + + // Enable/disable switch + Switch( + checked = rule.enabled, + onCheckedChange = { onToggle() }, + modifier = Modifier.size(36.dp), + colors = SwitchDefaults.colors( + checkedThumbColor = Surface, + checkedTrackColor = outboundColor, + uncheckedThumbColor = Surface, + uncheckedTrackColor = Border + ) + ) + + Spacer(Modifier.width(4.dp)) + + // Delete button + IconButton(onClick = onDelete, modifier = Modifier.size(32.dp)) { + Icon( + Icons.Default.Delete, + contentDescription = stringResource(R.string.rule_delete), + tint = TextTertiary, + modifier = Modifier.size(18.dp) + ) + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun AddEditRuleDialog( + existing: Rule?, + onDismiss: () -> Unit, + onSave: (RuleType, String, RuleOutbound) -> Unit +) { + var selectedType by remember { mutableStateOf(existing?.type ?: RuleType.DOMAIN_KEYWORD) } + var pattern by remember { mutableStateOf(existing?.pattern ?: "") } + var selectedOutbound by remember { mutableStateOf(existing?.outbound ?: RuleOutbound.PROXY) } + var typeExpanded by remember { mutableStateOf(false) } + val isEdit = existing != null + + AlertDialog( + onDismissRequest = onDismiss, + title = { + Text( + if (isEdit) stringResource(R.string.rule_edit) else stringResource(R.string.rule_add), + style = MaterialTheme.typography.titleLarge, + color = TextPrimary + ) + }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { + // Rule type dropdown + ExposedDropdownMenuBox( + expanded = typeExpanded, + onExpandedChange = { typeExpanded = it } + ) { + OutlinedTextField( + value = selectedType.label, + onValueChange = {}, + readOnly = true, + label = { Text(stringResource(R.string.rule_type_label)) }, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = typeExpanded) }, + modifier = Modifier + .fillMaxWidth() + .menuAnchor(), + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = AccentPurple, + unfocusedBorderColor = Border, + focusedLabelColor = AccentPurple + ) + ) + ExposedDropdownMenu( + expanded = typeExpanded, + onDismissRequest = { typeExpanded = false } + ) { + RuleType.entries.forEach { type -> + DropdownMenuItem( + text = { + Column { + Text(type.label, color = TextPrimary) + Text(type.hint, style = MaterialTheme.typography.bodySmall, color = TextTertiary) + } + }, + onClick = { + selectedType = type + typeExpanded = false + } + ) + } + } + } + + // Pattern input + OutlinedTextField( + value = pattern, + onValueChange = { pattern = it }, + label = { Text(stringResource(R.string.rule_pattern_label)) }, + placeholder = { Text(selectedType.hint, color = TextTertiary) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + textStyle = MaterialTheme.typography.bodyMedium.copy( + fontFamily = FontFamily.Monospace + ), + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = AccentPurple, + unfocusedBorderColor = Border, + cursorColor = AccentPurple, + focusedLabelColor = AccentPurple + ) + ) + + // Outbound selector + Text( + stringResource(R.string.rule_outbound_label), + style = MaterialTheme.typography.bodySmall, + color = TextSecondary + ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + OutboundChip( + label = "Proxy", + color = AccentPurple, + selected = selectedOutbound == RuleOutbound.PROXY, + onClick = { selectedOutbound = RuleOutbound.PROXY }, + modifier = Modifier.weight(1f) + ) + OutboundChip( + label = "Direct", + color = Green, + selected = selectedOutbound == RuleOutbound.DIRECT, + onClick = { selectedOutbound = RuleOutbound.DIRECT }, + modifier = Modifier.weight(1f) + ) + OutboundChip( + label = "Block", + color = Red, + selected = selectedOutbound == RuleOutbound.BLOCK, + onClick = { selectedOutbound = RuleOutbound.BLOCK }, + modifier = Modifier.weight(1f) + ) + } + } + }, + confirmButton = { + TextButton( + onClick = { + val trimmed = pattern.trim() + if (trimmed.isNotEmpty()) { + onSave(selectedType, trimmed, selectedOutbound) + } + }, + enabled = pattern.isNotBlank() + ) { + Text( + stringResource(R.string.save), + color = if (pattern.isNotBlank()) AccentPurple else TextTertiary + ) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.cancel), color = TextSecondary) + } + } + ) +} + +@Composable +private fun OutboundChip( + label: String, + color: androidx.compose.ui.graphics.Color, + selected: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + val bg by animateColorAsState( + if (selected) color.copy(alpha = 0.15f) else SurfaceHover, + label = "outbound_bg" + ) + val borderColor by animateColorAsState( + if (selected) color else Border, + label = "outbound_border" + ) + val textColor by animateColorAsState( + if (selected) color else TextSecondary, + label = "outbound_text" + ) + Surface( + modifier = modifier, + shape = RoundedCornerShape(10.dp), + color = bg, + border = androidx.compose.foundation.BorderStroke(1.dp, borderColor), + onClick = onClick + ) { + Text( + text = label, + style = MaterialTheme.typography.labelLarge, + color = textColor, + modifier = Modifier + .padding(vertical = 10.dp) + .wrapContentWidth(Alignment.CenterHorizontally) + .fillMaxWidth(), + textAlign = androidx.compose.ui.text.style.TextAlign.Center + ) + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 3c50ca3..22cf895 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -28,7 +28,7 @@ Profile deleted %d nodes updated Edit Profile - %s / %s + %1$s / %2$s General @@ -90,6 +90,7 @@ 0.5 Engine mini-sing + Build %d nodes @@ -120,22 +121,39 @@ Clear + Open log file No logs yet - - Proxy Domains - Direct Domains - Block Domains + Rules - One keyword per line. Matched against domain names. - example.com\ngoogle\nyoutube + %1$d total, %2$d enabled + No rules yet. Tap + to add one. + Add Rule + Edit Rule + Delete + Match Type + Pattern + Route to + Custom Rules + Rule Sets + Add Rule Set + Name + URL + https://raw.githubusercontent.com/… + Built-in + Remote + Proxy Services + Direct Services + Block + Save + Cancel Add Rule for %s → Direct (bypass proxy) → Proxy → Block - Rule added: %s → %s + Rule added: %1$s → %2$s Close connection diff --git a/app/src/main/res/xml/file_paths.xml b/app/src/main/res/xml/file_paths.xml new file mode 100644 index 0000000..4f7310f --- /dev/null +++ b/app/src/main/res/xml/file_paths.xml @@ -0,0 +1,4 @@ + + + +