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:
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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() }
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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) { "{}" }
|
||||
}
|
||||
|
||||
@@ -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 = "",
|
||||
|
||||
@@ -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 */
|
||||
|
||||
213
app/src/main/java/com/sing/vpn/Rule.kt
Normal file
213
app/src/main/java/com/sing/vpn/Rule.kt
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
184
app/src/main/java/com/sing/vpn/RuleSet.kt
Normal file
184
app/src/main/java/com/sing/vpn/RuleSet.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
653
app/src/main/java/com/sing/vpn/ui/screens/RulesScreen.kt
Normal file
653
app/src/main/java/com/sing/vpn/ui/screens/RulesScreen.kt
Normal 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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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 -->
|
||||
|
||||
4
app/src/main/res/xml/file_paths.xml
Normal file
4
app/src/main/res/xml/file_paths.xml
Normal file
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<paths>
|
||||
<files-path name="files" path="." />
|
||||
</paths>
|
||||
Reference in New Issue
Block a user