Browse Source

Improve balancer & gRPC API

世界 4 years ago
parent
commit
767554f045

+ 3 - 0
.gitmodules

@@ -52,3 +52,6 @@
 [submodule "external/preferencex"]
 	path = external/preferencex
 	url = https://github.com/SagerNet/preferencex-android
+[submodule "external/v2ray-core"]
+	path = external/v2ray-core
+	url = https://github.com/v2fly/v2ray-core

+ 2 - 0
.idea/gradle.xml

@@ -112,6 +112,8 @@
             <option value="$PROJECT_DIR$/external/preferencex/preferencex-simplemenu" />
             <option value="$PROJECT_DIR$/library" />
             <option value="$PROJECT_DIR$/library/core" />
+            <option value="$PROJECT_DIR$/library/proto" />
+            <option value="$PROJECT_DIR$/library/proto-stub" />
             <option value="$PROJECT_DIR$/library/shadowsocks" />
             <option value="$PROJECT_DIR$/library/shadowsocksr" />
             <option value="$PROJECT_DIR$/plugin" />

+ 4 - 0
app/build.gradle.kts

@@ -4,6 +4,7 @@ plugins {
     id("kotlin-kapt")
     id("kotlin-parcelize")
     id("com.mikepenz.aboutlibraries.plugin")
+    id("com.google.protobuf")
 }
 
 setupApp()
@@ -79,5 +80,8 @@ dependencies {
     implementation("editorkit:feature-editor:2.0.0")
     implementation("editorkit:language-json:2.0.0")
 
+    implementation(project(":library:proto-stub"))
+    implementation("io.grpc:grpc-okhttp:1.39.0")
+
     coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:1.1.5")
 }

+ 1 - 0
app/proguard-rules.pro

@@ -1,6 +1,7 @@
 -repackageclasses ''
 -allowaccessmodification
 -keep class io.nekohasekai.sagernet.** { *;}
+-keep class com.v2ray.** { *; }
 
 # SnakeYaml
 -keep class org.yaml.snakeyaml.** { *; }

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

@@ -72,6 +72,7 @@ object Key {
     const val TRANSPROXY_PORT = "transproxyPort"
 
     const val CONNECTION_TEST_URL = "connectionTestURL"
+    const val PROBE_INTERVAL = "probeInterval"
 
     const val ENABLE_MUX = "enableMux"
     const val ENABLE_MUX_FOR_ALL = "enableMuxForAll"
@@ -82,6 +83,8 @@ object Key {
     const val RULES_PROVIDER = "rulesProvider"
     const val ENABLE_LOG = "enableLog"
 
+    const val API_PORT = "apiPort"
+
     const val PROFILE_DIRTY = "profileDirty"
     const val PROFILE_ID = "profileId"
     const val PROFILE_NAME = "profileName"

+ 4 - 4
app/src/main/java/io/nekohasekai/sagernet/bg/BaseService.kt

@@ -134,10 +134,10 @@ class BaseService {
                 val proxy = data?.proxy ?: continue
                 lastQueryTime = queryTime
                 val stats = TrafficStats(
-                    (proxy.uplinkProxy / sinceLastQueryInSeconds).toLong(),
-                    (proxy.downlinkProxy / sinceLastQueryInSeconds).toLong(),
-                    if (showDirectSpeed) (proxy.uplinkDirect / sinceLastQueryInSeconds).toLong() else 0L,
-                    if (showDirectSpeed) (proxy.downlinkDirect / sinceLastQueryInSeconds).toLong() else 0L,
+                    (proxy.uplinkProxy() / sinceLastQueryInSeconds).toLong(),
+                    (proxy.downlinkProxy() / sinceLastQueryInSeconds).toLong(),
+                    if (showDirectSpeed) (proxy.uplinkDirect() / sinceLastQueryInSeconds).toLong() else 0L,
+                    if (showDirectSpeed) (proxy.downlinkDirect() / sinceLastQueryInSeconds).toLong() else 0L,
                     proxy.uplinkTotalProxy,
                     proxy.downlinkTotalProxy
                 )

+ 72 - 42
app/src/main/java/io/nekohasekai/sagernet/bg/ProxyInstance.kt

@@ -29,13 +29,17 @@ import android.webkit.WebResourceError
 import android.webkit.WebResourceRequest
 import android.webkit.WebView
 import android.webkit.WebViewClient
-import cn.hutool.json.JSONArray
-import cn.hutool.json.JSONObject
+import com.v2ray.core.app.stats.command.GetStatsRequest
+import com.v2ray.core.app.stats.command.StatsServiceGrpcKt
+import io.grpc.ManagedChannel
+import io.grpc.StatusException
+import io.grpc.okhttp.OkHttpChannelBuilder
 import io.nekohasekai.sagernet.IPv6Mode
 import io.nekohasekai.sagernet.SagerNet
 import io.nekohasekai.sagernet.database.DataStore
 import io.nekohasekai.sagernet.database.ProxyEntity
 import io.nekohasekai.sagernet.database.SagerDatabase
+import io.nekohasekai.sagernet.fmt.LOCALHOST
 import io.nekohasekai.sagernet.fmt.V2rayBuildResult
 import io.nekohasekai.sagernet.fmt.brook.BrookBean
 import io.nekohasekai.sagernet.fmt.brook.internalUri
@@ -56,8 +60,7 @@ import io.nekohasekai.sagernet.fmt.trojan_go.buildTrojanGoConfig
 import io.nekohasekai.sagernet.ktx.*
 import io.nekohasekai.sagernet.plugin.PluginManager.InitResult
 import io.nekohasekai.sagernet.utils.DirectBoot
-import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.delay
+import kotlinx.coroutines.*
 import libv2ray.Libv2ray
 import libv2ray.V2RayPoint
 import libv2ray.V2RayVPNServiceSupportsSet
@@ -73,6 +76,9 @@ class ProxyInstance(val profile: ProxyEntity) {
     lateinit var base: BaseService.Interface
     lateinit var wsForwarder: WebView
 
+    lateinit var managedChannel: ManagedChannel
+    val statsService by lazy { StatsServiceGrpcKt.StatsServiceCoroutineStub(managedChannel) }
+
     val pluginPath = hashMapOf<String, InitResult>()
     fun initPlugin(name: String): InitResult {
         return pluginPath.getOrPut(name) { PluginManagerS.init(name)!! }
@@ -100,8 +106,7 @@ class ProxyInstance(val profile: ProxyEntity) {
                     when {
                         profile.useExternalShadowsocks() -> {
                             bean as ShadowsocksBean
-                            pluginConfigs[port] =
-                                profile.type to bean.buildShadowsocksConfig(port)
+                            pluginConfigs[port] = profile.type to bean.buildShadowsocksConfig(port)
                         }
                         bean is ShadowsocksRBean -> {
                             pluginConfigs[port] =
@@ -178,7 +183,7 @@ class ProxyInstance(val profile: ProxyEntity) {
             chain.entries.forEachIndexed { index, (port, profile) ->
                 val bean = profile.requireBean()
                 val needChain = !isBalancer && index != chain.size - 1
-                val (_,config) = pluginConfigs[port] ?: return@forEachIndexed
+                val (_, config) = pluginConfigs[port] ?: return@forEachIndexed
 
                 when {
                     profile.useExternalShadowsocks() -> {
@@ -408,12 +413,20 @@ class ProxyInstance(val profile: ProxyEntity) {
             }
         }
 
+        managedChannel =
+            OkHttpChannelBuilder.forAddress(LOCALHOST, DataStore.apiPort).usePlaintext()
+                .executor(Dispatchers.Default.asExecutor()).build()
+
         DataStore.startedProxy = profile.id
     }
 
     fun stop() {
         v2rayPoint.stopLoop()
 
+        if (::managedChannel.isInitialized) {
+            managedChannel.shutdownNow()
+        }
+
         if (::wsForwarder.isInitialized) {
             wsForwarder.loadUrl("about:blank")
             wsForwarder.destroy()
@@ -422,41 +435,56 @@ class ProxyInstance(val profile: ProxyEntity) {
         DataStore.startedProxy = 0L
     }
 
-    fun stats(direct: String): Long {
-        if (!::config.isInitialized) {
+    private suspend fun queryStats(tag: String, direct: String): Long {
+        try {
+            return queryStatsGrpc(tag, direct)
+        } catch (e: StatusException) {
+            Logs.w(e)
+            if (isExpert) return 0L
+        }
+        return v2rayPoint.queryStats(tag, direct)
+    }
+
+    private suspend fun queryStatsGrpc(tag: String, direct: String): Long {
+        if (!::managedChannel.isInitialized) {
             return 0L
         }
-        return config.outboundTags.map { v2rayPoint.queryStats(it, direct) }
-            .fold(0L) { acc, l -> acc + l }
+        val result = statsService.getStats(
+            GetStatsRequest.newBuilder().setName("outbound>>>$tag>>>traffic>>>$direct")
+                .setReset(true).build()
+        )
+        return result.stat.value
     }
 
-    fun statsDirect(direct: String): Long {
+    private suspend fun outboundStats(direct: String): Long {
         if (!::config.isInitialized) {
             return 0L
         }
-        return v2rayPoint.queryStats(config.directTag, direct)
+        return config.outboundTags.map { queryStats(it, direct) }.fold(0L) { acc, l -> acc + l }
     }
 
-    val uplinkProxy
-        get() = stats("uplink").also {
-            uplinkTotalProxy += it
+    suspend fun directStats(direct: String): Long {
+        if (!::config.isInitialized) {
+            return 0L
         }
+        return queryStats(config.directTag, direct)
+    }
 
-    val downlinkProxy
-        get() = stats("downlink").also {
-            downlinkTotalProxy += it
-        }
+    suspend fun uplinkProxy() = outboundStats("uplink").also {
+        uplinkTotalProxy += it
+    }
 
-    val uplinkDirect
-        get() = statsDirect("uplink").also {
-            uplinkTotalDirect += it
-        }
+    suspend fun downlinkProxy() = outboundStats("downlink").also {
+        downlinkTotalProxy += it
+    }
 
-    val downlinkDirect
-        get() = statsDirect("downlink").also {
-            downlinkTotalDirect += it
-        }
+    suspend fun uplinkDirect() = directStats("uplink").also {
+        uplinkTotalDirect += it
+    }
 
+    suspend fun downlinkDirect() = directStats("downlink").also {
+        downlinkTotalDirect += it
+    }
 
     var uplinkTotalProxy = 0L
     var downlinkTotalProxy = 0L
@@ -464,20 +492,22 @@ class ProxyInstance(val profile: ProxyEntity) {
     var downlinkTotalDirect = 0L
 
     fun persistStats() {
-        try {
-            uplinkProxy
-            downlinkProxy
-            profile.tx += uplinkTotalProxy
-            profile.rx += downlinkTotalProxy
-            SagerDatabase.proxyDao.updateProxy(profile)
-        } catch (e: IOException) {
-            if (!DataStore.directBootAware) throw e // we should only reach here because we're in direct boot
-            val profile = DirectBoot.getDeviceProfile()!!
-            profile.tx += uplinkTotalProxy
-            profile.rx += downlinkTotalProxy
-            profile.dirty = true
-            DirectBoot.update(profile)
-            DirectBoot.listenForUnlock()
+        runBlocking {
+            try {
+                uplinkProxy()
+                downlinkProxy()
+                profile.tx += uplinkTotalProxy
+                profile.rx += downlinkTotalProxy
+                SagerDatabase.proxyDao.updateProxy(profile)
+            } catch (e: IOException) {
+                if (!DataStore.directBootAware) throw e // we should only reach here because we're in direct boot
+                val profile = DirectBoot.getDeviceProfile()!!
+                profile.tx += uplinkTotalProxy
+                profile.rx += downlinkTotalProxy
+                profile.dirty = true
+                DirectBoot.update(profile)
+                DirectBoot.listenForUnlock()
+            }
         }
     }
 

+ 11 - 2
app/src/main/java/io/nekohasekai/sagernet/database/DataStore.kt

@@ -106,6 +106,13 @@ object DataStore : OnPreferenceDataStoreChangeListener {
     var transproxyPort: Int
         get() = getLocalPort(Key.TRANSPROXY_PORT, 9200)
         set(value) = saveLocalPort(Key.TRANSPROXY_PORT, value)
+    var apiPort: Int
+        get() = getLocalPort(Key.API_PORT, 9002)
+        set(value) {
+            saveLocalPort(Key.API_PORT, value)
+        }
+
+    var probeInterval by configurationStore.stringToInt(Key.PROBE_INTERVAL) { 0 }
 
     fun initGlobal() {
         if (configurationStore.getString(Key.SOCKS_PORT) == null) {
@@ -120,7 +127,9 @@ object DataStore : OnPreferenceDataStoreChangeListener {
         if (configurationStore.getString(Key.TRANSPROXY_PORT) == null) {
             transproxyPort = transproxyPort
         }
-
+        if (configurationStore.getString(Key.API_PORT) == null) {
+            apiPort = apiPort
+        }
     }
 
 
@@ -194,7 +203,7 @@ object DataStore : OnPreferenceDataStoreChangeListener {
     var serverAllowInsecure by profileCacheStore.boolean(Key.SERVER_ALLOW_INSECURE)
 
     var balancerType by profileCacheStore.stringToInt(Key.BALANCER_TYPE)
-    var balancerGroup  by profileCacheStore.stringToLong(Key.BALANCER_GROUP)
+    var balancerGroup by profileCacheStore.stringToLong(Key.BALANCER_GROUP)
     var balancerStrategy by profileCacheStore.string(Key.BALANCER_STRATEGY)
 
     var routeName by profileCacheStore.string(Key.ROUTE_NAME)

+ 50 - 9
app/src/main/java/io/nekohasekai/sagernet/fmt/ConfigBuilder.kt

@@ -61,6 +61,11 @@ const val TAG_BLOCK = "block"
 const val TAG_DNS_IN = "dns-in"
 const val TAG_DNS_OUT = "dns-out"
 
+const val TAG_API_IN = "api-in"
+const val TAG_API = "api"
+
+const val LOCALHOST = "127.0.0.1"
+
 class V2rayBuildResult(
     var config: String,
     var index: ArrayList<Pair<Boolean, LinkedHashMap<Int, ProxyEntity>>>,
@@ -121,7 +126,7 @@ fun buildV2RayConfig(proxy: ProxyEntity): V2rayBuildResult {
         })) to it.resolveChain()
     }.toMap()
 
-    val bind = if (DataStore.allowAccess) "0.0.0.0" else "127.0.0.1"
+    val bind = if (DataStore.allowAccess) "0.0.0.0" else LOCALHOST
 
     val dnsMode = DataStore.dnsMode
     DataStore.dnsModeFinal = dnsMode
@@ -365,7 +370,7 @@ fun buildV2RayConfig(proxy: ProxyEntity): V2rayBuildResult {
                 val needGlobal: Boolean
 
                 if (isBalancer || index == profileList.size - 1 && !pastExternal) {
-                    tagIn = "$TAG_DIRECT-global-${proxyEntity.id}"
+                    tagIn = "$TAG_AGENT-global-${proxyEntity.id}"
                     needGlobal = true
                 } else {
                     tagIn = if (index == 0) tagOutbound else {
@@ -395,7 +400,7 @@ fun buildV2RayConfig(proxy: ProxyEntity): V2rayBuildResult {
                                 SocksOutboundConfigurationObject().apply {
                                     servers = listOf(
                                         SocksOutboundConfigurationObject.ServerObject().apply {
-                                                address = "127.0.0.1"
+                                                address = LOCALHOST
                                                 port = localPort
                                             })
                                 })
@@ -740,7 +745,7 @@ fun buildV2RayConfig(proxy: ProxyEntity): V2rayBuildResult {
                             chainMap[localPort] = proxyEntity
                             inbounds.add(InboundObject().apply {
                                 tag = "$tagIn-in"
-                                listen = "127.0.0.1"
+                                listen = LOCALHOST
                                 port = localPort
                                 protocol = "socks"
                                 settings = LazyInboundConfigurationObject(this,
@@ -785,7 +790,13 @@ fun buildV2RayConfig(proxy: ProxyEntity): V2rayBuildResult {
                     tag = "balancer-$tagOutbound"
                     selector = chainOutbounds.map { it.tag }
 
-                    if (observatory == null) observatory = ObservatoryObject()
+                    if (observatory == null) observatory = ObservatoryObject().apply {
+                        probeUrl = DataStore.connectionTestURL
+                        val testInterval = DataStore.probeInterval
+                        if (testInterval > 0) {
+                            probeInterval = testInterval
+                        }
+                    }
                     if (observatory.subjectSelector == null) observatory.subjectSelector = HashSet()
                     observatory.subjectSelector.addAll(selector)
 
@@ -891,7 +902,7 @@ fun buildV2RayConfig(proxy: ProxyEntity): V2rayBuildResult {
 
         if (requireWs) {
             browserForwarder = BrowserForwarderObject().apply {
-                listenAddr = "127.0.0.1"
+                listenAddr = LOCALHOST
                 listenPort = DataStore.socksPort + 1
             }
         }
@@ -954,7 +965,7 @@ fun buildV2RayConfig(proxy: ProxyEntity): V2rayBuildResult {
         if (dnsMode != DnsMode.SYSTEM) {
             inbounds.add(InboundObject().apply {
                 tag = TAG_DNS_IN
-                listen = "127.0.0.1"
+                listen = LOCALHOST
                 port = DataStore.localDNSPort
                 protocol = "dokodemo-door"
                 settings = LazyInboundConfigurationObject(this,
@@ -1074,6 +1085,36 @@ fun buildV2RayConfig(proxy: ProxyEntity): V2rayBuildResult {
 
         stats = emptyMap()
 
+        val apiPort = DataStore.apiPort
+
+        api = ApiObject().apply {
+            tag = TAG_API
+            services = mutableListOf("StatsService")
+            if (!observatory?.subjectSelector.isNullOrEmpty()) {
+                services.add("ObservatoryService")
+            }
+        }
+
+        inbounds.add(InboundObject().apply {
+            protocol = "dokodemo-door"
+            listen = LOCALHOST
+            port = apiPort
+            tag = TAG_API_IN
+            settings = LazyInboundConfigurationObject(
+                this,
+                DokodemoDoorInboundConfigurationObject().apply {
+                    address = LOCALHOST
+                    port = apiPort
+                    network = "tcp"
+                })
+        })
+
+        routing.rules.add(0, RoutingObject.RuleObject().apply {
+            type = "field"
+            inboundTag = listOf(TAG_API_IN)
+            outboundTag = TAG_API
+        })
+
     }.let {
         V2rayBuildResult(
             gson.toJson(it), indexMap, requireWs, outboundTags, TAG_DIRECT
@@ -1084,7 +1125,7 @@ fun buildV2RayConfig(proxy: ProxyEntity): V2rayBuildResult {
 
 fun buildCustomConfig(proxy: ProxyEntity): V2rayBuildResult {
 
-    val bind = if (DataStore.allowAccess) "0.0.0.0" else "127.0.0.1"
+    val bind = if (DataStore.allowAccess) "0.0.0.0" else LOCALHOST
     val trafficSniffing = DataStore.trafficSniffing
 
     val bean = proxy.configBean!!
@@ -1302,7 +1343,7 @@ fun buildCustomConfig(proxy: ProxyEntity): V2rayBuildResult {
     if (config.contains("browserForwarder")) {
         config.set("browserForwarder", JSONObject(gson.toJson(BrowserForwarderObject().apply {
             requireWs = true
-            listenAddr = "127.0.0.1"
+            listenAddr = LOCALHOST
             listenPort = DataStore.socksPort + 1
         })))
     }

+ 2 - 0
app/src/main/java/io/nekohasekai/sagernet/fmt/v2ray/V2RayConfig.java

@@ -821,6 +821,8 @@ public class V2RayConfig {
 
     public static class ObservatoryObject {
         public Set<String> subjectSelector;
+        public String probeUrl;
+        public Integer probeInterval;
     }
 
     public void init() {

+ 5 - 6
app/src/main/java/io/nekohasekai/sagernet/ktx/Utils.kt

@@ -51,6 +51,7 @@ import cn.hutool.core.util.CharsetUtil
 import io.nekohasekai.sagernet.BuildConfig
 import io.nekohasekai.sagernet.R
 import io.nekohasekai.sagernet.SagerNet
+import io.nekohasekai.sagernet.database.DataStore
 import io.nekohasekai.sagernet.ui.MainActivity
 import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.GlobalScope
@@ -124,8 +125,7 @@ fun Context.listenForPackageChanges(onetime: Boolean = true, callback: () -> Uni
     }
 
 val PackageInfo.signaturesCompat
-    get() =
-        if (Build.VERSION.SDK_INT >= 28) signingInfo.apkContentsSigners else @Suppress("DEPRECATION") signatures
+    get() = if (Build.VERSION.SDK_INT >= 28) signingInfo.apkContentsSigners else @Suppress("DEPRECATION") signatures
 
 /**
  * Based on: https://stackoverflow.com/a/26348729/2245107
@@ -150,11 +150,10 @@ private val parseNumericAddress by lazy {
     }
 }
 
-fun String?.parseNumericAddress(): InetAddress? = Os.inet_pton(OsConstants.AF_INET, this)
-    ?: Os.inet_pton(OsConstants.AF_INET6, this)?.let {
+fun String?.parseNumericAddress(): InetAddress? =
+    Os.inet_pton(OsConstants.AF_INET, this) ?: Os.inet_pton(OsConstants.AF_INET6, this)?.let {
         if (Build.VERSION.SDK_INT >= 29) it else parseNumericAddress.invoke(
-            null,
-            this
+            null, this
         ) as InetAddress
     }
 

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

@@ -101,6 +101,7 @@ class SettingsPreferenceFragment : PreferenceFragmentCompat() {
                 true
             }
         }
+        val portLocalDns = findPreference<EditTextPreference>(Key.LOCAL_DNS_PORT)!!
 
 
         val showStopButton = findPreference<SwitchPreference>(Key.SHOW_STOP_BUTTON)!!
@@ -139,6 +140,8 @@ class SettingsPreferenceFragment : PreferenceFragmentCompat() {
         val transproxyMode = findPreference<SimpleMenuPreference>(Key.TRANSPROXY_MODE)!!
         val enableLog = findPreference<SwitchPreference>(Key.ENABLE_LOG)!!
 
+        val apiPort = findPreference<EditTextPreference>(Key.API_PORT)!!
+
         transproxyPort.isEnabled = requireTransproxy.isChecked
         transproxyMode.isEnabled = requireTransproxy.isChecked
 
@@ -169,12 +172,11 @@ class SettingsPreferenceFragment : PreferenceFragmentCompat() {
             true
         }
 
-        val portLocalDns = findPreference<EditTextPreference>(Key.LOCAL_DNS_PORT)!!
-
         portLocalDns.setOnBindEditTextListener(EditTextPreferenceModifiers.Port)
         muxConcurrency.setOnBindEditTextListener(EditTextPreferenceModifiers.Port)
         portSocks5.setOnBindEditTextListener(EditTextPreferenceModifiers.Port)
         portHttp.setOnBindEditTextListener(EditTextPreferenceModifiers.Port)
+        apiPort.setOnBindEditTextListener(EditTextPreferenceModifiers.Port)
 
         val metedNetwork = findPreference<Preference>(Key.METERED_NETWORK)!!
         if (Build.VERSION.SDK_INT < 28) {

+ 3 - 0
app/src/main/res/values-zh-rCN/strings.xml

@@ -325,5 +325,8 @@
     <string name="balancer_strategy">策略</string>
     <string name="list">列表</string>
     <string name="random">随机</string>
+    <string name="api_port">API 端口</string>
+    <string name="experimental_api">使用实验性的 API</string>
+    <string name="probe_interval">负载均衡观测间隔</string>
 
 </resources>

+ 4 - 0
app/src/main/res/values/strings.xml

@@ -349,4 +349,8 @@
     <string name="random">Random</string>
     <string name="leastPing" translatable="false">Ping</string>
 
+    <string name="api_port">API Port</string>
+    <string name="experimental_api">Use Experimental API</string>
+    <string name="probe_interval">Balancer observation interval</string>
+
 </resources>

+ 11 - 3
app/src/main/res/xml/global_preferences.xml

@@ -83,7 +83,7 @@
             app:title="@string/proxied_apps" />
         <SwitchPreference
             app:defaultValue="true"
-            app:icon="@drawable/ic_baseline_local_bar_24"
+            app:icon="@drawable/ic_baseline_legend_toggle_24"
             app:key="bypassLan"
             app:title="@string/route_opt_bypass_lan" />
         <com.takisoft.preferencex.SimpleMenuPreference
@@ -161,7 +161,6 @@
             app:title="@string/port_proxy"
             app:useSimpleSummaryProvider="true" />
         <EditTextPreference
-            app:defaultValue="5450"
             app:key="portLocalDns"
             app:title="@string/port_local_dns"
             app:useSimpleSummaryProvider="true" />
@@ -178,7 +177,6 @@
             app:key="requireTransproxy"
             app:title="@string/require_transproxy" />
         <EditTextPreference
-            app:defaultValue="5450"
             app:key="transproxyPort"
             app:title="@string/port_transproxy"
             app:useSimpleSummaryProvider="true" />
@@ -189,6 +187,11 @@
             app:key="transproxyMode"
             app:title="@string/transproxy_mode"
             app:useSimpleSummaryProvider="true" />
+        <EditTextPreference
+            app:icon="@drawable/ic_baseline_nfc_24"
+            app:key="apiPort"
+            app:title="@string/api_port"
+            app:useSimpleSummaryProvider="true" />
     </PreferenceCategory>
 
     <PreferenceCategory app:title="@string/cag_misc">
@@ -198,6 +201,11 @@
             app:key="connectionTestURL"
             app:title="@string/connection_test_url"
             app:useSimpleSummaryProvider="true" />
+        <EditTextPreference
+            app:icon="@drawable/ic_baseline_local_bar_24"
+            app:key="probeInterval"
+            app:title="@string/probe_interval"
+            app:useSimpleSummaryProvider="true" />
         <SwitchPreference
             app:defaultValue="false"
             app:icon="@drawable/ic_baseline_compare_arrows_24"

+ 1 - 0
build.gradle.kts

@@ -4,6 +4,7 @@ import com.github.benmanes.gradle.versions.updates.DependencyUpdatesTask
 
 plugins {
     id("com.github.ben-manes.versions") version "0.39.0" apply false
+    id("com.google.protobuf") version "0.8.16" apply false
 }
 
 buildscript {

+ 1 - 0
external/v2ray-core

@@ -0,0 +1 @@
+Subproject commit 91a228606484d0eaebf0c1b30df36169ba990541

+ 58 - 0
library/proto-stub/build.gradle.kts

@@ -0,0 +1,58 @@
+import com.google.protobuf.gradle.generateProtoTasks
+import com.google.protobuf.gradle.id
+import com.google.protobuf.gradle.plugins
+import com.google.protobuf.gradle.protobuf
+import com.google.protobuf.gradle.protoc
+
+plugins {
+    id("com.android.library")
+    kotlin("android")
+    id("com.google.protobuf")
+}
+
+setupKotlinCommon()
+
+val grpcVersion = "1.39.0"
+val grpcKotlinVersion = "1.1.0"
+val protobufVersion = "3.17.3"
+
+dependencies {
+    protobuf(project(":library:proto"))
+
+    api("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.5.0")
+    api("io.grpc:grpc-protobuf-lite:$grpcVersion")
+    api("io.grpc:grpc-kotlin-stub:$grpcKotlinVersion")
+    api("com.google.protobuf:protobuf-javalite:$protobufVersion")
+}
+
+protobuf {
+    protoc {
+        artifact = "com.google.protobuf:protoc:$protobufVersion"
+    }
+    plugins {
+        id("java") {
+            artifact = "io.grpc:protoc-gen-grpc-java:$grpcVersion"
+        }
+        id("grpc") {
+            artifact = "io.grpc:protoc-gen-grpc-java:$grpcVersion"
+        }
+        id("grpckt") {
+            artifact = "io.grpc:protoc-gen-grpc-kotlin:$grpcKotlinVersion:jdk7@jar"
+        }
+    }
+    generateProtoTasks {
+        all().forEach {
+            it.plugins {
+                id("java") {
+                    option("lite")
+                }
+                id("grpc") {
+                    option("lite")
+                }
+                id("grpckt") {
+                    option("lite")
+                }
+            }
+        }
+    }
+}

+ 2 - 0
library/proto-stub/src/main/AndroidManifest.xml

@@ -0,0 +1,2 @@
+<?xml version="1.0" encoding="utf-8"?>
+<manifest package="com.v2ray.core" />

+ 7 - 0
library/proto/build.gradle.kts

@@ -0,0 +1,7 @@
+plugins {
+    `java-library`
+}
+
+java {
+    sourceSets.getByName("main").resources.srcDir(rootProject.file("external/v2ray-core"))
+}

+ 1 - 1
library/v2ray

@@ -1 +1 @@
-Subproject commit 48c0c0e897897546a91da42e9a23c5acb6e8f92f
+Subproject commit 5e81a33e635705b040fe11134822b8a61b28bc97

+ 2 - 0
settings.gradle.kts

@@ -1,6 +1,8 @@
 include(":library:core")
 include(":library:shadowsocks")
 include(":library:shadowsocksr")
+include(":library:proto")
+include(":library:proto-stub")
 
 include(":plugin:api")
 include(":plugin:trojan-go")