Skip to content

Commit 2fedcc0

Browse files
committed
feat: support mihomo YAML import
1 parent 9b6d124 commit 2fedcc0

19 files changed

Lines changed: 1081 additions & 157 deletions

app/build.gradle.kts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,7 @@ dependencies {
116116
implementation(libs.miuix.navigation3.ui)
117117
implementation(libs.miuix.preference)
118118
implementation(libs.reorderable)
119+
implementation(libs.snakeyaml)
119120
implementation(libs.zxing.android.embedded)
120121
ksp(libs.androidx.room.compiler)
121122
}

app/src/main/assets/aboutlibraries.json

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -247,6 +247,19 @@
247247
"Apache-2.0"
248248
]
249249
},
250+
{
251+
"uniqueId": "org.yaml:snakeyaml",
252+
"artifactVersion": "2.6",
253+
"name": "SnakeYAML",
254+
"description": "YAML 1.1 parser and emitter for Java",
255+
"website": "https://bitbucket.org/snakeyaml/snakeyaml",
256+
"scm": {
257+
"url": "https://bitbucket.org/snakeyaml/snakeyaml/src"
258+
},
259+
"licenses": [
260+
"Apache-2.0"
261+
]
262+
},
250263
{
251264
"uniqueId": "github:XTLS/Xray-core",
252265
"artifactVersion": "v26.5.9",
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
package features.proxy.server.usecase
2+
3+
import features.proxy.server.model.ProxyServer
4+
5+
internal data class ProxyServerImportResult(
6+
val urlCount: Int,
7+
val servers: List<ProxyServer<*>>,
8+
)
9+
10+
internal enum class ProxyServerImportSource(
11+
val logName: String,
12+
val decodeBase64: Boolean,
13+
) {
14+
Clipboard(logName = "clipboard", decodeBase64 = false),
15+
File(logName = "file", decodeBase64 = true),
16+
QrCode(logName = "qr_code", decodeBase64 = false),
17+
SubscriptionUrl(logName = "subscription_url", decodeBase64 = true),
18+
}
19+
20+
internal typealias ProxyServerPayloadParser = (String, ProxyServerImportSource) -> ProxyServerImportResult
21+
22+
internal val EmptyProxyServerImportResult = ProxyServerImportResult(
23+
urlCount = 0,
24+
servers = emptyList(),
25+
)
Lines changed: 18 additions & 152 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,9 @@
11
package features.proxy.server.usecase
22

3-
import features.logs.AndroidAppLogger
4-
import features.proxy.server.model.ProxyServer
5-
import features.proxy.server.model.ProxyServerConstants
6-
import kotlin.io.encoding.Base64
7-
8-
internal data class ProxyServerImportResult(
9-
val urlCount: Int,
10-
val servers: List<ProxyServer<*>>,
11-
)
12-
13-
internal enum class ProxyServerImportSource(
14-
val logName: String,
15-
val decodeBase64: Boolean,
16-
) {
17-
Clipboard(logName = "clipboard", decodeBase64 = false),
18-
File(logName = "file", decodeBase64 = true),
19-
QrCode(logName = "qr_code", decodeBase64 = false),
20-
SubscriptionUrl(logName = "subscription_url", decodeBase64 = true),
21-
}
3+
import features.proxy.server.usecase.importer.importPayloads
4+
import features.proxy.server.usecase.importer.parseProxyServersFromJsonConfig
5+
import features.proxy.server.usecase.importer.parseProxyServersFromMihomoYamlConfig
6+
import features.proxy.server.usecase.importer.parseProxyServersFromUrls
227

238
internal fun importProxyServersFromText(
249
text: String,
@@ -34,142 +19,23 @@ private fun parseProxyServersFromPayloads(
3419
payloads: List<String>,
3520
source: ProxyServerImportSource,
3621
): ProxyServerImportResult {
37-
var lastAttempt = ProxyServerImportResult(urlCount = 0, servers = emptyList())
38-
payloads.forEach { payload ->
39-
val jsonResult = parseProxyServersFromJsonConfig(
40-
text = payload,
41-
source = source,
42-
)
43-
if (jsonResult.servers.isNotEmpty()) {
44-
return jsonResult
45-
}
46-
if (jsonResult.urlCount > 0) {
47-
lastAttempt = jsonResult
48-
}
49-
50-
val lineResult = parseProxyServersFromLines(
51-
lines = payload.lineSequence(),
52-
source = source,
53-
)
54-
if (lineResult.servers.isNotEmpty()) {
55-
return lineResult
56-
}
57-
if (lineResult.urlCount > 0 || lastAttempt.urlCount == 0) {
58-
lastAttempt = lineResult
22+
var lastAttempt = EmptyProxyServerImportResult
23+
for (payload in payloads) {
24+
for (parser in ProxyServerImportParsers) {
25+
val result = parser(payload, source)
26+
if (result.servers.isNotEmpty()) {
27+
return result
28+
}
29+
if (result.urlCount > 0 || lastAttempt.urlCount == 0) {
30+
lastAttempt = result
31+
}
5932
}
6033
}
6134
return lastAttempt
6235
}
6336

64-
private fun parseProxyServersFromLines(
65-
lines: Sequence<String>,
66-
source: ProxyServerImportSource,
67-
): ProxyServerImportResult {
68-
val urls = lines.proxyServerUrlCandidates(distinct = true)
69-
val servers = urls.mapIndexedNotNull { index, url ->
70-
parseProxyServerUrlOrNull(
71-
url = url,
72-
index = index,
73-
source = source,
74-
)
75-
}
76-
return ProxyServerImportResult(
77-
urlCount = urls.size,
78-
servers = servers,
79-
)
80-
}
81-
82-
private fun parseProxyServerUrlOrNull(
83-
url: String,
84-
index: Int,
85-
source: ProxyServerImportSource,
86-
): ProxyServer<*>? {
87-
return runCatching { ProxyServer.parse(url) }
88-
.onFailure { error ->
89-
AndroidAppLogger.warn(
90-
ProxyServerImportLogTag,
91-
url.importFailureMessage(index = index, source = source),
92-
error,
93-
)
94-
}
95-
.getOrNull()
96-
}
97-
98-
private fun String.importFailureMessage(
99-
index: Int,
100-
source: ProxyServerImportSource,
101-
): String {
102-
val protocol = substringBefore("://", missingDelimiterValue = "").ifBlank { "<blank>" }
103-
return "Failed to import proxy server URL source=${source.logName} index=$index protocol=$protocol length=$length"
104-
}
105-
106-
private fun String.importPayloads(source: ProxyServerImportSource): List<String> {
107-
return if (source.decodeBase64) {
108-
listOfNotNull(decodeImportBase64(), this).distinct()
109-
} else {
110-
listOf(this)
111-
}
112-
}
113-
114-
private fun String.decodeImportBase64(): String? {
115-
val normalized = trimStart(ImportByteOrderMark).filterNot(Char::isWhitespace)
116-
if (normalized.isBlank()) return null
117-
return ImportBase64Decoders.firstNotNullOfOrNull { decoder ->
118-
runCatching { decoder.decode(normalized).decodeToString() }.getOrNull()
119-
} ?: normalized.trimEnd('=').takeIf { it.length != normalized.length }?.let { trimmed ->
120-
ImportBase64Decoders.firstNotNullOfOrNull { decoder ->
121-
runCatching { decoder.decode(trimmed).decodeToString() }.getOrNull()
122-
}
123-
}
124-
}
125-
126-
private fun Sequence<String>.proxyServerUrlCandidates(distinct: Boolean): List<String> {
127-
val urls = flatMap { line -> line.proxyServerUrlCandidates() }
128-
.let { sequence -> if (distinct) sequence.distinct() else sequence }
129-
return urls.toList()
130-
}
131-
132-
private fun String.proxyServerUrlCandidates(): Sequence<String> {
133-
val line = trim().trimStart(ImportByteOrderMark)
134-
if (line.isBlank()) return emptySequence()
135-
val embeddedUrls = ProxyServerUrlRegex.findAll(line)
136-
.map { match -> match.value.trimEnd(',', ';') }
137-
.filterNot { url -> url == line }
138-
.toList()
139-
return if (line.startsWithProxyServerScheme()) {
140-
(listOf(line) + embeddedUrls).asSequence()
141-
} else {
142-
embeddedUrls.asSequence()
143-
}
144-
}
145-
146-
private fun String.startsWithProxyServerScheme(): Boolean {
147-
val lower = lowercase()
148-
return ProxyServerUrlPrefixes.any { prefix -> lower.startsWith(prefix) }
149-
}
150-
151-
private val ImportBase64Decoders = listOf(
152-
Base64.Default,
153-
Base64.Default.withPadding(Base64.PaddingOption.ABSENT_OPTIONAL),
154-
Base64.UrlSafe,
155-
Base64.UrlSafe.withPadding(Base64.PaddingOption.ABSENT_OPTIONAL),
37+
private val ProxyServerImportParsers: List<ProxyServerPayloadParser> = listOf(
38+
::parseProxyServersFromJsonConfig,
39+
::parseProxyServersFromMihomoYamlConfig,
40+
::parseProxyServersFromUrls,
15641
)
157-
158-
private val ProxyServerUrlPrefixes = listOf(
159-
"${ProxyServerConstants.PROTOCOL_HTTP}://",
160-
"${ProxyServerConstants.PROTOCOL_SOCKS}://",
161-
"${ProxyServerConstants.PROTOCOL_SS}://",
162-
"${ProxyServerConstants.PROTOCOL_VMESS}://",
163-
"${ProxyServerConstants.PROTOCOL_VLESS}://",
164-
"${ProxyServerConstants.PROTOCOL_TROJAN}://",
165-
"${ProxyServerConstants.PROTOCOL_HY2}://",
166-
"${ProxyServerConstants.PROTOCOL_HYSTERIA2}://",
167-
"${ProxyServerConstants.PROTOCOL_WIREGUARD}://",
168-
)
169-
170-
private val ProxyServerUrlRegex = Regex(
171-
"(?i)\\b(?:http|socks|ss|vmess|vless|trojan|hy2|hysteria2|wireguard)://[^\\s<>\"']+",
172-
)
173-
174-
private const val ProxyServerImportLogTag = "ProxyServerImport"
175-
private const val ImportByteOrderMark = '\uFEFF'
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
package features.proxy.server.usecase.importer
2+
3+
import features.proxy.server.usecase.ProxyServerImportSource
4+
import kotlin.io.encoding.Base64
5+
6+
internal fun String.importPayloads(source: ProxyServerImportSource): List<String> {
7+
return if (source.decodeBase64) {
8+
listOfNotNull(decodeImportBase64(), this).distinct()
9+
} else {
10+
listOf(this)
11+
}
12+
}
13+
14+
private fun String.decodeImportBase64(): String? {
15+
val normalized = trimStart(ImportByteOrderMark).filterNot(Char::isWhitespace)
16+
if (normalized.isBlank()) return null
17+
return ImportBase64Decoders.firstNotNullOfOrNull { decoder ->
18+
runCatching { decoder.decode(normalized).decodeToString() }.getOrNull()
19+
} ?: normalized.trimEnd('=').takeIf { it.length != normalized.length }?.let { trimmed ->
20+
ImportBase64Decoders.firstNotNullOfOrNull { decoder ->
21+
runCatching { decoder.decode(trimmed).decodeToString() }.getOrNull()
22+
}
23+
}
24+
}
25+
26+
private val ImportBase64Decoders = listOf(
27+
Base64.Default,
28+
Base64.Default.withPadding(Base64.PaddingOption.ABSENT_OPTIONAL),
29+
Base64.UrlSafe,
30+
Base64.UrlSafe.withPadding(Base64.PaddingOption.ABSENT_OPTIONAL),
31+
)
32+
33+
internal const val ImportByteOrderMark = '\uFEFF'

app/src/main/kotlin/features/proxy/server/usecase/ProxyServerJsonImport.kt renamed to app/src/main/kotlin/features/proxy/server/usecase/importer/ProxyServerJsonImport.kt

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,33 @@
1-
package features.proxy.server.usecase
1+
package features.proxy.server.usecase.importer
22

33
import features.logs.AndroidAppLogger
44
import features.proxy.server.model.Custom
55
import features.proxy.server.model.ProxyServer
66
import features.proxy.server.model.formatCustomXrayConfigJson
7+
import features.proxy.server.usecase.EmptyProxyServerImportResult
8+
import features.proxy.server.usecase.ProxyServerImportResult
9+
import features.proxy.server.usecase.ProxyServerImportSource
710
import kotlinx.serialization.json.JsonArray
811
import kotlinx.serialization.json.JsonObject
912
import kotlinx.serialization.json.JsonPrimitive
1013
import kotlinx.serialization.json.contentOrNull
1114

12-
private const val JsonByteOrderMark = '\uFEFF'
1315
private const val LogTag = "ProxyServerJsonImport"
1416

1517
internal fun parseProxyServersFromJsonConfig(
1618
text: String,
1719
source: ProxyServerImportSource,
1820
): ProxyServerImportResult {
1921
val root = runCatching {
20-
ProxyServer.json.parseToJsonElement(text.trimStart(JsonByteOrderMark))
21-
}.getOrNull() ?: return ProxyServerImportResult(urlCount = 0, servers = emptyList())
22+
ProxyServer.json.parseToJsonElement(text.trimStart(ImportByteOrderMark))
23+
}.getOrNull() ?: return EmptyProxyServerImportResult
2224
val configs = when (root) {
2325
is JsonObject -> listOf(root)
2426
is JsonArray -> root.mapNotNull { element -> element as? JsonObject }
2527
else -> emptyList()
2628
}
2729
if (configs.isEmpty()) {
28-
return ProxyServerImportResult(urlCount = 0, servers = emptyList())
30+
return EmptyProxyServerImportResult
2931
}
3032

3133
var failedCount = 0
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
package features.proxy.server.usecase.importer
2+
3+
import features.proxy.server.model.HTTP
4+
5+
internal fun MihomoYamlMap.toMihomoHttpProxyServer(): HTTP {
6+
ensureNoMihomoTlsOptions("TLS for HTTP proxy nodes is not supported")
7+
if (map("headers")?.isNotEmpty() == true) {
8+
unsupported("HTTP proxy custom headers are not supported")
9+
}
10+
return HTTP(
11+
remarks = requiredString("name"),
12+
server = requiredString("server"),
13+
port = requiredString("port"),
14+
user = string("username", "user"),
15+
password = string("password", "pass"),
16+
)
17+
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
package features.proxy.server.usecase.importer
2+
3+
import features.proxy.server.model.Hysteria2
4+
5+
internal fun MihomoYamlMap.toMihomoHysteria2ProxyServer(): Hysteria2 {
6+
val realmOpts = map("realm-opts")
7+
if (realmOpts?.boolean("enable") == true) {
8+
unsupported("Hysteria2 realm-opts are not supported")
9+
}
10+
val multiPorts = string("ports").orEmpty()
11+
val hopInterval = string("hop-interval").orEmpty()
12+
if ('-' in hopInterval) {
13+
unsupported("Hysteria2 ranged hop-interval is not supported")
14+
}
15+
val port = string("port") ?: multiPorts.firstPortInRange()
16+
return Hysteria2(
17+
remarks = requiredString("name"),
18+
server = requiredString("server"),
19+
port = port,
20+
auth = requiredString("password", "auth", "auth-str"),
21+
obfs = string("obfs").orEmpty(),
22+
obfsPassword = string("obfs-password", "obfsPassword").orEmpty(),
23+
sni = string("sni", "servername").orEmpty(),
24+
insecure = if (boolean("skip-cert-verify") == true) 1 else 0,
25+
pinSHA256 = string("pinSHA256", "pin-sha256", "fingerprint").orEmpty(),
26+
mport = multiPorts,
27+
mportHopInt = hopInterval,
28+
up = string("up").orEmpty(),
29+
down = string("down").orEmpty(),
30+
security = if (hasTlsFields() || boolean("tls") == true) "tls" else "none",
31+
)
32+
}
33+
34+
private fun String.firstPortInRange(): String {
35+
return split(',').firstOrNull()
36+
?.substringBefore('-')
37+
?.trim()
38+
?.takeIf(String::isNotBlank)
39+
?: unsupported("Hysteria2 port or ports is required")
40+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
package features.proxy.server.usecase.importer
2+
3+
import features.proxy.server.model.Shadowsocks
4+
5+
internal fun MihomoYamlMap.toMihomoShadowsocksProxyServer(): Shadowsocks {
6+
if (!string("plugin").isNullOrBlank() || map("plugin-opts")?.isNotEmpty() == true) {
7+
unsupported("Shadowsocks plugin options are not supported")
8+
}
9+
return Shadowsocks(
10+
remarks = requiredString("name"),
11+
server = requiredString("server"),
12+
port = requiredString("port"),
13+
method = requiredString("cipher", "method"),
14+
password = requiredString("password"),
15+
)
16+
}

0 commit comments

Comments
 (0)