diff --git a/client/android/app/src/main/AndroidManifest.xml b/client/android/app/src/main/AndroidManifest.xml index a52e295..42cc843 100644 --- a/client/android/app/src/main/AndroidManifest.xml +++ b/client/android/app/src/main/AndroidManifest.xml @@ -26,7 +26,8 @@ + android:icon="@mipmap/ic_launcher" + android:usesCleartextTraffic="true"> > = 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> { + val out = mutableListOf>() + 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 { + val out = mutableListOf() + 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//delay 的 {tag: ms} 响应(含嵌套 {tag:{delay:ms}}),只留正延迟。 */ + private fun parseDelayMap(resp: org.json.JSONObject): List> { + val out = mutableListOf>() + 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