瀏覽代碼

Route settings

世界 4 年之前
父節點
當前提交
0cd9b5f033
共有 35 個文件被更改,包括 1482 次插入420 次删除
  1. 1 0
      app/src/main/AndroidManifest.xml
  2. 11 0
      app/src/main/java/io/nekohasekai/sagernet/Constants.kt
  3. 1 0
      app/src/main/java/io/nekohasekai/sagernet/bg/GuardedProcessPool.kt
  4. 13 0
      app/src/main/java/io/nekohasekai/sagernet/database/DataStore.kt
  5. 80 3
      app/src/main/java/io/nekohasekai/sagernet/database/ProfileManager.kt
  6. 22 18
      app/src/main/java/io/nekohasekai/sagernet/database/ProxyEntity.kt
  7. 37 4
      app/src/main/java/io/nekohasekai/sagernet/database/RuleEntity.kt
  8. 15 5
      app/src/main/java/io/nekohasekai/sagernet/database/preference/RoomPreferenceDataStore.kt
  9. 28 47
      app/src/main/java/io/nekohasekai/sagernet/fmt/ConfigBuilder.kt
  10. 7 5
      app/src/main/java/io/nekohasekai/sagernet/fmt/trojan/TrojanFmt.kt
  11. 3 3
      app/src/main/java/io/nekohasekai/sagernet/ktx/Signtures.kt
  12. 5 3
      app/src/main/java/io/nekohasekai/sagernet/ui/ConfigurationFragment.kt
  13. 2 4
      app/src/main/java/io/nekohasekai/sagernet/ui/LicenseActivity.kt
  14. 273 5
      app/src/main/java/io/nekohasekai/sagernet/ui/RouteFragment.kt
  15. 0 141
      app/src/main/java/io/nekohasekai/sagernet/ui/RoutePreferenceFragment.kt
  16. 361 0
      app/src/main/java/io/nekohasekai/sagernet/ui/RouteSettingsActivity.kt
  17. 55 2
      app/src/main/java/io/nekohasekai/sagernet/ui/SettingsPreferenceFragment.kt
  18. 0 1
      app/src/main/java/io/nekohasekai/sagernet/ui/ToolbarFragment.kt
  19. 0 1
      app/src/main/java/io/nekohasekai/sagernet/ui/profile/ProfileSettingsActivity.kt
  20. 1 1
      app/src/main/java/io/nekohasekai/sagernet/ui/profile/StandardV2RaySettingsActivity.kt
  21. 65 0
      app/src/main/java/io/nekohasekai/sagernet/widget/OutboundPreference.kt
  22. 25 0
      app/src/main/res/drawable/ic_baseline_add_road_24.xml
  23. 10 0
      app/src/main/res/drawable/ic_baseline_home_24.xml
  24. 43 0
      app/src/main/res/layout/layout_empty_route.xml
  25. 1 1
      app/src/main/res/layout/layout_profile.xml
  26. 29 0
      app/src/main/res/layout/layout_route.xml
  27. 124 0
      app/src/main/res/layout/layout_route_item.xml
  28. 9 0
      app/src/main/res/menu/add_route_menu.xml
  29. 14 2
      app/src/main/res/values-zh-rCN/strings.xml
  30. 22 1
      app/src/main/res/values/arrays.xml
  31. 10 2
      app/src/main/res/values/strings.xml
  32. 174 68
      app/src/main/res/xml/global_preferences.xml
  33. 40 101
      app/src/main/res/xml/route_preferences.xml
  34. 0 1
      app/src/main/res/xml/shadowsocks_preferences.xml
  35. 1 1
      app/src/main/res/xml/socks_preferences.xml

+ 1 - 0
app/src/main/AndroidManifest.xml

@@ -87,6 +87,7 @@
         <activity android:name="io.nekohasekai.sagernet.ui.profile.TrojanSettingsActivity" />
         <activity android:name="io.nekohasekai.sagernet.ui.profile.TrojanGoSettingsActivity" />
         <activity android:name="io.nekohasekai.sagernet.ui.profile.ChainSettingsActivity" />
+        <activity android:name="io.nekohasekai.sagernet.ui.RouteSettingsActivity" />
 
         <activity
             android:name=".QuickToggleShortcut"

+ 11 - 0
app/src/main/java/io/nekohasekai/sagernet/Constants.kt

@@ -107,6 +107,17 @@ object Key {
     const val SERVER_WS_CATEGORY = "serverWsCategory"
     const val SERVER_SS_CATEGORY = "serverSsCategory"
 
+    const val ROUTE_NAME = "routeName"
+    const val ROUTE_DOMAIN = "routeDomain"
+    const val ROUTE_IP = "routeIP"
+    const val ROUTE_PORT = "routePort"
+    const val ROUTE_SOURCE_PORT = "routeSourcePort"
+    const val ROUTE_NETWORK = "routeNetwork"
+    const val ROUTE_SOURCE = "routeSource"
+    const val ROUTE_PROTOCOL = "routeProtocol"
+    const val ROUTE_OUTBOUND = "routeOutbound"
+    const val ROUTE_OUTBOUND_RULE = "routeOutboundRule"
+
 }
 
 object Action {

+ 1 - 0
app/src/main/java/io/nekohasekai/sagernet/bg/GuardedProcessPool.kt

@@ -60,6 +60,7 @@ class GuardedProcessPool(private val onFatal: suspend (IOException) -> Unit) : C
             }.start()
         }
 
+        @DelicateCoroutinesApi
         suspend fun looper(onRestartCallback: (suspend () -> Unit)?) {
             var running = true
             val cmdName = File(cmd.first()).nameWithoutExtension

+ 13 - 0
app/src/main/java/io/nekohasekai/sagernet/database/DataStore.kt

@@ -168,6 +168,19 @@ object DataStore : OnPreferenceDataStoreChangeListener {
     var serverWsMaxEarlyData by profileCacheStore.stringToInt(Key.SERVER_WS_MAX_EARLY_DATA)
     var serverWsBrowserForwarding by profileCacheStore.boolean(Key.SERVER_WS_BROWSER_FORWARDING)
 
+    var routeName by profileCacheStore.string(Key.ROUTE_NAME)
+    var routeDomain by profileCacheStore.string(Key.ROUTE_DOMAIN)
+    var routeIP by profileCacheStore.string(Key.ROUTE_IP)
+    var routePort by profileCacheStore.string(Key.ROUTE_PORT)
+    var routeSourcePort by profileCacheStore.string(Key.ROUTE_SOURCE_PORT)
+    var routeNetwork by profileCacheStore.string(Key.ROUTE_NETWORK)
+    var routeSource by profileCacheStore.string(Key.ROUTE_SOURCE)
+    var routeProtocol by profileCacheStore.string(Key.ROUTE_PROTOCOL)
+    var routeOutbound by profileCacheStore.stringToInt(Key.ROUTE_OUTBOUND)
+    var routeOutboundRule by profileCacheStore.long(Key.ROUTE_OUTBOUND_RULE)
+
+    var rulesFirstCreate by profileCacheStore.boolean("rulesFirstCreate")
+
     override fun onPreferenceDataStoreChanged(store: PreferenceDataStore, key: String) {
         when (key) {
             Key.PROFILE_ID -> if (directBootAware) DirectBoot.update()

+ 80 - 3
app/src/main/java/io/nekohasekai/sagernet/database/ProfileManager.kt

@@ -46,7 +46,6 @@ import io.nekohasekai.sagernet.utils.DirectBoot
 import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
 import org.yaml.snakeyaml.Yaml
 import java.io.IOException
-import java.lang.Exception
 import java.sql.SQLException
 import java.util.*
 import kotlin.collections.ArrayList
@@ -77,9 +76,16 @@ object ProfileManager {
         }
     }
 
+    interface RuleListener {
+        suspend fun onAdd(rule: RuleEntity)
+        suspend fun onUpdated(rule: RuleEntity)
+        suspend fun onRemoved(ruleId: Long)
+        suspend fun onCleared()
+    }
 
     private val listeners = ArrayList<Listener>()
     private val groupListeners = ArrayList<GroupListener>()
+    private val ruleListeners = ArrayList<RuleListener>()
 
     suspend fun iterator(what: suspend Listener.() -> Unit) {
         val listeners = synchronized(listeners) {
@@ -99,6 +105,15 @@ object ProfileManager {
         }
     }
 
+    suspend fun ruleIterator(what: suspend RuleListener.() -> Unit) {
+        val ruleListeners = synchronized(ruleListeners) {
+            ruleListeners.toList()
+        }
+        for (listener in ruleListeners) {
+            what(listener)
+        }
+    }
+
     fun addListener(listener: Listener) {
         synchronized(listeners) {
             listeners.add(listener)
@@ -117,13 +132,23 @@ object ProfileManager {
         }
     }
 
-
     fun removeListener(listener: GroupListener) {
         synchronized(groupListeners) {
             groupListeners.remove(listener)
         }
     }
 
+    fun addListener(listener: RuleListener) {
+        synchronized(ruleListeners) {
+            ruleListeners.add(listener)
+        }
+    }
+
+    fun removeListener(listener: RuleListener) {
+        synchronized(ruleListeners) {
+            ruleListeners.remove(listener)
+        }
+    }
 
     suspend fun createProfile(groupId: Long, bean: AbstractBean): ProxyEntity {
         val profile = ProxyEntity(groupId = groupId).apply {
@@ -248,6 +273,57 @@ object ProfileManager {
         }
     }
 
+    suspend fun createRule(rule: RuleEntity, post: Boolean = true): RuleEntity {
+        rule.userOrder = SagerDatabase.rulesDao.nextOrder() ?: 1
+        rule.id = SagerDatabase.rulesDao.createRule(rule)
+        if (post) {
+            ruleIterator { onAdd(rule) }
+        }
+        return rule
+    }
+
+    suspend fun updateRule(rule: RuleEntity) {
+        SagerDatabase.rulesDao.updateRule(rule)
+        ruleIterator { onUpdated(rule) }
+    }
+
+    suspend fun deleteRule(ruleId: Long) {
+        SagerDatabase.rulesDao.deleteById(ruleId)
+        ruleIterator { onRemoved(ruleId) }
+    }
+
+    suspend fun getRules(): List<RuleEntity> {
+        var rules = SagerDatabase.rulesDao.allRules()
+        if (rules.isEmpty() /*&& !DataStore.rulesFirstCreate*/) {
+            DataStore.rulesFirstCreate = true
+            val country = Locale.getDefault().country.lowercase()
+            val displayCountry = Locale.getDefault().displayCountry
+            createRule(
+                RuleEntity(
+                    name = app.getString(R.string.route_bypass_ip, displayCountry),
+                    ip = "geoip:$country",
+                    outbound = -1
+                ), false
+            )
+            createRule(
+                RuleEntity(
+                    name = app.getString(R.string.route_bypass_domain, displayCountry),
+                    domains = "geosite:$country",
+                    outbound = -1
+                ), false
+            )
+            createRule(
+                RuleEntity(
+                    name = app.getString(R.string.route_opt_block_ads),
+                    domains = "geosite:category-ads-all",
+                    outbound = -2
+                )
+            )
+            rules = SagerDatabase.rulesDao.allRules()
+        }
+        return rules
+    }
+
     @Suppress("UNCHECKED_CAST")
     fun parseSubscription(text: String): Pair<Int, List<AbstractBean>>? {
 
@@ -394,7 +470,8 @@ object ProfileManager {
         }
 
         try {
-            return 3 to (parseProxies(text.decodeBase64UrlSafe()).takeIf { it.isNotEmpty() } ?: error("Not found"))
+            return 3 to (parseProxies(text.decodeBase64UrlSafe()).takeIf { it.isNotEmpty() }
+                ?: error("Not found"))
         } catch (ignored: Exception) {
         }
 

+ 22 - 18
app/src/main/java/io/nekohasekai/sagernet/database/ProxyEntity.kt

@@ -53,9 +53,11 @@ import io.nekohasekai.sagernet.ktx.Logs
 import io.nekohasekai.sagernet.ktx.app
 import io.nekohasekai.sagernet.ui.profile.*
 
-@Entity(tableName = "proxy_entities", indices = [
-    Index("groupId", name = "groupId")
-])
+@Entity(
+    tableName = "proxy_entities", indices = [
+        Index("groupId", name = "groupId")
+    ]
+)
 data class ProxyEntity(
     @PrimaryKey(autoGenerate = true)
     var id: Long = 0L,
@@ -89,7 +91,8 @@ data class ProxyEntity(
         parcel.readInt(),
         parcel.readLong(),
         parcel.readLong(),
-        parcel.readLong()) {
+        parcel.readLong()
+    ) {
         dirty = parcel.readByte() > 0
         val byteArray = ByteArray(parcel.readInt())
         parcel.readByteArray(byteArray)
@@ -102,7 +105,7 @@ data class ProxyEntity(
             5 -> trojanBean = KryoConverters.trojanDeserialize(byteArray)
             6 -> httpBean = KryoConverters.httpDeserialize(byteArray)
             7 -> trojanGoBean = KryoConverters.trojanGoDeserialize(byteArray)
-            7 -> chainBean = KryoConverters.chainDeserialize(byteArray)
+            8 -> chainBean = KryoConverters.chainDeserialize(byteArray)
         }
     }
 
@@ -317,18 +320,20 @@ data class ProxyEntity(
     fun requireChain() = requireBean() as ChainBean
 
     fun settingIntent(ctx: Context, isSubscription: Boolean): Intent {
-        return Intent(ctx, when (type) {
-            0 -> SocksSettingsActivity::class.java
-            1 -> ShadowsocksSettingsActivity::class.java
-            2 -> ShadowsocksRSettingsActivity::class.java
-            3 -> VMessSettingsActivity::class.java
-            4 -> VLESSSettingsActivity::class.java
-            5 -> TrojanSettingsActivity::class.java
-            6 -> HttpSettingsActivity::class.java
-            7 -> TrojanGoSettingsActivity::class.java
-            8 -> ChainSettingsActivity::class.java
-            else -> throw IllegalArgumentException()
-        }).apply {
+        return Intent(
+            ctx, when (type) {
+                0 -> SocksSettingsActivity::class.java
+                1 -> ShadowsocksSettingsActivity::class.java
+                2 -> ShadowsocksRSettingsActivity::class.java
+                3 -> VMessSettingsActivity::class.java
+                4 -> VLESSSettingsActivity::class.java
+                5 -> TrojanSettingsActivity::class.java
+                6 -> HttpSettingsActivity::class.java
+                7 -> TrojanGoSettingsActivity::class.java
+                8 -> ChainSettingsActivity::class.java
+                else -> throw IllegalArgumentException()
+            }
+        ).apply {
             putExtra(ProfileSettingsActivity.EXTRA_PROFILE_ID, id)
             putExtra(ProfileSettingsActivity.EXTRA_IS_SUBSCRIPTION, isSubscription)
         }
@@ -370,7 +375,6 @@ data class ProxyEntity(
         @Insert
         fun addProxy(proxy: ProxyEntity): Long
 
-
         @Query("DELETE FROM proxy_entities WHERE groupId = :groupId")
         fun deleteAll(groupId: Long): Int
 

+ 37 - 4
app/src/main/java/io/nekohasekai/sagernet/database/RuleEntity.kt

@@ -32,7 +32,7 @@ data class RuleEntity(
     var id: Long = 0L,
     var name: String = "",
     var userOrder: Long = 0L,
-    var enabled: Boolean = true,
+    var enabled: Boolean = false,
     var domains: String = "",
     var ip: String = "",
     var port: String = "",
@@ -44,6 +44,33 @@ data class RuleEntity(
     var outbound: Long = 0,
 ) : Parcelable {
 
+    fun isBypassRule(): Boolean {
+        return (domains.isNotBlank() && ip.isBlank() || ip.isNotBlank() && domains.isBlank()) &&
+                port.isBlank() &&
+                sourcePort.isBlank() &&
+                network.isBlank() &&
+                source.isBlank() &&
+                protocol.isBlank() &&
+                attrs.isBlank() &&
+                outbound == -1L
+    }
+
+    fun displayName(): String {
+        return name.takeIf { it.isNotBlank() } ?: "Rule $id"
+    }
+
+    fun mkSummary(): String {
+        var summary = ""
+        if (domains.isNotBlank()) summary += "$domains\n"
+        if (ip.isNotBlank()) summary += "$ip\n"
+        if (sourcePort.isNotBlank()) summary += "$sourcePort\n"
+        if (network.isNotBlank()) summary += "$network\n"
+        if (source.isNotBlank()) summary += "$source\n"
+        if (protocol.isNotBlank()) summary += "$protocol\n"
+        if (attrs.isNotBlank()) summary += "$attrs\n"
+        return summary.trim()
+    }
+
     @androidx.room.Dao
     interface Dao {
 
@@ -63,13 +90,19 @@ data class RuleEntity(
         fun deleteById(ruleId: Long): Int
 
         @Delete
-        fun deleteRule(vararg group: RuleEntity)
+        fun deleteRule(rule: RuleEntity)
+
+        @Delete
+        fun deleteRules(rules: List<RuleEntity>)
 
         @Insert
-        fun createRule(group: RuleEntity): Long
+        fun createRule(rule: RuleEntity): Long
+
+        @Update
+        fun updateRule(rule: RuleEntity)
 
         @Update
-        fun updateRule(group: RuleEntity)
+        fun updateRules(rules: List<RuleEntity>)
 
     }
 

+ 15 - 5
app/src/main/java/io/nekohasekai/sagernet/database/preference/RoomPreferenceDataStore.kt

@@ -90,12 +90,22 @@ open class RoomPreferenceDataStore(private val kvPairDao: KeyValuePair.Dao) :
     }
 
     private val listeners = HashSet<OnPreferenceDataStoreChangeListener>()
-    private fun fireChangeListener(key: String) =
+    private fun fireChangeListener(key: String) {
+        val listeners = synchronized(listeners) {
+            listeners.toList()
+        }
         listeners.forEach { it.onPreferenceDataStoreChanged(this, key) }
+    }
 
-    fun registerChangeListener(listener: OnPreferenceDataStoreChangeListener) =
-        listeners.add(listener)
+    fun registerChangeListener(listener: OnPreferenceDataStoreChangeListener) {
+        synchronized(listeners) {
+            listeners.add(listener)
+        }
+    }
 
-    fun unregisterChangeListener(listener: OnPreferenceDataStoreChangeListener) =
-        listeners.remove(listener)
+    fun unregisterChangeListener(listener: OnPreferenceDataStoreChangeListener) {
+        synchronized(listeners) {
+            listeners.remove(listener)
+        }
+    }
 }

+ 28 - 47
app/src/main/java/io/nekohasekai/sagernet/fmt/ConfigBuilder.kt

@@ -38,6 +38,7 @@ import io.nekohasekai.sagernet.ktx.formatObject
 import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
 import java.util.*
 import kotlin.collections.HashMap
+import kotlin.collections.HashSet
 import kotlin.collections.LinkedHashMap
 
 const val TAG_SOCKS = "in"
@@ -97,17 +98,6 @@ fun buildV2RayConfig(proxy: ProxyEntity): V2rayBuildResult {
                     valueX = it
                 }
             })
-
-            if (routeChina == 1) {
-                servers.add(DnsObject.StringOrServerObject().apply {
-                    valueY = DnsObject.ServerObject().apply {
-                        address = domesticDns.first()
-                        port = 53
-                        domains = listOf("geosite:cn")
-                        expectIPs = listOf("geoip:cn")
-                    }
-                })
-            }
         }
 
         log = LogObject().apply {
@@ -217,33 +207,6 @@ fun buildV2RayConfig(proxy: ProxyEntity): V2rayBuildResult {
                     ip = listOf("geoip:private")
                 })
             }
-
-            if (DataStore.blockAds) {
-                rules.add(RoutingObject.RuleObject().apply {
-                    type = "field"
-                    outboundTag = TAG_BLOCK
-                    domain = listOf("geosite:category-ads-all")
-                })
-            }
-
-            rules.add(RoutingObject.RuleObject().apply {
-                type = "field"
-                outboundTag = TAG_AGENT
-                domain = listOf("domain:googleapis.cn")
-            })
-
-            if (routeChina > 0) {
-                rules.add(RoutingObject.RuleObject().apply {
-                    type = "field"
-                    outboundTag = if (routeChina == 1) TAG_DIRECT else TAG_BLOCK
-                    ip = listOf("geoip:cn")
-                })
-                rules.add(RoutingObject.RuleObject().apply {
-                    type = "field"
-                    outboundTag = if (routeChina == 1) TAG_DIRECT else TAG_BLOCK
-                    domain = listOf("geosite:cn")
-                })
-            }
         }
 
         var currentPort = socksPort + 10
@@ -663,15 +626,8 @@ fun buildV2RayConfig(proxy: ProxyEntity): V2rayBuildResult {
         }
 
         buildChain(TAG_AGENT, proxies)
-        if (extraProxies.isNotEmpty()) {
-            extraProxies.forEach { (id, entities) ->
-                buildChain("$TAG_AGENT-$id", entities)
-            }
-            routing.rules.add(RoutingObject.RuleObject().apply {
-                type = "field"
-                inboundTag = extraProxies.keys.map { "$TAG_AGENT-$it" }
-                outboundTag = TAG_DIRECT
-            })
+        extraProxies.forEach { (id, entities) ->
+            buildChain("$TAG_AGENT-$id", entities)
         }
         extraRules.forEach { rule ->
             routing.rules.add(RoutingObject.RuleObject().apply {
@@ -709,6 +665,30 @@ fun buildV2RayConfig(proxy: ProxyEntity): V2rayBuildResult {
             })
         }
 
+        val bypassIP = HashSet<String>()
+        val bypassDomain = HashSet<String>()
+        for (bypassRule in extraRules.filter { it.isBypassRule() }) {
+            if (bypassRule.domains.isNotBlank()) {
+                bypassDomain.addAll(bypassRule.domains.split("\n"))
+            } else if (bypassRule.ip.isNotBlank()) {
+                bypassIP.addAll(bypassRule.ip.split("\n"))
+            }
+        }
+        if (bypassIP.isNotEmpty() || bypassDomain.isNotEmpty()) {
+            dns.servers.add(DnsObject.StringOrServerObject().apply {
+                valueY = DnsObject.ServerObject().apply {
+                    address = domesticDns.first()
+                    port = 53
+                    if (bypassIP.isNotEmpty()) {
+                        expectIPs = bypassIP.toList()
+                    }
+                    if (bypassDomain.isNotEmpty()) {
+                        domains = bypassDomain.toList()
+                    }
+                }
+            })
+        }
+
         if (requireWs) {
             browserForwarder = BrowserForwarderObject().apply {
                 listenAddr = "127.0.0.1"
@@ -966,6 +946,7 @@ fun buildXrayConfig(proxy: ProxyEntity, localPort: Int, chain: Boolean, index: I
                     tag = "front"
                 }
             )
+
             outbound.proxySettings = OutboundObject.ProxySettingsObject().apply {
                 tag = "front"
                 transportLayer = true

+ 7 - 5
app/src/main/java/io/nekohasekai/sagernet/fmt/trojan/TrojanFmt.kt

@@ -23,10 +23,12 @@ package io.nekohasekai.sagernet.fmt.trojan
 
 import io.nekohasekai.sagernet.ktx.linkBuilder
 import io.nekohasekai.sagernet.ktx.toLink
+import io.nekohasekai.sagernet.ktx.unUrlSafe
 import io.nekohasekai.sagernet.ktx.urlSafe
-import okhttp3.HttpUrl
 import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
 
+// WTF
+// https://github.com/trojan-gfw/igniter/issues/318
 fun parseTrojan(server: String): TrojanBean {
 
     val link = server.replace("trojan://", "https://").toHttpUrlOrNull()
@@ -35,11 +37,10 @@ fun parseTrojan(server: String): TrojanBean {
     return TrojanBean().apply {
         serverAddress = link.host
         serverPort = link.port
-        password = link.username
+        password = link.username.unUrlSafe()
 
         if (link.password.isNotBlank()) {
-            // https://github.com/trojan-gfw/igniter/issues/318
-            password += ":" + link.password
+            password += ":" + link.password.unUrlSafe()
         }
 
         security = link.queryParameter("security") ?: "tls"
@@ -66,7 +67,8 @@ fun TrojanBean.toUri(): String {
     }
 
     when (security) {
-        "tls" -> {}
+        "tls" -> {
+        }
         "xtls" -> {
             builder.addQueryParameter("security", security)
             builder.addQueryParameter("flow", flow)

+ 3 - 3
app/src/main/java/io/nekohasekai/sagernet/ktx/Signtures.kt

@@ -71,8 +71,6 @@ fun Context.isVerified(): Boolean {
 }
 
 fun Context.checkMT() {
-    if (isVerified()) return
-
     val fuckMT = block {
         Thread.setDefaultUncaughtExceptionHandler(null)
         Thread.currentThread().uncaughtExceptionHandler = null
@@ -92,6 +90,8 @@ fun Context.checkMT() {
     } catch (ignored: ClassNotFoundException) {
     }
 
+    if (isVerified()) return
+
     val manifestMF = javaClass.getResourceAsStream("/META-INF/MANIFEST.MF")
     if (manifestMF == null) {
         Logs.w("/META-INF/MANIFEST.MF not found")
@@ -99,7 +99,7 @@ fun Context.checkMT() {
     }
 
     val input = manifestMF.bufferedReader()
-    val headers = input.use { (0 until 3).map { readLine() } }.joinToString("\n")
+    val headers = input.use { (0 until 5).map { readLine() } }.joinToString("\n")
 
     // WTF version?
     if (headers.contains("Android Gradle 3.5.0")) {

+ 5 - 3
app/src/main/java/io/nekohasekai/sagernet/ui/ConfigurationFragment.kt

@@ -441,9 +441,11 @@ class ConfigurationFragment @JvmOverloads constructor(
                     override fun getSwipeDirs(
                         recyclerView: RecyclerView,
                         viewHolder: RecyclerView.ViewHolder,
-                    ) = if (isProfileEditable((viewHolder).itemId)) {
-                        super.getSwipeDirs(recyclerView, viewHolder)
-                    } else 0
+                    ): Int {
+                        return if (isProfileEditable((viewHolder as ConfigurationHolder).entity.id)) {
+                            super.getSwipeDirs(recyclerView, viewHolder)
+                        } else 0
+                    }
 
                     override fun getDragDirs(
                         recyclerView: RecyclerView,

+ 2 - 4
app/src/main/java/io/nekohasekai/sagernet/ui/LicenseActivity.kt

@@ -22,12 +22,9 @@
 package io.nekohasekai.sagernet.ui
 
 import android.os.Bundle
-import android.widget.TextView
 import androidx.appcompat.app.AppCompatActivity
 import com.mikepenz.aboutlibraries.LibsBuilder
 import io.nekohasekai.sagernet.R
-import io.nekohasekai.sagernet.ktx.onMainDispatcher
-import io.nekohasekai.sagernet.ktx.runOnDefaultDispatcher
 import io.nekohasekai.sagernet.widget.ListHolderListener
 
 class LicenseActivity : AppCompatActivity() {
@@ -50,7 +47,8 @@ class LicenseActivity : AppCompatActivity() {
             .withExcludedLibraries(
                 // Can't parse ${project.artifactId} in pom.xml
                 "cn_hutool__hutool_core",
-                "cn_hutool__hutool_json"
+                "cn_hutool__hutool_json",
+                "cn_hutool__hutool_crypto"
             )
             .supportFragment()
 

+ 273 - 5
app/src/main/java/io/nekohasekai/sagernet/ui/RouteFragment.kt

@@ -21,23 +21,291 @@
 
 package io.nekohasekai.sagernet.ui
 
+import android.content.Intent
+import android.net.Uri
 import android.os.Bundle
+import android.view.MenuItem
 import android.view.View
+import android.view.ViewGroup
+import android.widget.ImageView
+import android.widget.LinearLayout
+import android.widget.TextView
+import androidx.appcompat.widget.SwitchCompat
+import androidx.appcompat.widget.Toolbar
 import androidx.core.view.ViewCompat
+import androidx.recyclerview.widget.ItemTouchHelper
+import androidx.recyclerview.widget.RecyclerView
 import io.nekohasekai.sagernet.R
+import io.nekohasekai.sagernet.database.ProfileManager
+import io.nekohasekai.sagernet.database.RuleEntity
+import io.nekohasekai.sagernet.database.SagerDatabase
+import io.nekohasekai.sagernet.ktx.FixedLinearLayoutManager
+import io.nekohasekai.sagernet.ktx.addOverScrollListener
+import io.nekohasekai.sagernet.ktx.launchCustomTab
+import io.nekohasekai.sagernet.ktx.runOnDefaultDispatcher
 import io.nekohasekai.sagernet.widget.ListHolderListener
+import io.nekohasekai.sagernet.widget.UndoSnackbarManager
 
-class RouteFragment : ToolbarFragment(R.layout.layout_settings_activity) {
+class RouteFragment : ToolbarFragment(R.layout.layout_route), Toolbar.OnMenuItemClickListener {
+
+    lateinit var activity: MainActivity
+    lateinit var ruleListView: RecyclerView
+    lateinit var ruleAdapter: RuleAdapter
+    lateinit var undoManager: UndoSnackbarManager<RuleEntity>
 
     override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
         super.onViewCreated(view, savedInstanceState)
 
+        activity = requireActivity() as MainActivity
+
         ViewCompat.setOnApplyWindowInsetsListener(view, ListHolderListener)
-        toolbar.setTitle(R.string.cag_route)
+        toolbar.setTitle(R.string.menu_route)
+        toolbar.inflateMenu(R.menu.add_route_menu)
+        toolbar.setOnMenuItemClickListener(this)
+
+        ruleListView = view.findViewById(R.id.route_list)
+        ruleListView.layoutManager = FixedLinearLayoutManager(view.context)
+        ruleAdapter = RuleAdapter()
+        ProfileManager.addListener(ruleAdapter)
+        ruleListView.adapter = ruleAdapter
+        addOverScrollListener(ruleListView)
+        undoManager = UndoSnackbarManager(activity, ruleAdapter)
+
+        ItemTouchHelper(object :
+            ItemTouchHelper.SimpleCallback(
+                ItemTouchHelper.UP or ItemTouchHelper.DOWN,
+                ItemTouchHelper.START
+            ) {
+
+            override fun getSwipeDirs(
+                recyclerView: RecyclerView,
+                viewHolder: RecyclerView.ViewHolder,
+            ) = if (viewHolder.adapterPosition == 0) {
+                0
+            } else {
+                super.getSwipeDirs(recyclerView, viewHolder)
+            }
+
+            override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {
+                val index = viewHolder.adapterPosition
+                ruleAdapter.remove(index)
+                undoManager.remove(index to (viewHolder as RuleAdapter.RuleHolder).rule)
+            }
+
+            override fun onMove(
+                recyclerView: RecyclerView,
+                viewHolder: RecyclerView.ViewHolder, target: RecyclerView.ViewHolder,
+            ): Boolean {
+                return if (target is RuleAdapter.DocumentHolder) {
+                    false
+                } else {
+                    ruleAdapter.move(viewHolder.adapterPosition, target.adapterPosition)
+                    true
+                }
+            }
+
+            override fun clearView(
+                recyclerView: RecyclerView,
+                viewHolder: RecyclerView.ViewHolder,
+            ) {
+                super.clearView(recyclerView, viewHolder)
+                ruleAdapter.commitMove()
+            }
+        }).attachToRecyclerView(ruleListView)
+    }
+
+    override fun onDestroy() {
+        if (::ruleAdapter.isInitialized) {
+            ProfileManager.removeListener(ruleAdapter)
+        }
+        super.onDestroy()
+    }
+
+    override fun onMenuItemClick(item: MenuItem): Boolean {
+        when (item.itemId) {
+            R.id.action_new_route -> {
+                startActivity(Intent(context, RouteSettingsActivity::class.java))
+            }
+        }
+        return true
+    }
+
+    inner class RuleAdapter : RecyclerView.Adapter<RecyclerView.ViewHolder>(),
+        ProfileManager.RuleListener,
+        UndoSnackbarManager.Interface<RuleEntity> {
+
+        val ruleList = ArrayList<RuleEntity>()
+        suspend fun reload() {
+            val rules = ProfileManager.getRules()
+            ruleList.clear()
+            ruleList.addAll(rules)
+            ruleListView.post {
+                ruleAdapter.notifyDataSetChanged()
+            }
+        }
+
+        init {
+            runOnDefaultDispatcher {
+                reload()
+            }
+        }
+
+        override fun onCreateViewHolder(
+            parent: ViewGroup,
+            viewType: Int
+        ): RecyclerView.ViewHolder {
+            return if (viewType == 0) {
+                DocumentHolder(
+                    layoutInflater.inflate(R.layout.layout_empty_route, parent, false)
+                )
+            } else {
+                RuleHolder(
+                    layoutInflater.inflate(R.layout.layout_route_item, parent, false)
+                )
+            }
+        }
+
+        override fun getItemViewType(position: Int): Int {
+            if (position == 0) return 0
+            return 1
+        }
+
+        override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
+            if (holder is DocumentHolder) {
+                holder.bind()
+            } else if (holder is RuleHolder) {
+                holder.bind(ruleList[position - 1])
+            }
+        }
+
+        override fun getItemCount(): Int {
+            return ruleList.size + 1
+        }
+
+        override fun getItemId(position: Int): Long {
+            if (position == 0) return 0L
+            return ruleList[position - 1].id
+        }
+
+        private val updated = HashSet<RuleEntity>()
+        fun move(from: Int, to: Int) {
+            val first = ruleList[from - 1]
+            var previousOrder = first.userOrder
+            val (step, range) = if (from < to) Pair(1, from - 1 until to - 1) else Pair(
+                -1,
+                to downTo from - 1
+            )
+            for (i in range) {
+                val next = ruleList[i + step]
+                val order = next.userOrder
+                next.userOrder = previousOrder
+                previousOrder = order
+                ruleList[i] = next
+                updated.add(next)
+            }
+            first.userOrder = previousOrder
+            ruleList[to - 1] = first
+            updated.add(first)
+            notifyItemMoved(from, to)
+        }
+
+        fun commitMove() = runOnDefaultDispatcher {
+            SagerDatabase.rulesDao.updateRules(updated.toList())
+            updated.clear()
+        }
+
+        fun remove(index: Int) {
+            ruleList.removeAt(index - 1)
+            notifyItemRemoved(index)
+        }
+
+        override fun undo(actions: List<Pair<Int, RuleEntity>>) {
+            for ((index, item) in actions) {
+                ruleList.add(index - 1, item)
+                notifyItemInserted(index)
+            }
+        }
+
+        override fun commit(actions: List<Pair<Int, RuleEntity>>) {
+            val rules = actions.map { it.second }
+            runOnDefaultDispatcher {
+                SagerDatabase.rulesDao.deleteRules(rules)
+            }
+        }
+
+        override suspend fun onAdd(rule: RuleEntity) {
+            ruleListView.post {
+                ruleList.add(rule)
+                ruleAdapter.notifyItemInserted(ruleList.size)
+            }
+        }
+
+        override suspend fun onUpdated(rule: RuleEntity) {
+            val index = ruleList.indexOfFirst { it.id == rule.id }
+            if (index == -1) return
+            ruleListView.post {
+                ruleList[index] = rule
+                ruleAdapter.notifyItemChanged(index)
+            }
+        }
+
+        override suspend fun onRemoved(ruleId: Long) {
+            val index = ruleList.indexOfFirst { it.id == ruleId }
+            if (index == -1) return
+            ruleListView.post {
+                ruleList.removeAt(index)
+                ruleAdapter.notifyItemRemoved(index)
+            }
+        }
+
+        override suspend fun onCleared() {
+            ruleListView.post {
+                ruleList.clear()
+                ruleAdapter.notifyDataSetChanged()
+            }
+        }
+
+        inner class DocumentHolder(val view: View) : RecyclerView.ViewHolder(view) {
+            fun bind() {
+                view.setOnClickListener {
+                    it.context.launchCustomTab(Uri.parse("https://www.v2fly.org/config/routing.html#ruleobject"))
+                }
+            }
+        }
+
+        inner class RuleHolder(val view: View) : RecyclerView.ViewHolder(view) {
+
+            lateinit var rule: RuleEntity
+            val profileName: TextView = view.findViewById(R.id.profile_name)
+            val profileType: TextView = view.findViewById(R.id.profile_type)
+            val editButton: ImageView = view.findViewById(R.id.edit)
+            val shareLayout: LinearLayout = view.findViewById(R.id.share)
+            val enableSwitch: SwitchCompat = view.findViewById(R.id.enable)
+
+            fun bind(ruleEntity: RuleEntity) {
+                rule = ruleEntity
+                profileName.text = rule.displayName()
+                profileType.text = rule.mkSummary()
+                view.setOnClickListener {
+                    enableSwitch.performClick()
+                }
+                enableSwitch.isChecked = rule.enabled
+                enableSwitch.setOnCheckedChangeListener { _, isChecked ->
+                    runOnDefaultDispatcher {
+                        rule.enabled = isChecked
+                        SagerDatabase.rulesDao.updateRule(rule)
+                    }
+                }
+                editButton.setOnClickListener {
+                    startActivity(
+                        Intent(it.context, RouteSettingsActivity::class.java).apply {
+                            putExtra(RouteSettingsActivity.EXTRA_ROUTE_ID, rule.id)
+                        }
+                    )
+                }
+            }
+        }
 
-        parentFragmentManager.beginTransaction()
-            .replace(R.id.settings, RoutePreferenceFragment())
-            .commitAllowingStateLoss()
     }
 
 }

+ 0 - 141
app/src/main/java/io/nekohasekai/sagernet/ui/RoutePreferenceFragment.kt

@@ -1,141 +0,0 @@
-/******************************************************************************
- *                                                                            *
- * Copyright (C) 2021 by nekohasekai <[email protected]>                    *
- * Copyright (C) 2021 by Max Lv <[email protected]>                          *
- * Copyright (C) 2021 by Mygod Studio <[email protected]>  *
- *                                                                            *
- * This program is free software: you can redistribute it and/or modify       *
- * it under the terms of the GNU General Public License as published by       *
- * the Free Software Foundation, either version 3 of the License, or          *
- *  (at your option) any later version.                                       *
- *                                                                            *
- * This program is distributed in the hope that it will be useful,            *
- * but WITHOUT ANY WARRANTY; without even the implied warranty of             *
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the              *
- * GNU General Public License for more details.                               *
- *                                                                            *
- * You should have received a copy of the GNU General Public License          *
- * along with this program. If not, see <http://www.gnu.org/licenses/>.       *
- *                                                                            *
- ******************************************************************************/
-
-package io.nekohasekai.sagernet.ui
-
-import android.content.Intent
-import android.os.Bundle
-import android.view.View
-import androidx.preference.EditTextPreference
-import androidx.preference.Preference
-import androidx.preference.SwitchPreference
-import com.takisoft.preferencex.PreferenceFragmentCompat
-import io.nekohasekai.sagernet.Key
-import io.nekohasekai.sagernet.R
-import io.nekohasekai.sagernet.bg.BaseService
-import io.nekohasekai.sagernet.database.DataStore
-import io.nekohasekai.sagernet.database.preference.EditTextPreferenceModifiers
-import io.nekohasekai.sagernet.ktx.addOverScrollListener
-import io.nekohasekai.sagernet.ktx.isExpert
-import io.nekohasekai.sagernet.ktx.runOnMainDispatcher
-
-class RoutePreferenceFragment : PreferenceFragmentCompat() {
-
-    private lateinit var isProxyApps: SwitchPreference
-    private lateinit var listener: (BaseService.State) -> Unit
-
-    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
-        super.onViewCreated(view, savedInstanceState)
-
-        addOverScrollListener(listView)
-    }
-
-    override fun onCreatePreferencesFix(savedInstanceState: Bundle?, rootKey: String?) {
-        preferenceManager.preferenceDataStore = DataStore.configurationStore
-        addPreferencesFromResource(R.xml.route_preferences)
-
-        val ipv6Route = findPreference<Preference>(Key.IPV6_ROUTE)!!
-        val preferIpv6 = findPreference<Preference>(Key.PREFER_IPV6)!!
-        val domainStrategy = findPreference<Preference>(Key.DOMAIN_STRATEGY)!!
-        val domainMatcher = findPreference<Preference>(Key.DOMAIN_MATCHER)!!
-        domainMatcher.isVisible = isExpert
-
-        val trafficSniffing = findPreference<Preference>(Key.TRAFFIC_SNIFFING)!!
-        val enableMux = findPreference<Preference>(Key.ENABLE_MUX)!!
-        val enableMuxForAll = findPreference<Preference>(Key.ENABLE_MUX_FOR_ALL)!!
-        val muxConcurrency = findPreference<EditTextPreference>(Key.MUX_CONCURRENCY)!!
-        val tcpKeepAliveInterval = findPreference<EditTextPreference>(Key.TCP_KEEP_ALIVE_INTERVAL)!!
-
-        val bypassLan = findPreference<Preference>(Key.BYPASS_LAN)!!
-        val routeChina = findPreference<Preference>(Key.ROUTE_CHINA)!!
-        val blockAds = findPreference<Preference>(Key.BLOCK_ADS)!!
-
-        val forceShadowsocksRust =
-            findPreference<SwitchPreference>(Key.FORCE_SHADOWSOCKS_RUST)!!
-        forceShadowsocksRust.isVisible = isExpert
-
-        val remoteDns = findPreference<Preference>(Key.REMOTE_DNS)!!
-        val enableLocalDns = findPreference<SwitchPreference>(Key.ENABLE_LOCAL_DNS)!!
-        val portLocalDns = findPreference<EditTextPreference>(Key.LOCAL_DNS_PORT)!!
-        val domesticDns = findPreference<EditTextPreference>(Key.DOMESTIC_DNS)!!
-
-        portLocalDns.setOnBindEditTextListener(EditTextPreferenceModifiers.Port)
-        muxConcurrency.setOnBindEditTextListener(EditTextPreferenceModifiers.Port)
-
-        val currServiceMode = DataStore.serviceMode
-        isProxyApps = findPreference(Key.PROXY_APPS)!!
-        isProxyApps.isEnabled = currServiceMode == Key.MODE_VPN
-        isProxyApps.setOnPreferenceChangeListener { _, newValue ->
-            startActivity(Intent(activity, AppManagerActivity::class.java))
-            if (newValue as Boolean) DataStore.dirty = true
-            newValue
-        }
-
-        listener = {
-            val stopped = it == BaseService.State.Stopped
-            val sMode = DataStore.serviceMode
-
-            runOnMainDispatcher {
-                domainStrategy.isEnabled = stopped
-                domainMatcher.isEnabled = stopped
-                trafficSniffing.isEnabled = stopped
-                enableMux.isEnabled = stopped
-                enableMuxForAll.isEnabled = stopped
-                muxConcurrency.isEnabled = stopped
-                tcpKeepAliveInterval.isEnabled = stopped
-
-                bypassLan.isEnabled = stopped
-                blockAds.isEnabled = stopped
-                routeChina.isEnabled = stopped
-
-                forceShadowsocksRust.isEnabled = stopped
-
-                isProxyApps.isEnabled = sMode == Key.MODE_VPN && stopped
-
-                remoteDns.isEnabled = stopped
-                enableLocalDns.isEnabled = stopped
-                portLocalDns.isEnabled = stopped
-                domesticDns.isEnabled = stopped
-                ipv6Route.isEnabled = stopped
-                preferIpv6.isEnabled = stopped
-            }
-        }
-    }
-
-    override fun onResume() {
-        super.onResume()
-
-        if (::listener.isInitialized) {
-            MainActivity.stateListener = listener
-            listener((activity as MainActivity).state)
-        }
-        if (::isProxyApps.isInitialized) {
-            isProxyApps.isChecked = DataStore.proxyApps
-        }
-    }
-
-    override fun onDestroy() {
-        if (MainActivity.stateListener == listener) {
-            MainActivity.stateListener = null
-        }
-        super.onDestroy()
-    }
-}

+ 361 - 0
app/src/main/java/io/nekohasekai/sagernet/ui/RouteSettingsActivity.kt

@@ -0,0 +1,361 @@
+/******************************************************************************
+ *                                                                            *
+ * Copyright (C) 2021 by nekohasekai <[email protected]>                    *
+ * Copyright (C) 2021 by Max Lv <[email protected]>                          *
+ * Copyright (C) 2021 by Mygod Studio <[email protected]>  *
+ *                                                                            *
+ * This program is free software: you can redistribute it and/or modify       *
+ * it under the terms of the GNU General Public License as published by       *
+ * the Free Software Foundation, either version 3 of the License, or          *
+ *  (at your option) any later version.                                       *
+ *                                                                            *
+ * This program is distributed in the hope that it will be useful,            *
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of             *
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the              *
+ * GNU General Public License for more details.                               *
+ *                                                                            *
+ * You should have received a copy of the GNU General Public License          *
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.       *
+ *                                                                            *
+ ******************************************************************************/
+
+package io.nekohasekai.sagernet.ui
+
+import android.app.Activity
+import android.content.DialogInterface
+import android.content.Intent
+import android.os.Bundle
+import android.os.Parcelable
+import android.view.Menu
+import android.view.MenuItem
+import android.view.View
+import androidx.activity.result.component1
+import androidx.activity.result.component2
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.annotation.LayoutRes
+import androidx.appcompat.app.AlertDialog
+import androidx.appcompat.app.AppCompatActivity
+import androidx.core.view.ViewCompat
+import androidx.preference.EditTextPreference
+import androidx.preference.Preference
+import androidx.preference.PreferenceDataStore
+import com.github.shadowsocks.plugin.fragment.AlertDialogFragment
+import com.takisoft.preferencex.PreferenceFragmentCompat
+import io.nekohasekai.sagernet.Key
+import io.nekohasekai.sagernet.R
+import io.nekohasekai.sagernet.database.DataStore
+import io.nekohasekai.sagernet.database.ProfileManager
+import io.nekohasekai.sagernet.database.RuleEntity
+import io.nekohasekai.sagernet.database.SagerDatabase
+import io.nekohasekai.sagernet.database.preference.OnPreferenceDataStoreChangeListener
+import io.nekohasekai.sagernet.ktx.Empty
+import io.nekohasekai.sagernet.ktx.onMainDispatcher
+import io.nekohasekai.sagernet.ktx.runOnDefaultDispatcher
+import io.nekohasekai.sagernet.utils.DirectBoot
+import io.nekohasekai.sagernet.widget.ListListener
+import io.nekohasekai.sagernet.widget.OutboundPreference
+import kotlinx.parcelize.Parcelize
+
+@Suppress("UNCHECKED_CAST")
+class RouteSettingsActivity(
+    @LayoutRes
+    resId: Int = R.layout.layout_settings_activity,
+) : AppCompatActivity(resId),
+    OnPreferenceDataStoreChangeListener {
+
+    fun init() {
+        RuleEntity().init()
+    }
+
+    fun RuleEntity.init() {
+        DataStore.routeName = name
+        DataStore.routeDomain = domains
+        DataStore.routeIP = ip
+        DataStore.routeSourcePort = sourcePort
+        DataStore.routeNetwork = network
+        DataStore.routeSource = source
+        DataStore.routeProtocol = protocol
+        DataStore.routeOutboundRule = outbound
+        DataStore.routeOutbound = when (outbound) {
+            0L -> 0
+            -1L -> 1
+            -2L -> 2
+            else -> 3
+        }
+    }
+
+    fun RuleEntity.serialize() {
+        name = DataStore.routeName
+        domains = DataStore.routeDomain
+        ip = DataStore.routeIP
+        sourcePort = DataStore.routeSourcePort
+        network = DataStore.routeNetwork
+        protocol = DataStore.routeProtocol
+        outbound = when (DataStore.routeOutbound) {
+            0 -> 0L
+            1 -> -1L
+            2 -> -2L
+            else -> DataStore.routeOutboundRule
+        }
+    }
+
+    fun needSave(): Boolean {
+        if (!DataStore.dirty) return false
+        if (DataStore.routeDomain.isBlank() &&
+            DataStore.routeIP.isBlank() &&
+            DataStore.routeSourcePort.isBlank() &&
+            DataStore.routeNetwork.isBlank() &&
+            DataStore.routeProtocol.isBlank()
+        ) {
+            return false
+        }
+        return true
+    }
+
+    fun PreferenceFragmentCompat.createPreferences(
+        savedInstanceState: Bundle?,
+        rootKey: String?,
+    ) {
+        addPreferencesFromResource(R.xml.route_preferences)
+    }
+
+    lateinit var outbound: OutboundPreference
+    val selectProfileForAdd = registerForActivityResult(
+        ActivityResultContracts.StartActivityForResult()
+    ) { (resultCode, data) ->
+        if (resultCode == Activity.RESULT_OK) runOnDefaultDispatcher {
+            val profile = ProfileManager.getProfile(
+                data!!.getLongExtra(
+                    ProfileSelectActivity.EXTRA_PROFILE_ID,
+                    0
+                )
+            ) ?: return@runOnDefaultDispatcher
+            DataStore.routeOutboundRule = profile.id
+            onMainDispatcher {
+                outbound.value = "3"
+            }
+        }
+    }
+
+    fun PreferenceFragmentCompat.viewCreated(view: View, savedInstanceState: Bundle?) {
+        outbound = findPreference(Key.ROUTE_OUTBOUND)!!
+        outbound.setOnPreferenceChangeListener { _, newValue ->
+            if (newValue.toString() == "3") {
+                selectProfileForAdd.launch(
+                    Intent(
+                        this@RouteSettingsActivity,
+                        ProfileSelectActivity::class.java
+                    )
+                )
+                false
+            } else true
+        }
+    }
+
+    fun PreferenceFragmentCompat.displayPreferenceDialog(preference: Preference): Boolean {
+        return false
+    }
+
+    class UnsavedChangesDialogFragment : AlertDialogFragment<Empty, Empty>() {
+        override fun AlertDialog.Builder.prepare(listener: DialogInterface.OnClickListener) {
+            setTitle(R.string.unsaved_changes_prompt)
+            setPositiveButton(R.string.yes) { _, _ ->
+                runOnDefaultDispatcher {
+                    (requireActivity() as RouteSettingsActivity).saveAndExit()
+                }
+            }
+            setNegativeButton(R.string.no) { _, _ ->
+                requireActivity().finish()
+            }
+            setNeutralButton(android.R.string.cancel, null)
+        }
+    }
+
+    @Parcelize
+    data class ProfileIdArg(val ruleId: Long) : Parcelable
+    class DeleteConfirmationDialogFragment : AlertDialogFragment<ProfileIdArg, Empty>() {
+        override fun AlertDialog.Builder.prepare(listener: DialogInterface.OnClickListener) {
+            setTitle(R.string.delete_route_prompt)
+            setPositiveButton(R.string.yes) { _, _ ->
+                runOnDefaultDispatcher {
+                    ProfileManager.deleteRule(arg.ruleId)
+                }
+                requireActivity().finish()
+            }
+            setNegativeButton(R.string.no, null)
+        }
+    }
+
+    companion object {
+        const val EXTRA_ROUTE_ID = "id"
+    }
+
+    override fun onCreate(savedInstanceState: Bundle?) {
+        super.onCreate(savedInstanceState)
+        setSupportActionBar(findViewById(R.id.toolbar))
+        supportActionBar?.apply {
+            setTitle(R.string.cag_route)
+            setDisplayHomeAsUpEnabled(true)
+            setHomeAsUpIndicator(R.drawable.ic_navigation_close)
+        }
+
+        if (savedInstanceState == null) {
+            val editingId = intent.getLongExtra(EXTRA_ROUTE_ID, 0L)
+            DataStore.editingId = editingId
+            runOnDefaultDispatcher {
+                if (editingId == 0L) {
+                    init()
+                } else {
+                    val ruleEntity = SagerDatabase.rulesDao.getById(editingId)
+                    if (ruleEntity == null) {
+                        onMainDispatcher {
+                            finish()
+                        }
+                        return@runOnDefaultDispatcher
+                    }
+                    ruleEntity.init()
+                }
+
+                onMainDispatcher {
+                    supportFragmentManager.beginTransaction()
+                        .replace(R.id.settings,
+                            MyPreferenceFragmentCompat().apply {
+                                activity = this@RouteSettingsActivity
+                            })
+                        .commit()
+
+                    DataStore.dirty = false
+                    DataStore.profileCacheStore.registerChangeListener(this@RouteSettingsActivity)
+                }
+            }
+
+
+        }
+
+    }
+
+    suspend fun saveAndExit() {
+
+        if (!needSave()) {
+            onMainDispatcher {
+                AlertDialog.Builder(this@RouteSettingsActivity)
+                    .setTitle(R.string.empty_route)
+                    .setMessage(R.string.empty_route_notice)
+                    .setPositiveButton(android.R.string.ok, null)
+                    .show()
+            }
+            return
+        }
+
+        val editingId = DataStore.editingId
+        if (editingId == 0L) {
+            ProfileManager.createRule(RuleEntity().apply { serialize() })
+        } else {
+            val entity = SagerDatabase.rulesDao.getById(DataStore.editingId)
+            if (entity == null) {
+                finish()
+                return
+            }
+            ProfileManager.updateRule(entity.apply { serialize() })
+        }
+        if (editingId == DataStore.selectedProxy && DataStore.directBootAware) DirectBoot.update()
+        finish()
+
+    }
+
+    val child by lazy { supportFragmentManager.findFragmentById(R.id.settings) as MyPreferenceFragmentCompat }
+
+    override fun onCreateOptionsMenu(menu: Menu?): Boolean {
+        menuInflater.inflate(R.menu.profile_config_menu, menu)
+        return true
+    }
+
+    override fun onOptionsItemSelected(item: MenuItem) = child.onOptionsItemSelected(item)
+
+    override fun onBackPressed() {
+        if (needSave()) {
+            UnsavedChangesDialogFragment().apply { key() }
+                .show(supportFragmentManager, null)
+        } else super.onBackPressed()
+    }
+
+    override fun onSupportNavigateUp(): Boolean {
+        if (!super.onSupportNavigateUp()) finish()
+        return true
+    }
+
+    override fun onDestroy() {
+        DataStore.profileCacheStore.unregisterChangeListener(this)
+        super.onDestroy()
+    }
+
+    override fun onPreferenceDataStoreChanged(store: PreferenceDataStore, key: String) {
+        if (key != Key.PROFILE_DIRTY) {
+            DataStore.dirty = true
+        }
+    }
+
+    class MyPreferenceFragmentCompat : PreferenceFragmentCompat() {
+
+        lateinit var activity: RouteSettingsActivity
+
+        override fun onCreatePreferencesFix(savedInstanceState: Bundle?, rootKey: String?) {
+            preferenceManager.preferenceDataStore = DataStore.profileCacheStore
+            activity.apply {
+                createPreferences(savedInstanceState, rootKey)
+            }
+        }
+
+        override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+            super.onViewCreated(view, savedInstanceState)
+
+            ViewCompat.setOnApplyWindowInsetsListener(listView, ListListener)
+
+            activity.apply {
+                viewCreated(view, savedInstanceState)
+            }
+        }
+
+        override fun onOptionsItemSelected(item: MenuItem) = when (item.itemId) {
+            R.id.action_delete -> {
+                if (DataStore.editingId == 0L) {
+                    requireActivity().finish()
+                } else {
+                    DeleteConfirmationDialogFragment().apply {
+                        arg(ProfileIdArg(DataStore.editingId))
+                        key()
+                    }.show(parentFragmentManager, null)
+                }
+                true
+            }
+            R.id.action_apply -> {
+                runOnDefaultDispatcher {
+                    activity.saveAndExit()
+                }
+                true
+            }
+            else -> false
+        }
+
+        override fun onDisplayPreferenceDialog(preference: Preference) {
+            activity.apply {
+                if (displayPreferenceDialog(preference)) return
+            }
+            super.onDisplayPreferenceDialog(preference)
+        }
+
+    }
+
+    object PasswordSummaryProvider : Preference.SummaryProvider<EditTextPreference> {
+
+        override fun provideSummary(preference: EditTextPreference): CharSequence {
+            return if (preference.text.isNullOrBlank()) {
+                preference.context.getString(androidx.preference.R.string.not_set)
+            } else {
+                "\u2022".repeat(preference.text.length)
+            }
+        }
+
+    }
+
+}

+ 55 - 2
app/src/main/java/io/nekohasekai/sagernet/ui/SettingsPreferenceFragment.kt

@@ -21,6 +21,7 @@
 
 package io.nekohasekai.sagernet.ui
 
+import android.content.Intent
 import android.os.Build
 import android.os.Bundle
 import android.view.View
@@ -34,11 +35,13 @@ import io.nekohasekai.sagernet.bg.BaseService
 import io.nekohasekai.sagernet.database.DataStore
 import io.nekohasekai.sagernet.database.preference.EditTextPreferenceModifiers
 import io.nekohasekai.sagernet.ktx.addOverScrollListener
+import io.nekohasekai.sagernet.ktx.isExpert
 import io.nekohasekai.sagernet.ktx.remove
 import io.nekohasekai.sagernet.ktx.runOnMainDispatcher
 
 class SettingsPreferenceFragment : PreferenceFragmentCompat() {
 
+    private lateinit var isProxyApps: SwitchPreference
     private lateinit var listener: (BaseService.State) -> Unit
 
     override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
@@ -65,7 +68,31 @@ class SettingsPreferenceFragment : PreferenceFragmentCompat() {
         }
         val securityAdvisory = findPreference<SwitchPreference>(Key.SECURITY_ADVISORY)!!
         val showDirectSpeed = findPreference<SwitchPreference>(Key.SHOW_DIRECT_SPEED)!!
-
+        val ipv6Route = findPreference<Preference>(Key.IPV6_ROUTE)!!
+        val preferIpv6 = findPreference<Preference>(Key.PREFER_IPV6)!!
+        val domainStrategy = findPreference<Preference>(Key.DOMAIN_STRATEGY)!!
+        val domainMatcher = findPreference<Preference>(Key.DOMAIN_MATCHER)!!
+        domainMatcher.isVisible = isExpert
+
+        val trafficSniffing = findPreference<Preference>(Key.TRAFFIC_SNIFFING)!!
+        val enableMux = findPreference<Preference>(Key.ENABLE_MUX)!!
+        val enableMuxForAll = findPreference<Preference>(Key.ENABLE_MUX_FOR_ALL)!!
+        val muxConcurrency = findPreference<EditTextPreference>(Key.MUX_CONCURRENCY)!!
+        val tcpKeepAliveInterval = findPreference<EditTextPreference>(Key.TCP_KEEP_ALIVE_INTERVAL)!!
+
+        val bypassLan = findPreference<Preference>(Key.BYPASS_LAN)!!
+
+        val forceShadowsocksRust =
+            findPreference<SwitchPreference>(Key.FORCE_SHADOWSOCKS_RUST)!!
+        forceShadowsocksRust.isVisible = isExpert
+
+        val remoteDns = findPreference<Preference>(Key.REMOTE_DNS)!!
+        val enableLocalDns = findPreference<SwitchPreference>(Key.ENABLE_LOCAL_DNS)!!
+        val portLocalDns = findPreference<EditTextPreference>(Key.LOCAL_DNS_PORT)!!
+        val domesticDns = findPreference<EditTextPreference>(Key.DOMESTIC_DNS)!!
+
+        portLocalDns.setOnBindEditTextListener(EditTextPreferenceModifiers.Port)
+        muxConcurrency.setOnBindEditTextListener(EditTextPreferenceModifiers.Port)
         portSocks5.setOnBindEditTextListener(EditTextPreferenceModifiers.Port)
         portHttp.setOnBindEditTextListener(EditTextPreferenceModifiers.Port)
 
@@ -76,6 +103,13 @@ class SettingsPreferenceFragment : PreferenceFragmentCompat() {
         } else {
             metedNetwork.remove()
         }
+        isProxyApps = findPreference(Key.PROXY_APPS)!!
+        isProxyApps.isEnabled = currServiceMode == Key.MODE_VPN
+        isProxyApps.setOnPreferenceChangeListener { _, newValue ->
+            startActivity(Intent(activity, AppManagerActivity::class.java))
+            if (newValue as Boolean) DataStore.dirty = true
+            newValue
+        }
 
         listener = {
             val stopped = it == BaseService.State.Stopped
@@ -92,10 +126,26 @@ class SettingsPreferenceFragment : PreferenceFragmentCompat() {
                 showStopButton.isEnabled = stopped
                 securityAdvisory.isEnabled = stopped
                 showDirectSpeed.isEnabled = stopped
+                domainStrategy.isEnabled = stopped
+                domainMatcher.isEnabled = stopped
+                trafficSniffing.isEnabled = stopped
+                enableMux.isEnabled = stopped
+                enableMuxForAll.isEnabled = stopped
+                muxConcurrency.isEnabled = stopped
+                tcpKeepAliveInterval.isEnabled = stopped
+                bypassLan.isEnabled = stopped
+                forceShadowsocksRust.isEnabled = stopped
+                remoteDns.isEnabled = stopped
+                enableLocalDns.isEnabled = stopped
+                portLocalDns.isEnabled = stopped
+                domesticDns.isEnabled = stopped
+                ipv6Route.isEnabled = stopped
+                preferIpv6.isEnabled = stopped
+                allowAccess.isEnabled = stopped
 
                 metedNetwork.isEnabled = sMode == Key.MODE_VPN && stopped
+                isProxyApps.isEnabled = sMode == Key.MODE_VPN && stopped
 
-                allowAccess.isEnabled = stopped
             }
         }
 
@@ -108,6 +158,9 @@ class SettingsPreferenceFragment : PreferenceFragmentCompat() {
             MainActivity.stateListener = listener
             listener((activity as MainActivity).state)
         }
+        if (::isProxyApps.isInitialized) {
+            isProxyApps.isChecked = DataStore.proxyApps
+        }
     }
 
     override fun onDestroy() {

+ 0 - 1
app/src/main/java/io/nekohasekai/sagernet/ui/ToolbarFragment.kt

@@ -35,7 +35,6 @@ open class ToolbarFragment : Fragment {
 
     lateinit var toolbar: Toolbar
 
-
     override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
         super.onViewCreated(view, savedInstanceState)
         toolbar = view.findViewById(R.id.toolbar)

+ 0 - 1
app/src/main/java/io/nekohasekai/sagernet/ui/profile/ProfileSettingsActivity.kt

@@ -194,7 +194,6 @@ abstract class ProfileSettingsActivity<T : AbstractBean>(
 
     override fun onPreferenceDataStoreChanged(store: PreferenceDataStore, key: String) {
         if (key != Key.PROFILE_DIRTY) {
-            Logs.d("Chnaged $key")
             DataStore.dirty = true
         }
     }

+ 1 - 1
app/src/main/java/io/nekohasekai/sagernet/ui/profile/StandardV2RaySettingsActivity.kt

@@ -260,7 +260,7 @@ abstract class StandardV2RaySettingsActivity : ProfileSettingsActivity<StandardV
             }
             "kcp" -> {
                 header.setEntries(R.array.kcp_quic_headers_entry)
-                header.setEntryValues(R.array.kcp_quic_headers_entry)
+                header.setEntryValues(R.array.kcp_quic_headers_value)
                 path.setTitle(R.string.kcp_seed)
 
                 if (DataStore.serverHeader !in kcpQuicHeadersValue) {

+ 65 - 0
app/src/main/java/io/nekohasekai/sagernet/widget/OutboundPreference.kt

@@ -0,0 +1,65 @@
+/******************************************************************************
+ *                                                                            *
+ * Copyright (C) 2021 by nekohasekai <[email protected]>                    *
+ * Copyright (C) 2021 by Max Lv <[email protected]>                          *
+ * Copyright (C) 2021 by Mygod Studio <[email protected]>  *
+ *                                                                            *
+ * This program is free software: you can redistribute it and/or modify       *
+ * it under the terms of the GNU General Public License as published by       *
+ * the Free Software Foundation, either version 3 of the License, or          *
+ *  (at your option) any later version.                                       *
+ *                                                                            *
+ * This program is distributed in the hope that it will be useful,            *
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of             *
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the              *
+ * GNU General Public License for more details.                               *
+ *                                                                            *
+ * You should have received a copy of the GNU General Public License          *
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.       *
+ *                                                                            *
+ ******************************************************************************/
+
+package io.nekohasekai.sagernet.widget
+
+import android.content.Context
+import android.util.AttributeSet
+import com.takisoft.preferencex.SimpleMenuPreference
+import io.nekohasekai.sagernet.R
+import io.nekohasekai.sagernet.database.DataStore
+import io.nekohasekai.sagernet.database.ProfileManager
+
+class OutboundPreference : SimpleMenuPreference {
+
+    constructor(context: Context?) : super(context)
+    constructor(context: Context?, attrs: AttributeSet?) : super(context, attrs)
+    constructor(context: Context?, attrs: AttributeSet?, defStyle: Int) : super(
+        context,
+        attrs,
+        defStyle
+    )
+
+    constructor(
+        context: Context?,
+        attrs: AttributeSet?,
+        defStyleAttr: Int,
+        defStyleRes: Int
+    ) : super(context, attrs, defStyleAttr, defStyleRes)
+
+    init {
+        setEntries(R.array.outbound_entry)
+        setEntryValues(R.array.outbound_value)
+    }
+
+    override fun getSummary(): CharSequence {
+        if (value == "3") {
+            val routeOutbound = DataStore.routeOutboundRule
+            if (routeOutbound > 0) {
+                ProfileManager.getProfile(routeOutbound)?.displayName()?.let {
+                    return it
+                }
+            }
+        }
+        return super.getSummary()
+    }
+
+}

+ 25 - 0
app/src/main/res/drawable/ic_baseline_add_road_24.xml

@@ -0,0 +1,25 @@
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+    android:width="24dp"
+    android:height="24dp"
+    android:viewportWidth="24"
+    android:viewportHeight="24"
+    android:tint="?attr/colorControlNormal">
+  <path
+      android:fillColor="@android:color/white"
+      android:pathData="M20,18l0,-3l-2,0l0,3l-3,0l0,2l3,0l0,3l2,0l0,-3l3,0l0,-2z"/>
+  <path
+      android:fillColor="@android:color/white"
+      android:pathData="M18,4h2v9h-2z"/>
+  <path
+      android:fillColor="@android:color/white"
+      android:pathData="M4,4h2v16h-2z"/>
+  <path
+      android:fillColor="@android:color/white"
+      android:pathData="M11,4h2v4h-2z"/>
+  <path
+      android:fillColor="@android:color/white"
+      android:pathData="M11,10h2v4h-2z"/>
+  <path
+      android:fillColor="@android:color/white"
+      android:pathData="M11,16h2v4h-2z"/>
+</vector>

+ 10 - 0
app/src/main/res/drawable/ic_baseline_home_24.xml

@@ -0,0 +1,10 @@
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+    android:width="24dp"
+    android:height="24dp"
+    android:viewportWidth="24"
+    android:viewportHeight="24"
+    android:tint="?attr/colorControlNormal">
+  <path
+      android:fillColor="@android:color/white"
+      android:pathData="M10,20v-6h4v6h5v-8h3L12,3 2,12h3v8z"/>
+</vector>

+ 43 - 0
app/src/main/res/layout/layout_empty_route.xml

@@ -0,0 +1,43 @@
+<?xml version="1.0" encoding="utf-8"?><!--
+  ~
+  ~ Copyright (C) 2021 by nekohasekai <[email protected]>
+  ~ Copyright (C) 2021 by Max Lv <[email protected]>
+  ~ Copyright (C) 2021 by Mygod Studio <[email protected]>
+  ~
+  ~ This program is free software: you can redistribute it and/or modify
+  ~ it under the terms of the GNU General Public License as published by
+  ~ the Free Software Foundation, either version 3 of the License, or
+  ~  (at your option) any later version.
+  ~
+  ~ This program is distributed in the hope that it will be useful,
+  ~ but WITHOUT ANY WARRANTY; without even the implied warranty of
+  ~ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+  ~ GNU General Public License for more details.
+  ~
+  ~ You should have received a copy of the GNU General Public License
+  ~ along with this program. If not, see <http://www.gnu.org/licenses/>.
+  ~
+  -->
+
+<com.google.android.material.card.MaterialCardView xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:app="http://schemas.android.com/apk/res-auto"
+    android:layout_width="match_parent"
+    android:layout_height="wrap_content"
+    android:layout_margin="4dp"
+    app:cardElevation="2dp">
+
+    <LinearLayout
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        android:padding="16dp">
+
+        <TextView
+            android:layout_width="wrap_content"
+            android:layout_height="wrap_content"
+            android:textAppearance="?android:attr/textAppearanceSmall"
+            android:textColor="?android:attr/textColorPrimary"
+            android:textStyle="bold"
+            android:text="@string/route_warn"/>
+    </LinearLayout>
+
+</com.google.android.material.card.MaterialCardView>

+ 1 - 1
app/src/main/res/layout/layout_profile.xml

@@ -112,7 +112,7 @@
                 android:layout_width="match_parent"
                 android:layout_height="wrap_content"
                 android:orientation="horizontal"
-                android:paddingLeft="10dp"
+                android:paddingLeft="12dp"
                 android:paddingRight="8dp"
                 android:paddingBottom="12dp">
 

+ 29 - 0
app/src/main/res/layout/layout_route.xml

@@ -0,0 +1,29 @@
+<?xml version="1.0" encoding="utf-8"?>
+<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:app="http://schemas.android.com/apk/res-auto"
+    android:layout_width="match_parent"
+    android:layout_height="match_parent">
+
+    <LinearLayout
+        android:layout_width="match_parent"
+        android:layout_height="match_parent"
+        android:orientation="vertical">
+
+        <include layout="@layout/layout_appbar" />
+
+        <com.simplecityapps.recyclerview_fastscroll.views.FastScrollRecyclerView
+            android:id="@+id/route_list"
+            android:layout_width="match_parent"
+            android:layout_height="match_parent"
+            android:padding="4dp"
+            android:scrollbarSize="0dp"
+            app:fastScrollAutoHide="true"
+            app:fastScrollThumbColor="?colorPrimary"
+            app:fastScrollThumbInactiveColor="?colorPrimary"
+            app:fastScrollTrackColor="?colorOnPrimarySurface">
+
+        </com.simplecityapps.recyclerview_fastscroll.views.FastScrollRecyclerView>
+
+    </LinearLayout>
+
+</androidx.coordinatorlayout.widget.CoordinatorLayout>

+ 124 - 0
app/src/main/res/layout/layout_route_item.xml

@@ -0,0 +1,124 @@
+<?xml version="1.0" encoding="utf-8"?><!-- Based on: https://github.com/android/platform_frameworks_base/blob/505e3ab/core/res/res/layout/simple_list_item_2.xml -->
+<com.google.android.material.card.MaterialCardView xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:app="http://schemas.android.com/apk/res-auto"
+    xmlns:tools="http://schemas.android.com/tools"
+    android:id="@+id/content"
+    android:layout_width="match_parent"
+    android:layout_height="wrap_content"
+    android:layout_margin="4dp"
+    app:cardElevation="2dp">
+
+    <LinearLayout
+        android:id="@+id/content_lin"
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content"
+        android:focusable="true"
+        android:orientation="vertical">
+
+
+        <LinearLayout
+            android:id="@+id/container"
+            android:layout_width="match_parent"
+            android:layout_height="wrap_content"
+            android:orientation="horizontal">
+
+            <LinearLayout
+                android:layout_width="0dp"
+                android:layout_height="wrap_content"
+                android:layout_gravity="center_vertical"
+                android:layout_weight="1"
+                android:gravity="center_vertical"
+                android:paddingStart="12dp"
+                android:paddingTop="8dp"
+                android:paddingBottom="8dp">
+
+                <TextView
+                    android:id="@+id/profile_name"
+                    android:layout_width="wrap_content"
+                    android:layout_height="wrap_content"
+                    android:textAppearance="?android:attr/textAppearanceSmall"
+                    android:textColor="?android:attr/textColorPrimary"
+                    android:textStyle="bold"
+                    tools:text="@string/profile_name" />
+            </LinearLayout>
+
+
+            <androidx.appcompat.widget.AppCompatImageView
+                android:id="@+id/subscription"
+                android:layout_width="wrap_content"
+                android:layout_height="wrap_content"
+                android:layout_gravity="top"
+                android:background="?attr/selectableItemBackgroundBorderless"
+                android:contentDescription="@string/subscriptions"
+                android:focusable="true"
+                android:padding="12dp"
+                android:visibility="gone"
+                app:srcCompat="@drawable/ic_file_cloud_queue" />
+
+            <androidx.appcompat.widget.AppCompatImageView
+                android:id="@+id/edit"
+                android:layout_width="wrap_content"
+                android:layout_height="wrap_content"
+                android:layout_gravity="top"
+                android:background="?attr/selectableItemBackgroundBorderless"
+                android:contentDescription="@string/edit"
+                android:focusable="true"
+                android:padding="12dp"
+                app:srcCompat="@drawable/ic_image_edit" />
+
+            <LinearLayout
+                android:id="@+id/share"
+                android:layout_width="wrap_content"
+                android:layout_height="wrap_content"
+                android:layout_gravity="top"
+                android:background="?attr/selectableItemBackgroundBorderless"
+                android:contentDescription="@string/share"
+                android:focusable="true"
+                android:nextFocusLeft="@+id/container"
+                android:visibility="gone">
+
+                <LinearLayout
+                    android:id="@+id/share_layer"
+                    android:layout_width="match_parent"
+                    android:layout_height="match_parent"
+                    android:padding="12dp">
+
+                    <androidx.appcompat.widget.AppCompatImageView
+                        android:id="@+id/shareIcon"
+                        android:layout_width="wrap_content"
+                        android:layout_height="wrap_content"
+                        app:srcCompat="@drawable/ic_social_share" />
+                </LinearLayout>
+
+
+            </LinearLayout>
+        </LinearLayout>
+
+        <LinearLayout
+            android:layout_width="match_parent"
+            android:layout_height="wrap_content"
+            android:gravity="end|bottom"
+            android:orientation="horizontal"
+            android:paddingStart="12dp"
+            android:paddingEnd="8dp"
+            android:paddingBottom="8dp">
+
+            <TextView
+                android:id="@+id/profile_type"
+                android:layout_width="0dp"
+                android:layout_height="wrap_content"
+                android:layout_weight="1"
+                android:text="@string/profile_type"
+                android:textAppearance="?android:attr/textAppearanceSmall"
+                android:textColor="@color/color_primary_text" />
+
+            <androidx.appcompat.widget.SwitchCompat
+                android:id="@+id/enable"
+                android:layout_width="wrap_content"
+                android:layout_height="wrap_content" />
+
+        </LinearLayout>
+
+    </LinearLayout>
+
+</com.google.android.material.card.MaterialCardView>

+ 9 - 0
app/src/main/res/menu/add_route_menu.xml

@@ -0,0 +1,9 @@
+<?xml version="1.0" encoding="utf-8"?>
+<menu xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:app="http://schemas.android.com/apk/res-auto">
+    <item
+        android:id="@+id/action_new_route"
+        android:icon="@drawable/ic_baseline_add_road_24"
+        android:title="@string/route_add"
+        app:showAsAction="always" />
+</menu>

+ 14 - 2
app/src/main/res/values-zh-rCN/strings.xml

@@ -201,7 +201,7 @@
     <string name="add_profile_scanner_permission_required">扫描二维码需要相机权限</string>
     <plurals name="removed">
         <item quantity="one">已删除</item>
-        <item quantity="other">%d 个服务器配置已删除</item>
+        <item quantity="other">%d 已删除</item>
     </plurals>
     <plurals name="added">
         <item quantity="one">已添加</item>
@@ -248,7 +248,6 @@
     <string name="route_block">屏蔽</string>
     <string name="route_opt_bypass_lan">绕过局域网地址</string>
     <string name="route_opt_block_ads">屏蔽广告</string>
-    <string name="route_opt_china">中国大陆规则</string>
     <string name="domain_strategy">域名解析策略</string>
     <string name="domain_matcher">域名匹配算法</string>
     <string name="dm_linear">线性匹配</string>
@@ -289,5 +288,18 @@
     <string name="mux_for_all">为所有可能支持的协议启用多路复用</string>
     <string name="mux_for_all_sum">如果服务器不支持, 则将无法连接</string>
     <string name="tcp_keep_alive_interval">TCP 保持活跃数据包发送间隔</string>
+    <string name="ipv6_settings">IPv6 设置</string>
+    <string name="route_add">新建路由规则</string>
+    <string name="delete_route_prompt">您确定要移除此路吗?</string>
+    <string name="route_name">路由名称</string>
+    <string name="route_profile">选择配置…</string>
+    <string name="empty_route">空路由</string>
+    <string name="empty_route_notice">在保存前设置一些规则</string>
+    <string name="route_warn">在添加自定义规则之前,请确保您已经阅读了文档,否则您将可能无法连接到互联网。</string>
+    <string name="route_bypass_domain">%s 域名规则</string>
+    <string name="route_bypass_ip">%s IP 规则</string>
+    <string name="profile_file">配置文件</string>
+    <string name="no_proxies_found_in_file">未在此文件内找到服务器配置</string>
+    <string name="no_proxies_found_in_clipboard">未在剪切板内找到服务器配置</string>
 
 </resources>

+ 22 - 1
app/src/main/res/values/arrays.xml

@@ -223,7 +223,28 @@
         <item>1</item>
         <item>2</item>
     </string-array>
-
+    <string-array name="route_protocol_entry">
+        <item>TCP and UDP</item>
+        <item>TCP</item>
+        <item>UDP</item>
+    </string-array>
+    <string-array name="route_protocol_value">
+        <item />
+        <item>tcp</item>
+        <item>udp</item>
+    </string-array>
+    <string-array name="outbound_entry">
+        <item>@string/route_proxy</item>
+        <item>@string/route_bypass</item>
+        <item>@string/route_block</item>
+        <item>@string/route_profile</item>
+    </string-array>
+    <string-array name="outbound_value" translatable="false">
+        <item>0</item>
+        <item>1</item>
+        <item>2</item>
+        <item>3</item>
+    </string-array>
     <string-array name="enc_method_entry_ssr" translatable="false">
         <item>NONE</item>
         <item>TABLE</item>

+ 10 - 2
app/src/main/res/values/strings.xml

@@ -128,6 +128,7 @@
     <string name="encryption">Encryption</string>
 
     <!-- feature category -->
+    <string name="ipv6_settings">IPv6 Settings</string>
     <string name="ipv6">IPv6 Route</string>
     <string name="ipv6_summary">Redirect IPv6 traffic to remote</string>
     <string name="ipv6_prefer">Prefer IPv6</string>
@@ -136,14 +137,20 @@
     <string name="metered">Metered Hint</string>
     <string name="metered_summary">Hint system to treat VPN as metered</string>
     <string name="menu_route">Route</string>
-
+    <string name="route_add">Create Route</string>
+    <string name="delete_route_prompt">Are you sure you want to remove this route?</string>
+    <string name="route_name">Route Name</string>
     <string name="route_proxy">Proxy</string>
     <string name="route_bypass">Bypass</string>
     <string name="route_block">Block</string>
+    <string name="route_profile">Select Profile…</string>
+    <string name="empty_route">Empty Route</string>
+    <string name="empty_route_notice">Set some rules before saving</string>
+    <string name="route_bypass_domain">Domain rule for %s</string>
+    <string name="route_bypass_ip">IP rule for %s</string>
 
     <string name="route_opt_bypass_lan">Bypass LAN</string>
     <string name="route_opt_block_ads">Block ADs</string>
-    <string name="route_opt_china">Rule for mainland China</string>
 
     <string name="domain_strategy">Domain resolution strategy</string>
     <string name="domain_matcher">Domain matching algorithm</string>
@@ -300,5 +307,6 @@
     <string name="apply">Apply</string>
 
     <string name="license">License</string>
+    <string name="route_warn">Make sure you have read the documentation before adding custom rules, otherwise you may not be able to connect to the Internet.</string>
 
 </resources>

+ 174 - 68
app/src/main/res/xml/global_preferences.xml

@@ -1,71 +1,177 @@
 <?xml version="1.0" encoding="utf-8"?>
-<PreferenceScreen xmlns:app="http://schemas.android.com/apk/res-auto"
-    app:initialExpandedChildrenCount="114">
-    <SwitchPreference
-        app:defaultValue="true"
-        app:icon="@drawable/ic_communication_phonelink_ring"
-        app:key="isAutoConnect"
-        app:summary="@string/auto_connect_summary"
-        app:title="@string/auto_connect" />
-    <SwitchPreference
-        app:icon="@drawable/ic_action_lock"
-        app:key="directBootAware"
-        app:summary="@string/direct_boot_aware_summary"
-        app:title="@string/direct_boot_aware" />
-    <com.takisoft.preferencex.SimpleMenuPreference
-        app:defaultValue="vpn"
-        app:entries="@array/service_modes"
-        app:entryValues="@array/service_mode_values"
-        app:icon="@drawable/ic_device_developer_mode"
-        app:key="serviceMode"
-        app:title="@string/service_mode"
-        app:useSimpleSummaryProvider="true" />
-    <SwitchPreference
-        app:icon="@drawable/ic_device_data_usage"
-        app:key="meteredNetwork"
-        app:summary="@string/metered_summary"
-        app:title="@string/metered" />
-    <SwitchPreference
-        app:icon="@drawable/ic_baseline_security_24"
-        app:key="securityAdvisory"
-        app:summary="@string/insecure_warn_sum"
-        app:title="@string/insecure_warn" />
-    <com.takisoft.preferencex.SimpleMenuPreference
-        app:defaultValue="1000"
-        app:entries="@array/notification_entry"
-        app:entryValues="@array/notification_value"
-        app:icon="@drawable/ic_baseline_shutter_speed_24"
-        app:key="speedInterval"
-        app:title="@string/speed_interval"
-        app:useSimpleSummaryProvider="true" />
-    <SwitchPreference
-        app:icon="@drawable/ic_baseline_speed_24"
-        app:key="showDirectSpeed"
-        app:summary="@string/show_direct_speed_sum"
-        app:title="@string/show_direct_speed"
-        app:useSimpleSummaryProvider="true" />
-    <SwitchPreference
-        app:icon="@drawable/ic_baseline_multiple_stop_24"
-        app:key="showStopButton"
-        app:summary="@string/show_stop_sum"
-        app:title="@string/show_stop" />
-    <SwitchPreference
-        app:icon="@drawable/ic_baseline_nat_24"
-        app:key="allowAccess"
-        app:summary="@string/allow_access_sum"
-        app:title="@string/allow_access" />
-    <EditTextPreference
-        app:icon="@drawable/ic_maps_directions_boat"
-        app:key="socksPort"
-        app:title="@string/port_proxy"
-        app:useSimpleSummaryProvider="true" />
-    <SwitchPreference
-        app:icon="@drawable/ic_baseline_http_24"
-        app:key="requireHttp"
-        app:title="@string/require_http" />
-    <EditTextPreference
-        app:key="httpPort"
-        app:title="@string/port_http"
-        app:useSimpleSummaryProvider="true" />
+<PreferenceScreen xmlns:app="http://schemas.android.com/apk/res-auto">
+    <PreferenceCategory app:title="@string/general_settings">
+        <SwitchPreference
+            app:defaultValue="true"
+            app:icon="@drawable/ic_communication_phonelink_ring"
+            app:key="isAutoConnect"
+            app:summary="@string/auto_connect_summary"
+            app:title="@string/auto_connect" />
+        <SwitchPreference
+            app:icon="@drawable/ic_action_lock"
+            app:key="directBootAware"
+            app:summary="@string/direct_boot_aware_summary"
+            app:title="@string/direct_boot_aware" />
+        <com.takisoft.preferencex.SimpleMenuPreference
+            app:defaultValue="vpn"
+            app:entries="@array/service_modes"
+            app:entryValues="@array/service_mode_values"
+            app:icon="@drawable/ic_device_developer_mode"
+            app:key="serviceMode"
+            app:title="@string/service_mode"
+            app:useSimpleSummaryProvider="true" />
+        <SwitchPreference
+            app:icon="@drawable/ic_baseline_security_24"
+            app:key="securityAdvisory"
+            app:summary="@string/insecure_warn_sum"
+            app:title="@string/insecure_warn" />
+        <SwitchPreference
+            app:icon="@drawable/ic_device_data_usage"
+            app:key="meteredNetwork"
+            app:summary="@string/metered_summary"
+            app:title="@string/metered" />
+        <com.takisoft.preferencex.SimpleMenuPreference
+            app:defaultValue="1000"
+            app:entries="@array/notification_entry"
+            app:entryValues="@array/notification_value"
+            app:icon="@drawable/ic_baseline_shutter_speed_24"
+            app:key="speedInterval"
+            app:title="@string/speed_interval"
+            app:useSimpleSummaryProvider="true" />
+        <SwitchPreference
+            app:icon="@drawable/ic_baseline_speed_24"
+            app:key="showDirectSpeed"
+            app:summary="@string/show_direct_speed_sum"
+            app:title="@string/show_direct_speed"
+            app:useSimpleSummaryProvider="true" />
+        <SwitchPreference
+            app:icon="@drawable/ic_baseline_multiple_stop_24"
+            app:key="showStopButton"
+            app:summary="@string/show_stop_sum"
+            app:title="@string/show_stop" />
+        <SwitchPreference
+            app:icon="@drawable/ic_baseline_nat_24"
+            app:key="allowAccess"
+            app:summary="@string/allow_access_sum"
+            app:title="@string/allow_access" />
+    </PreferenceCategory>
+    <PreferenceCategory app:title="@string/inbound_settings">
+        <EditTextPreference
+            app:icon="@drawable/ic_maps_directions_boat"
+            app:key="socksPort"
+            app:title="@string/port_proxy"
+            app:useSimpleSummaryProvider="true" />
+        <SwitchPreference
+            app:icon="@drawable/ic_baseline_http_24"
+            app:key="requireHttp"
+            app:title="@string/require_http" />
+        <EditTextPreference
+            app:key="httpPort"
+            app:title="@string/port_http"
+            app:useSimpleSummaryProvider="true" />
+    </PreferenceCategory>
+
+    <PreferenceCategory app:title="@string/ipv6_settings">
+        <SwitchPreference
+            app:defaultValue="true"
+            app:icon="@drawable/ic_image_looks_6"
+            app:key="ipv6Route"
+            app:summary="@string/ipv6_summary"
+            app:title="@string/ipv6" />
+        <SwitchPreference
+            app:key="preferIpv6"
+            app:summary="@string/ipv6_prefer_summary"
+            app:title="@string/ipv6_prefer" />
+    </PreferenceCategory>
+
+    <PreferenceCategory app:title="@string/cag_dns">
+        <EditTextPreference
+            app:defaultValue="https://1.1.1.1/dns-query"
+            app:icon="@drawable/ic_action_dns"
+            app:key="remoteDns"
+            app:title="@string/remote_dns"
+            app:useSimpleSummaryProvider="true" />
+        <SwitchPreference
+            app:defaultValue="true"
+            app:key="enableLocalDns"
+            app:title="@string/local_dns" />
+        <EditTextPreference
+            app:defaultValue="5450"
+            app:key="portLocalDns"
+            app:title="@string/port_local_dns"
+            app:useSimpleSummaryProvider="true" />
+        <EditTextPreference
+            app:defaultValue="9.9.9.11"
+            app:key="domesticDns"
+            app:title="@string/domestic_dns"
+            app:useSimpleSummaryProvider="true" />
+    </PreferenceCategory>
+
+    <PreferenceCategory app:title="@string/cag_route">
+        <SwitchPreference
+            app:icon="@drawable/ic_navigation_apps"
+            app:key="proxyApps"
+            app:summary="@string/proxied_apps_summary"
+            app:title="@string/proxied_apps" />
+        <SwitchPreference
+            app:defaultValue="true"
+            app:icon="@drawable/ic_baseline_local_bar_24"
+            app:key="bypassLan"
+            app:title="@string/route_opt_bypass_lan" />
+        <com.takisoft.preferencex.SimpleMenuPreference
+            app:defaultValue="IPIfNonMatch"
+            app:entries="@array/domain_strategy"
+            app:entryValues="@array/domain_strategy"
+            app:icon="@drawable/ic_action_dns"
+            app:key="domainStrategy"
+            app:title="@string/domain_strategy"
+            app:useSimpleSummaryProvider="true" />
+        <com.takisoft.preferencex.SimpleMenuPreference
+            app:defaultValue="mph"
+            app:entries="@array/domain_matcher_entry"
+            app:entryValues="@array/domain_matcher_value"
+            app:icon="@drawable/ic_baseline_domain_24"
+            app:key="domainMatcher"
+            app:title="@string/domain_matcher"
+            app:useSimpleSummaryProvider="true" />
+        <SwitchPreference
+            app:defaultValue="true"
+            app:icon="@drawable/ic_baseline_manage_search_24"
+            app:key="trafficSniffing"
+            app:title="@string/traffic_sniffing" />
+    </PreferenceCategory>
+
+    <PreferenceCategory app:title="@string/cag_misc">
+        <SwitchPreference
+            app:defaultValue="false"
+            app:icon="@drawable/ic_baseline_compare_arrows_24"
+            app:key="enableMux"
+            app:summary="@string/mux_sum"
+            app:title="@string/enable_mux" />
+        <SwitchPreference
+            app:defaultValue="false"
+            app:icon="@drawable/ic_baseline_multiline_chart_24"
+            app:key="enableMuxForAll"
+            app:summary="@string/mux_for_all_sum"
+            app:title="@string/mux_for_all" />
+        <EditTextPreference
+            app:defaultValue="8"
+            app:icon="@drawable/ic_baseline_low_priority_24"
+            app:key="muxConcurrency"
+            app:title="@string/mux_concurrency"
+            app:useSimpleSummaryProvider="true" />
+        <EditTextPreference
+            app:defaultValue="15"
+            app:icon="@drawable/ic_baseline_flip_camera_android_24"
+            app:key="tcpKeepAliveInterval"
+            app:title="@string/tcp_keep_alive_interval"
+            app:useSimpleSummaryProvider="true" />
+        <SwitchPreference
+            app:icon="@drawable/ic_baseline_airplanemode_active_24"
+            app:key="forceShadowsocksRust"
+            app:summary="@string/force_ss_sum"
+            app:title="@string/force_ss_rust" />
+    </PreferenceCategory>
+
 
 </PreferenceScreen>

+ 40 - 101
app/src/main/res/xml/route_preferences.xml

@@ -1,117 +1,56 @@
 <?xml version="1.0" encoding="utf-8"?>
-<PreferenceScreen xmlns:app="http://schemas.android.com/apk/res-auto">
-
+<PreferenceScreen xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:app="http://schemas.android.com/apk/res-auto">
+    <EditTextPreference
+        app:icon="@drawable/ic_social_emoji_symbols"
+        app:key="routeName"
+        app:title="@string/route_name"
+        app:useSimpleSummaryProvider="true" />
     <PreferenceCategory app:title="@string/cag_route">
-        <SwitchPreference
-            app:icon="@drawable/ic_navigation_apps"
-            app:key="proxyApps"
-            app:summary="@string/proxied_apps_summary"
-            app:title="@string/proxied_apps" />
-        <SwitchPreference
-            app:defaultValue="true"
-            app:icon="@drawable/ic_baseline_local_bar_24"
-            app:key="bypassLan"
-            app:title="@string/route_opt_bypass_lan" />
-        <SwitchPreference
-            app:icon="@drawable/ic_baseline_gpp_bad_24"
-            app:key="blockAds"
-            app:title="@string/route_opt_block_ads" />
-        <com.takisoft.preferencex.SimpleMenuPreference
-            app:defaultValue="0"
-            app:entries="@array/route_entry"
-            app:entryValues="@array/route_value"
-            app:icon="@drawable/ic_baseline_vpn_lock_24"
-            app:key="routeChina"
-            app:title="@string/route_opt_china"
-            app:useSimpleSummaryProvider="true" />
-        <com.takisoft.preferencex.SimpleMenuPreference
-            app:defaultValue="IPIfNonMatch"
-            app:entries="@array/domain_strategy"
-            app:entryValues="@array/domain_strategy"
-            app:icon="@drawable/ic_action_dns"
-            app:key="domainStrategy"
-            app:title="@string/domain_strategy"
-            app:useSimpleSummaryProvider="true" />
-        <com.takisoft.preferencex.SimpleMenuPreference
-            app:defaultValue="mph"
-            app:entries="@array/domain_matcher_entry"
-            app:entryValues="@array/domain_matcher_value"
+        <EditTextPreference
             app:icon="@drawable/ic_baseline_domain_24"
-            app:key="domainMatcher"
-            app:title="@string/domain_matcher"
+            app:key="routeDomain"
+            app:title="domain"
             app:useSimpleSummaryProvider="true" />
-        <SwitchPreference
-            app:defaultValue="true"
-            app:icon="@drawable/ic_baseline_manage_search_24"
-            app:key="trafficSniffing"
-            app:title="@string/traffic_sniffing" />
-        <SwitchPreference
-            app:defaultValue="false"
-            app:icon="@drawable/ic_baseline_compare_arrows_24"
-            app:key="enableMux"
-            app:summary="@string/mux_sum"
-            app:title="@string/enable_mux" />
-        <SwitchPreference
-            app:defaultValue="false"
-            app:icon="@drawable/ic_baseline_multiline_chart_24"
-            app:key="enableMuxForAll"
-            app:summary="@string/mux_for_all_sum"
-            app:title="@string/mux_for_all" />
         <EditTextPreference
-            app:defaultValue="8"
-            app:icon="@drawable/ic_baseline_low_priority_24"
-            app:key="muxConcurrency"
-            app:title="@string/mux_concurrency"
+            app:icon="@drawable/ic_baseline_add_road_24"
+            app:key="routeIP"
+            app:title="ip"
             app:useSimpleSummaryProvider="true" />
         <EditTextPreference
-            app:defaultValue="15"
-            app:icon="@drawable/ic_baseline_flip_camera_android_24"
-            app:key="tcpKeepAliveInterval"
-            app:title="@string/tcp_keep_alive_interval"
+            android:layout_width="match_parent"
+            android:layout_height="match_parent"
+            app:icon="@drawable/ic_maps_directions_boat"
+            app:key="routePort"
+            app:title="port"
             app:useSimpleSummaryProvider="true" />
-    </PreferenceCategory>
-
-    <PreferenceCategory app:title="@string/cag_misc">
-        <SwitchPreference
-            app:defaultValue="true"
-            app:icon="@drawable/ic_image_looks_6"
-            app:key="ipv6Route"
-            app:summary="@string/ipv6_summary"
-            app:title="@string/ipv6" />
-        <SwitchPreference
-            app:key="preferIpv6"
-            app:summary="@string/ipv6_prefer_summary"
-            app:title="@string/ipv6_prefer" />
-        <SwitchPreference
-            app:icon="@drawable/ic_baseline_airplanemode_active_24"
-            app:key="forceShadowsocksRust"
-            app:summary="@string/force_ss_sum"
-            app:title="@string/force_ss_rust" />
-    </PreferenceCategory>
-
-    <PreferenceCategory
-        app:initialExpandedChildrenCount="1"
-        app:title="@string/cag_dns">
         <EditTextPreference
-            app:defaultValue="https://1.1.1.1/dns-query"
-            app:icon="@drawable/ic_action_dns"
-            app:key="remoteDns"
-            app:title="@string/remote_dns"
+            app:icon="@drawable/ic_baseline_home_24"
+            app:key="routeSourcePort"
+            app:title="sourcePort"
+            app:useSimpleSummaryProvider="true" />
+        <com.takisoft.preferencex.SimpleMenuPreference
+            app:icon="@drawable/ic_baseline_compare_arrows_24"
+            app:entries="@array/route_protocol_entry"
+            app:entryValues="@array/route_protocol_value"
+            app:key="routeNetwork"
+            app:title="network"
             app:useSimpleSummaryProvider="true" />
-        <SwitchPreference
-            app:defaultValue="true"
-            app:key="enableLocalDns"
-            app:title="@string/local_dns" />
         <EditTextPreference
-            app:defaultValue="5450"
-            app:key="portLocalDns"
-            app:title="@string/port_local_dns"
+            app:icon="@drawable/ic_baseline_local_bar_24"
+            app:key="routeSource"
+            app:title="source"
             app:useSimpleSummaryProvider="true" />
         <EditTextPreference
-            app:defaultValue="9.9.9.11"
-            app:key="domesticDns"
-            app:title="@string/domestic_dns"
+            app:icon="@drawable/ic_baseline_layers_24"
+            app:key="routeProtocol"
+            app:title="protocol"
+            app:useSimpleSummaryProvider="true" />
+        <io.nekohasekai.sagernet.widget.OutboundPreference
+            app:icon="@drawable/ic_hardware_router"
+            app:key="routeOutbound"
+            app:title="outbound"
             app:useSimpleSummaryProvider="true" />
-    </PreferenceCategory>
 
+    </PreferenceCategory>
 </PreferenceScreen>

+ 0 - 1
app/src/main/res/xml/shadowsocks_preferences.xml

@@ -5,7 +5,6 @@
         app:key="profileName"
         app:title="@string/profile_name"
         app:useSimpleSummaryProvider="true" />
-
     <PreferenceCategory app:title="@string/proxy_cat">
 
         <EditTextPreference

+ 1 - 1
app/src/main/res/xml/socks_preferences.xml

@@ -28,7 +28,7 @@
             app:icon="@drawable/ic_settings_password"
             app:key="serverPassword"
             app:title="@string/password_opt" />
-        <SwitchPreference
+        <SwitchPreferenceCompat
             app:icon="@drawable/ic_baseline_https_24"
             app:key="serverTLS"
             app:title="@string/tls" />