feat(client/android): urltest 延迟与 iOS/macOS 对齐 + 开明文(联调 http API)
Android 同 iOS 根因:单 CommandClient 的 Group 命令拿不到 urltest + 配置无 clash_api。 - PangolinVpnService:startKernel 注入 clash_api(127.0.0.1)+ cache_file;新增 pollClash 经本地 HTTP 查 /proxies(history)+ /group/<name>/delay 取真实延迟,替换原依赖 Group 回调的 urlTestTimer(Group 不回调,延迟恒空)。逻辑与 iOS StatsCollector 完全一致。 - Manifest:usesCleartextTraffic=true —— 控制面 API 当前 http,Android 9+ 默认禁明文, 否则登录就连不上(生产转 https 后去掉)。 注:需 libbox.aar(gomobile,要 JDK 17)才能编译验证;本机仅 JDK 21,待装 17。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -26,7 +26,8 @@
|
|||||||
<application
|
<application
|
||||||
android:label="穿山甲"
|
android:label="穿山甲"
|
||||||
android:name="${applicationName}"
|
android:name="${applicationName}"
|
||||||
android:icon="@mipmap/ic_launcher">
|
android:icon="@mipmap/ic_launcher"
|
||||||
|
android:usesCleartextTraffic="true"><!-- 控制面 API 当前为 http(联调),Android 9+ 默认禁明文,需开;生产改 https 后可去掉 -->
|
||||||
|
|
||||||
<!-- 主 Activity -->
|
<!-- 主 Activity -->
|
||||||
<activity
|
<activity
|
||||||
|
|||||||
+114
-11
@@ -98,6 +98,12 @@ class PangolinVpnService : VpnService(), PlatformInterface, CommandServerHandler
|
|||||||
@Volatile private var activeOutbound: String = "auto"
|
@Volatile private var activeOutbound: String = "auto"
|
||||||
@Volatile private var latestUrltest: List<Pair<String, Int>> = emptyList()
|
@Volatile private var latestUrltest: List<Pair<String, Int>> = emptyList()
|
||||||
|
|
||||||
|
// clash API(内核内注入,127.0.0.1 本地监听):取出口组 urltest 真实延迟。
|
||||||
|
// 服务端配置无 clash_api → sing-box 不暴露组/urltest 历史,单 CommandClient 的 Group
|
||||||
|
// 命令拿不到延迟。注入后用与 iOS/macOS/Windows 完全一致的取法(/proxies + /group/delay)。
|
||||||
|
@Volatile private var clashBase: String = ""
|
||||||
|
@Volatile private var clashSecret: String = ""
|
||||||
|
|
||||||
/** 缓存的「可手动选择的出口组」tag(selector 组)。selectOutbound 据此切换。 */
|
/** 缓存的「可手动选择的出口组」tag(selector 组)。selectOutbound 据此切换。 */
|
||||||
@Volatile private var selectableGroup: String = ""
|
@Volatile private var selectableGroup: String = ""
|
||||||
|
|
||||||
@@ -177,7 +183,13 @@ class PangolinVpnService : VpnService(), PlatformInterface, CommandServerHandler
|
|||||||
server.start()
|
server.start()
|
||||||
commandServer = server
|
commandServer = server
|
||||||
|
|
||||||
server.startOrReloadService(configJson, OverrideOptions())
|
// 注入 clash_api(127.0.0.1 本地监听)+ cache_file,让内核暴露出口组/urltest 历史;
|
||||||
|
// 由 pollClash 经本地 HTTP 取真实延迟(与 iOS/macOS 同法)。
|
||||||
|
val clashPort = (20000..60000).random()
|
||||||
|
val secret = java.util.UUID.randomUUID().toString()
|
||||||
|
clashBase = "http://127.0.0.1:$clashPort"
|
||||||
|
clashSecret = secret
|
||||||
|
server.startOrReloadService(injectClashApi(configJson, clashPort, secret), OverrideOptions())
|
||||||
Log.i(TAG, "sing-box service started")
|
Log.i(TAG, "sing-box service started")
|
||||||
|
|
||||||
startStatsClient()
|
startStatsClient()
|
||||||
@@ -220,23 +232,114 @@ class PangolinVpnService : VpnService(), PlatformInterface, CommandServerHandler
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 周期主动触发 urltest 探测,让延迟稳定出现(补偿惰性探测)。 */
|
/** 周期经 clash API 取 urltest 延迟(与 iOS/macOS 同策略:读 /proxies history + /group/delay
|
||||||
|
* 刷新;非空才更新,单次失败/空不覆盖旧值,拥塞链路也稳定显示)。 */
|
||||||
private fun startUrlTestTimer() {
|
private fun startUrlTestTimer() {
|
||||||
urlTestTimer?.cancel()
|
urlTestTimer?.cancel()
|
||||||
val timer = java.util.Timer("pangolin-urltest", true)
|
val timer = java.util.Timer("pangolin-urltest", true)
|
||||||
timer.scheduleAtFixedRate(object : java.util.TimerTask() {
|
timer.scheduleAtFixedRate(object : java.util.TimerTask() {
|
||||||
override fun run() {
|
override fun run() { try { pollClash() } catch (e: Exception) { Log.w(TAG, "pollClash: $e") } }
|
||||||
val client = statsClient ?: return
|
}, 3_000L, 8_000L)
|
||||||
for (tag in groupTags) {
|
|
||||||
try { client.urlTest(tag) } catch (e: Exception) {
|
|
||||||
Log.w(TAG, "urlTest($tag) failed: $e")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, 2_000L, 12_000L)
|
|
||||||
urlTestTimer = timer
|
urlTestTimer = timer
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun pollClash() {
|
||||||
|
val proxies = clashGet("/proxies") ?: return
|
||||||
|
extractUrltestResults(proxies).let { if (it.isNotEmpty()) latestUrltest = it }
|
||||||
|
for (name in extractUrltestGroups(proxies)) {
|
||||||
|
if (name == "GLOBAL") continue
|
||||||
|
val resp = clashGet("/group/$name/delay?url=http%3A%2F%2Fwww.gstatic.com%2Fgenerate_204&timeout=5000")
|
||||||
|
?: continue
|
||||||
|
parseDelayMap(resp).let { if (it.isNotEmpty()) latestUrltest = it }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** clash REST GET(本地、Bearer 鉴权),返回 JSON 对象;失败返回 null。 */
|
||||||
|
private fun clashGet(path: String): org.json.JSONObject? {
|
||||||
|
return try {
|
||||||
|
val conn = java.net.URL(clashBase + path).openConnection() as java.net.HttpURLConnection
|
||||||
|
conn.connectTimeout = 8000
|
||||||
|
conn.readTimeout = 8000
|
||||||
|
conn.requestMethod = "GET"
|
||||||
|
if (clashSecret.isNotEmpty()) conn.setRequestProperty("Authorization", "Bearer $clashSecret")
|
||||||
|
if (conn.responseCode != 200) { conn.disconnect(); return null }
|
||||||
|
val body = conn.inputStream.bufferedReader().use { it.readText() }
|
||||||
|
conn.disconnect()
|
||||||
|
org.json.JSONObject(body)
|
||||||
|
} catch (e: Exception) { null }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** /proxies 里各 URLTest/Fallback 组成员的最新 history 延迟。 */
|
||||||
|
private fun extractUrltestResults(resp: org.json.JSONObject): List<Pair<String, Int>> {
|
||||||
|
val out = mutableListOf<Pair<String, Int>>()
|
||||||
|
val proxies = resp.optJSONObject("proxies") ?: return out
|
||||||
|
val keys = proxies.keys()
|
||||||
|
while (keys.hasNext()) {
|
||||||
|
val group = proxies.optJSONObject(keys.next()) ?: continue
|
||||||
|
val type = group.optString("type")
|
||||||
|
if (type != "URLTest" && type != "Fallback") continue
|
||||||
|
val all = group.optJSONArray("all") ?: continue
|
||||||
|
for (i in 0 until all.length()) {
|
||||||
|
val tag = all.optString(i)
|
||||||
|
val member = proxies.optJSONObject(tag) ?: continue
|
||||||
|
val history = member.optJSONArray("history") ?: continue
|
||||||
|
if (history.length() == 0) continue
|
||||||
|
val delay = history.optJSONObject(history.length() - 1)?.optInt("delay", 0) ?: 0
|
||||||
|
if (delay > 0) out.add(tag to delay)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun extractUrltestGroups(resp: org.json.JSONObject): List<String> {
|
||||||
|
val out = mutableListOf<String>()
|
||||||
|
val proxies = resp.optJSONObject("proxies") ?: return out
|
||||||
|
val keys = proxies.keys()
|
||||||
|
while (keys.hasNext()) {
|
||||||
|
val k = keys.next()
|
||||||
|
val type = proxies.optJSONObject(k)?.optString("type") ?: continue
|
||||||
|
if (type == "URLTest" || type == "Fallback") out.add(k)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
/** /group/<name>/delay 的 {tag: ms} 响应(含嵌套 {tag:{delay:ms}}),只留正延迟。 */
|
||||||
|
private fun parseDelayMap(resp: org.json.JSONObject): List<Pair<String, Int>> {
|
||||||
|
val out = mutableListOf<Pair<String, Int>>()
|
||||||
|
val keys = resp.keys()
|
||||||
|
while (keys.hasNext()) {
|
||||||
|
val tag = keys.next()
|
||||||
|
val d = when (val v = resp.opt(tag)) {
|
||||||
|
is Number -> v.toInt()
|
||||||
|
is org.json.JSONObject -> v.optInt("delay", 0)
|
||||||
|
else -> 0
|
||||||
|
}
|
||||||
|
if (d > 0) out.add(tag to d)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 在配置 JSON 注入 experimental.clash_api + cache_file(已有则不覆盖)。 */
|
||||||
|
private fun injectClashApi(configJson: String, port: Int, secret: String): String {
|
||||||
|
return try {
|
||||||
|
val root = org.json.JSONObject(configJson)
|
||||||
|
val exp = root.optJSONObject("experimental") ?: org.json.JSONObject()
|
||||||
|
if (!exp.has("clash_api")) {
|
||||||
|
exp.put("clash_api", org.json.JSONObject()
|
||||||
|
.put("external_controller", "127.0.0.1:$port")
|
||||||
|
.put("secret", secret))
|
||||||
|
}
|
||||||
|
if (!exp.has("cache_file")) {
|
||||||
|
exp.put("cache_file", org.json.JSONObject().put("enabled", true))
|
||||||
|
}
|
||||||
|
root.put("experimental", exp)
|
||||||
|
root.toString()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "injectClashApi failed: $e")
|
||||||
|
configJson
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** 由 MainActivity(同进程)调用:切换出口节点到 [tag](在缓存的可选组内)。 */
|
/** 由 MainActivity(同进程)调用:切换出口节点到 [tag](在缓存的可选组内)。 */
|
||||||
fun selectOutbound(tag: String) {
|
fun selectOutbound(tag: String) {
|
||||||
val group = selectableGroup
|
val group = selectableGroup
|
||||||
|
|||||||
Reference in New Issue
Block a user