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:
wangjia
2026-06-30 22:06:11 +08:00
parent 328d34a320
commit e994c44116
2 changed files with 116 additions and 12 deletions
@@ -26,7 +26,8 @@
<application
android:label="穿山甲"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
android:icon="@mipmap/ic_launcher"
android:usesCleartextTraffic="true"><!-- 控制面 API 当前为 http(联调),Android 9+ 默认禁明文,需开;生产改 https 后可去掉 -->
<!-- 主 Activity -->
<activity
@@ -98,6 +98,12 @@ class PangolinVpnService : VpnService(), PlatformInterface, CommandServerHandler
@Volatile private var activeOutbound: String = "auto"
@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 = ""
/** 缓存的「可手动选择的出口组」tagselector 组)。selectOutbound 据此切换。 */
@Volatile private var selectableGroup: String = ""
@@ -177,7 +183,13 @@ class PangolinVpnService : VpnService(), PlatformInterface, CommandServerHandler
server.start()
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")
startStatsClient()
@@ -220,23 +232,114 @@ class PangolinVpnService : VpnService(), PlatformInterface, CommandServerHandler
}
}
/** 周期主动触发 urltest 探测,让延迟稳定出现(补偿惰性探测)。 */
/** 周期经 clash API 取 urltest 延迟(与 iOS/macOS 同策略:读 /proxies history + /group/delay
* 刷新;非空才更新,单次失败/空不覆盖旧值,拥塞链路也稳定显示)。 */
private fun startUrlTestTimer() {
urlTestTimer?.cancel()
val timer = java.util.Timer("pangolin-urltest", true)
timer.scheduleAtFixedRate(object : java.util.TimerTask() {
override fun run() {
val client = statsClient ?: return
for (tag in groupTags) {
try { client.urlTest(tag) } catch (e: Exception) {
Log.w(TAG, "urlTest($tag) failed: $e")
}
}
}
}, 2_000L, 12_000L)
override fun run() { try { pollClash() } catch (e: Exception) { Log.w(TAG, "pollClash: $e") } }
}, 3_000L, 8_000L)
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](在缓存的可选组内)。 */
fun selectOutbound(tag: String) {
val group = selectableGroup