Skip to content

Commit f654e61

Browse files
committed
feat: node manager screen + subscription decoder fixes
Subscription pulling: - Keep numeric/boolean scalars unquoted (port: 443) so Mihomo accepts decoded configs instead of rejecting the whole document. - Base64-decode whole-YAML subscriptions before deciding they are link lists; emit a select proxy-group so decoded node lists are switchable. - Emit nested transport keys (ws-opts.headers.Host) and follow http<->https redirects when fetching. Node manager (ClashMetaForAndroid-style): - Expose proxies/select/delay from the in-process mihomo bridge over JNI (MihomoProxies / MihomoSelectProxy / MihomoProxyDelay). - New ProxiesActivity lists the running core's selector groups and nodes, with tap-to-select and latency testing; reachable from the drawer.
1 parent db958e7 commit f654e61

15 files changed

Lines changed: 740 additions & 36 deletions

app/src/main/AndroidManifest.xml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,11 @@
5555
android:exported="false"
5656
android:parentActivityName=".ui.MainActivity" />
5757

58+
<activity
59+
android:name=".ui.ProxiesActivity"
60+
android:exported="false"
61+
android:parentActivityName=".ui.MainActivity" />
62+
5863
<activity
5964
android:name=".ui.AppListActivity"
6065
android:exported="false"

app/src/main/cpp/mihomo_jni.cpp

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@ void MihomoStop();
77
char* MihomoLastError();
88
char* MihomoVersion();
99
char* MihomoTraffic();
10+
char* MihomoProxies();
11+
int MihomoSelectProxy(char* group, char* name);
12+
char* MihomoProxyDelay(char* name, char* url, int timeout_ms);
1013
}
1114

1215
extern "C" JNIEXPORT jint JNICALL
@@ -47,3 +50,37 @@ Java_top_uwu_mikubox_core_MihomoCore_nativeTraffic(JNIEnv* env, jobject /* thiz
4750
std::free(traffic);
4851
return result;
4952
}
53+
54+
extern "C" JNIEXPORT jstring JNICALL
55+
Java_top_uwu_mikubox_core_MihomoCore_nativeProxies(JNIEnv* env, jobject /* thiz */) {
56+
char* proxies = MihomoProxies();
57+
jstring result = env->NewStringUTF(proxies == nullptr ? "{}" : proxies);
58+
std::free(proxies);
59+
return result;
60+
}
61+
62+
extern "C" JNIEXPORT jint JNICALL
63+
Java_top_uwu_mikubox_core_MihomoCore_nativeSelectProxy(
64+
JNIEnv* env, jobject /* thiz */, jstring group, jstring name) {
65+
const char* group_chars = env->GetStringUTFChars(group, nullptr);
66+
const char* name_chars = env->GetStringUTFChars(name, nullptr);
67+
const int result = MihomoSelectProxy(
68+
const_cast<char*>(group_chars), const_cast<char*>(name_chars));
69+
env->ReleaseStringUTFChars(group, group_chars);
70+
env->ReleaseStringUTFChars(name, name_chars);
71+
return result;
72+
}
73+
74+
extern "C" JNIEXPORT jstring JNICALL
75+
Java_top_uwu_mikubox_core_MihomoCore_nativeProxyDelay(
76+
JNIEnv* env, jobject /* thiz */, jstring name, jstring url, jint timeout_ms) {
77+
const char* name_chars = env->GetStringUTFChars(name, nullptr);
78+
const char* url_chars = env->GetStringUTFChars(url, nullptr);
79+
char* delay = MihomoProxyDelay(
80+
const_cast<char*>(name_chars), const_cast<char*>(url_chars), timeout_ms);
81+
env->ReleaseStringUTFChars(name, name_chars);
82+
env->ReleaseStringUTFChars(url, url_chars);
83+
jstring result = env->NewStringUTF(delay == nullptr ? "{\"error\":\"null\"}" : delay);
84+
std::free(delay);
85+
return result;
86+
}

app/src/main/java/top/uwu/mikubox/core/MihomoCore.kt

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package top.uwu.mikubox.core
22

33
import android.content.Context
4+
import org.json.JSONArray
45
import org.json.JSONObject
56
import top.uwu.mikubox.R
67
import java.io.File
@@ -17,6 +18,22 @@ object MihomoCore {
1718
val downloadTotal: Long,
1819
)
1920

21+
/**
22+
* A proxy or proxy-group exposed by the running core. Groups populate [all]
23+
* (their members) and [now] (the selected member); plain nodes leave them empty.
24+
*/
25+
data class Proxy(
26+
val name: String,
27+
val type: String,
28+
val now: String?,
29+
val all: List<String>,
30+
val delay: Int,
31+
val udp: Boolean,
32+
) {
33+
val isGroup: Boolean get() = all.isNotEmpty()
34+
val isSelector: Boolean get() = type.equals("Selector", ignoreCase = true)
35+
}
36+
2037
init {
2138
System.loadLibrary("mihomo")
2239
System.loadLibrary("mikubox_core")
@@ -44,9 +61,50 @@ object MihomoCore {
4461
)
4562
}
4663

64+
/** Live proxies/groups from the running core, keyed by name. Empty when stopped. */
65+
fun proxies(): Map<String, Proxy> = runCatching {
66+
val root = JSONObject(nativeProxies()).optJSONObject("proxies") ?: return emptyMap()
67+
buildMap {
68+
root.keys().forEach { key ->
69+
val obj = root.getJSONObject(key)
70+
val all = obj.optJSONArray("all").toStringList()
71+
put(
72+
key,
73+
Proxy(
74+
name = obj.optString("name", key),
75+
type = obj.optString("type"),
76+
now = obj.optString("now").ifBlank { null },
77+
all = all,
78+
delay = obj.optJSONArray("history").lastDelay(),
79+
udp = obj.optBoolean("udp"),
80+
),
81+
)
82+
}
83+
}
84+
}.getOrDefault(emptyMap())
85+
86+
/** Points a selector [group] at one of its members. Returns true on success. */
87+
fun selectProxy(group: String, name: String): Boolean = nativeSelectProxy(group, name) == 0
88+
89+
/** URL-tests a proxy, returning its delay in ms, or -1 on failure/timeout. */
90+
fun delay(name: String, url: String = "https://cp.cloudflare.com", timeoutMs: Int = 5000): Int =
91+
runCatching { JSONObject(nativeProxyDelay(name, url, timeoutMs)).optInt("delay", -1) }
92+
.getOrDefault(-1)
93+
94+
private fun JSONArray?.toStringList(): List<String> =
95+
if (this == null) emptyList() else List(length()) { optString(it) }
96+
97+
private fun JSONArray?.lastDelay(): Int {
98+
if (this == null || length() == 0) return 0
99+
return optJSONObject(length() - 1)?.optInt("delay", 0) ?: 0
100+
}
101+
47102
private external fun nativeStart(config: String, home: String, tunFd: Int): Int
48103
private external fun nativeStop()
49104
private external fun nativeLastError(): String
50105
private external fun nativeVersion(): String
51106
private external fun nativeTraffic(): String
107+
private external fun nativeProxies(): String
108+
private external fun nativeSelectProxy(group: String, name: String): Int
109+
private external fun nativeProxyDelay(name: String, url: String, timeoutMs: Int): String
52110
}

app/src/main/java/top/uwu/mikubox/profile/MihomoSubscriptionDecoder.kt

Lines changed: 64 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -11,13 +11,17 @@ import java.nio.charset.StandardCharsets
1111
/** Converts the common non-UI subscription formats used by UwU into Mihomo YAML. */
1212
object MihomoSubscriptionDecoder {
1313

14+
private const val PROXY_GROUP = "PROXY"
15+
1416
fun toMihomoConfig(context: Context, source: String): String {
1517
val text = source.trim().removePrefix("\uFEFF")
16-
if (text.contains("proxies:") || text.contains("proxy-providers:") || text.contains("proxy-groups:")) {
17-
return text
18-
}
18+
if (isMihomoConfig(text)) return text
1919

20-
val links = decodeBase64Subscription(text)
20+
// Some providers Base64-encode the whole Mihomo/Clash YAML, not a link list.
21+
val decoded = decodeBase64Subscription(text)
22+
if (isMihomoConfig(decoded)) return decoded
23+
24+
val links = decoded
2125
.lineSequence()
2226
.map(String::trim)
2327
.filter(String::isNotEmpty)
@@ -31,11 +35,19 @@ object MihomoSubscriptionDecoder {
3135
appendLine("mode: rule")
3236
appendLine("proxies:")
3337
proxies.forEach { append(it) }
38+
appendLine("proxy-groups:")
39+
appendLine(" - name: $PROXY_GROUP")
40+
appendLine(" type: select")
41+
appendLine(" proxies:")
42+
proxies.forEach { appendLine(" - ${it.name.yaml()}") }
3443
appendLine("rules:")
35-
appendLine(" - MATCH,${proxies.first().name.yaml()}")
44+
appendLine(" - MATCH,$PROXY_GROUP")
3645
}
3746
}
3847

48+
private fun isMihomoConfig(text: String): Boolean =
49+
text.contains("proxies:") || text.contains("proxy-providers:") || text.contains("proxy-groups:")
50+
3951
private fun decodeBase64Subscription(text: String): String = runCatching {
4052
val normalized = text.replace("\\s".toRegex(), "")
4153
Base64.decode(normalized, Base64.DEFAULT).toString(StandardCharsets.UTF_8)
@@ -98,9 +110,18 @@ object MihomoSubscriptionDecoder {
98110
)
99111
val network = objectJson.optString("net")
100112
if (network.isNotBlank()) fields += "network" to network
101-
objectJson.optString("host").takeIf { it.isNotBlank() }?.let { fields += "servername" to it }
102-
objectJson.optString("path").takeIf { it.isNotBlank() }?.let { fields += "ws-opts.path" to it }
103-
if (objectJson.optString("tls").equals("tls", true)) fields += "tls" to "true"
113+
val wsHost = objectJson.optString("host").takeIf { it.isNotBlank() }
114+
val path = objectJson.optString("path").takeIf { it.isNotBlank() }
115+
if (network.equals("ws", true)) {
116+
path?.let { fields += "ws-opts.path" to it }
117+
wsHost?.let { fields += "ws-opts.headers.Host" to it }
118+
}
119+
if (objectJson.optString("tls").equals("tls", true)) {
120+
fields += "tls" to "true"
121+
// SNI defaults to the disguise host when present.
122+
objectJson.optString("sni").takeIf { it.isNotBlank() }?.let { fields += "servername" to it }
123+
?: wsHost?.let { fields += "servername" to it }
124+
}
104125
return ProxyYaml(name(objectJson.optString("ps"), host), "vmess", fields)
105126
}
106127

@@ -188,19 +209,44 @@ object MihomoSubscriptionDecoder {
188209
fragment?.let { URLDecoder.decode(it, StandardCharsets.UTF_8.name()) }?.ifBlank { fallback } ?: fallback
189210

190211
private data class ProxyYaml(val name: String, val type: String, val fields: List<Pair<String, String>>) {
191-
override fun toString(): String = buildString {
192-
appendLine(" - name: ${name.yaml()}")
193-
appendLine(" type: $type")
194-
fields.forEach { (key, value) ->
195-
if (key.contains('.')) {
196-
// Nested transport properties are intentionally skipped here;
197-
// full YAML imports retain every advanced transport option.
198-
return@forEach
199-
}
200-
appendLine(" $key: ${value.yaml()}")
212+
override fun toString(): String {
213+
// Dotted keys (e.g. "ws-opts.path", "ws-opts.headers.Host") become nested YAML.
214+
val root = LinkedHashMap<String, Any>()
215+
insert(root, listOf("type"), type)
216+
fields.forEach { (key, value) -> insert(root, key.split('.'), value) }
217+
return buildString {
218+
appendLine(" - name: ${name.yaml()}")
219+
emit(root, 2)
220+
}
221+
}
222+
}
223+
224+
@Suppress("UNCHECKED_CAST")
225+
private fun insert(map: LinkedHashMap<String, Any>, path: List<String>, value: String) {
226+
if (path.size == 1) {
227+
map[path[0]] = value
228+
return
229+
}
230+
val child = map.getOrPut(path[0]) { LinkedHashMap<String, Any>() } as LinkedHashMap<String, Any>
231+
insert(child, path.drop(1), value)
232+
}
233+
234+
@Suppress("UNCHECKED_CAST")
235+
private fun StringBuilder.emit(map: Map<String, Any>, depth: Int) {
236+
val pad = " ".repeat(depth)
237+
map.forEach { (key, value) ->
238+
if (value is String) {
239+
appendLine("$pad$key: ${scalar(value)}")
240+
} else {
241+
appendLine("$pad$key:")
242+
emit(value as Map<String, Any>, depth + 1)
201243
}
202244
}
203245
}
204246

247+
/** Numbers and booleans must stay unquoted or Mihomo rejects the whole config. */
248+
private fun scalar(value: String): String =
249+
if (value == "true" || value == "false" || value.matches(Regex("-?\\d+"))) value else value.yaml()
250+
205251
private fun String.yaml(): String = "'${replace("'", "''")}'"
206252
}

app/src/main/java/top/uwu/mikubox/profile/MihomoSubscriptionUpdater.kt

Lines changed: 36 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -38,25 +38,44 @@ object MihomoSubscriptionUpdater {
3838

3939
fun update(context: Context, profile: MihomoProfileStore.Profile) {
4040
val url = requireNotNull(profile.subscriptionUrl)
41-
val connection = (URL(url).openConnection() as HttpURLConnection).apply {
42-
connectTimeout = 15_000
43-
readTimeout = 30_000
44-
instanceFollowRedirects = true
45-
setRequestProperty("User-Agent", subscriptionUserAgent(context))
46-
}
47-
try {
48-
check(connection.responseCode in 200..299) {
49-
context.getString(R.string.error_subscription_http, connection.responseCode)
41+
val body = fetch(context, url)
42+
val config = MihomoSubscriptionDecoder.toMihomoConfig(context, body)
43+
MihomoProfileStore.update(
44+
context,
45+
profile.copy(config = config, updatedAtMillis = System.currentTimeMillis()),
46+
)
47+
}
48+
49+
/**
50+
* HttpURLConnection does not follow redirects that switch between http and https,
51+
* which many subscription providers rely on. Follow them manually.
52+
*/
53+
private fun fetch(context: Context, initialUrl: String, maxRedirects: Int = 5): String {
54+
var target = URL(initialUrl)
55+
repeat(maxRedirects + 1) {
56+
val connection = (target.openConnection() as HttpURLConnection).apply {
57+
connectTimeout = 15_000
58+
readTimeout = 30_000
59+
instanceFollowRedirects = false
60+
setRequestProperty("User-Agent", subscriptionUserAgent(context))
61+
}
62+
try {
63+
val code = connection.responseCode
64+
if (code in 300..399) {
65+
val location = connection.getHeaderField("Location")
66+
?: error(context.getString(R.string.error_subscription_http, code))
67+
target = URL(target, location)
68+
return@repeat
69+
}
70+
check(code in 200..299) {
71+
context.getString(R.string.error_subscription_http, code)
72+
}
73+
return connection.inputStream.bufferedReader().use { it.readText() }
74+
} finally {
75+
connection.disconnect()
5076
}
51-
val body = connection.inputStream.bufferedReader().use { it.readText() }
52-
val config = MihomoSubscriptionDecoder.toMihomoConfig(context, body)
53-
MihomoProfileStore.update(
54-
context,
55-
profile.copy(config = config, updatedAtMillis = System.currentTimeMillis()),
56-
)
57-
} finally {
58-
connection.disconnect()
5977
}
78+
error(context.getString(R.string.error_subscription_http, 310))
6079
}
6180

6281
private fun subscriptionUserAgent(context: Context): String {

app/src/main/java/top/uwu/mikubox/ui/MainActivity.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,7 @@ class MainActivity : EdgeToEdgeActivity(), AddProfileBottomSheet.Listener {
213213
setOnMenuItemClickListener { item ->
214214
val target = when (item.itemId) {
215215
R.id.nav_settings -> SettingsActivity::class.java
216+
R.id.nav_proxies -> ProxiesActivity::class.java
216217
R.id.nav_apps -> AppListActivity::class.java
217218
R.id.nav_logcat -> LogcatActivity::class.java
218219
R.id.nav_tools -> ToolsActivity::class.java

0 commit comments

Comments
 (0)