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
|
||||
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
|
||||
|
||||
+114
-11
@@ -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 = ""
|
||||
|
||||
/** 缓存的「可手动选择的出口组」tag(selector 组)。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
|
||||
|
||||
Reference in New Issue
Block a user