Add unified rule management with rule set catalog and smart dedup

- 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
This commit is contained in:
Sing Dev
2026-04-04 05:00:40 +08:00
parent bfc5b675e3
commit c53d943150
17 changed files with 1387 additions and 363 deletions

View File

@@ -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 {

View File

@@ -51,5 +51,15 @@
</intent-filter>
</receiver>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
</application>
</manifest>

View File

@@ -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() }
)
}

View File

@@ -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) { "{}" }
}

View File

@@ -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<String> = 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 = "",

View File

@@ -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 */

View File

@@ -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<Rule> {
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<Rule> = getAll(ctx).filter { it.enabled }
/** Save the full rule list (order is derived from list index). */
fun saveAll(ctx: Context, rules: List<Rule>) {
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<Rule>()
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()
}
}
}

View File

@@ -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<RuleSet> {
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<RuleSet> = getAll(ctx).filter { it.enabled }
/** Save the full catalog. */
fun saveAll(ctx: Context, sets: List<RuleSet>) {
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)
}
}
}

View File

@@ -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<String> = emptyList(),
val domainSuffixes: List<String> = emptyList(),
val domainKeywords: List<String> = emptyList(),
val ipCidrs: List<String> = emptyList()
)
private data class CoverageIndex(
val domains: MutableSet<String> = linkedSetOf(),
val domainSuffixes: MutableSet<String> = linkedSetOf(),
val domainKeywords: MutableSet<String> = linkedSetOf(),
val ipCidrs: MutableList<Pair<InetAddress, Int>> = 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<String>()
val suffixes = mutableListOf<String>()
val keywords = mutableListOf<String>()
val cidrs = mutableListOf<String>()
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<String>): RuleSetData {
val domains = mutableListOf<String>()
val suffixes = mutableListOf<String>()
val keywords = mutableListOf<String>()
val cidrs = mutableListOf<String>()
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<String>): 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<InetAddress, Int>? {
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<InetAddress, Int>, child: Pair<InetAddress, Int>): 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<RuleSetData, RuleSetData, RuleSetData> {
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<Rule>, outbound: String): JSONObject? {
if (rules.isEmpty()) return null
val domains = mutableListOf<String>()
val suffixes = mutableListOf<String>()
val keywords = mutableListOf<String>()
val cidrs = mutableListOf<String>()
val processes = mutableListOf<String>()
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<Rule>): List<Rule> {
// Step 1: Remove exact duplicates (same type + pattern, case-insensitive)
val seen = mutableSetOf<String>()
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<Rule>): 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<InetAddress, Int>? {
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<InetAddress, Int>, child: Pair<InetAddress, Int>): 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

View File

@@ -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)
}
}

View File

@@ -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)

View File

@@ -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)
)
}
}

View File

@@ -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")
}
)
}

View File

@@ -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)
}
}
}

View File

@@ -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<Rule?>(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<Rule>,
onToggle: (Long) -> Unit,
onEdit: (Rule) -> Unit,
onDelete: (Long) -> Unit
) {
var filterOutbound by remember { mutableStateOf<RuleOutbound?>(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<RuleSet>,
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
)
}
}

View File

@@ -28,7 +28,7 @@
<string name="profile_deleted">Profile deleted</string>
<string name="profile_synced">%d nodes updated</string>
<string name="edit_profile">Edit Profile</string>
<string name="usage_label">%s / %s</string>
<string name="usage_label">%1$s / %2$s</string>
<!-- Settings sub-screens -->
<string name="settings_general">General</string>
@@ -90,6 +90,7 @@
<string name="version_value">0.5</string>
<string name="engine_label">Engine</string>
<string name="engine_value">mini-sing</string>
<string name="build_time_label">Build</string>
<!-- Nodes -->
<string name="nodes_count">%d nodes</string>
@@ -120,22 +121,39 @@
<!-- Logs -->
<string name="clear">Clear</string>
<string name="open_log_folder">Open log file</string>
<string name="no_logs_yet">No logs yet</string>
<!-- Edit Rules -->
<string name="title_proxy_domains">Proxy Domains</string>
<string name="title_direct_domains">Direct Domains</string>
<string name="title_block_domains">Block Domains</string>
<!-- Rules -->
<string name="title_rules">Rules</string>
<string name="rules_description">One keyword per line. Matched against domain names.</string>
<string name="placeholder_rules">example.com\ngoogle\nyoutube</string>
<string name="rules_stats">%1$d total, %2$d enabled</string>
<string name="rules_empty">No rules yet. Tap + to add one.</string>
<string name="rule_add">Add Rule</string>
<string name="rule_edit">Edit Rule</string>
<string name="rule_delete">Delete</string>
<string name="rule_type_label">Match Type</string>
<string name="rule_pattern_label">Pattern</string>
<string name="rule_outbound_label">Route to</string>
<string name="tab_custom_rules">Custom Rules</string>
<string name="tab_rule_sets">Rule Sets</string>
<string name="rule_set_add">Add Rule Set</string>
<string name="rule_set_name_label">Name</string>
<string name="rule_set_url_label">URL</string>
<string name="rule_set_url_hint">https://raw.githubusercontent.com/…</string>
<string name="rule_set_source_builtin">Built-in</string>
<string name="rule_set_source_remote">Remote</string>
<string name="rule_set_section_proxy">Proxy Services</string>
<string name="rule_set_section_direct">Direct Services</string>
<string name="rule_set_section_block">Block</string>
<string name="save">Save</string>
<string name="cancel">Cancel</string>
<!-- Quick rule -->
<string name="quick_rule_title">Add Rule for %s</string>
<string name="route_to_direct">→ Direct (bypass proxy)</string>
<string name="route_to_proxy">→ Proxy</string>
<string name="route_to_block">→ Block</string>
<string name="rule_added">Rule added: %s → %s</string>
<string name="rule_added">Rule added: %1$s → %2$s</string>
<string name="close_connection">Close connection</string>
<!-- Content descriptions -->

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<paths>
<files-path name="files" path="." />
</paths>