Skip to content

Commit 3f1b7b0

Browse files
committed
feat: support mihomo/clash.meta/flclashx install-config intent
1 parent 2fedcc0 commit 3f1b7b0

12 files changed

Lines changed: 192 additions & 57 deletions

app/build.gradle.kts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,7 @@ dependencies {
116116
implementation(libs.miuix.navigation3.ui)
117117
implementation(libs.miuix.preference)
118118
implementation(libs.reorderable)
119-
implementation(libs.snakeyaml)
119+
implementation(libs.snakeyaml.engine)
120120
implementation(libs.zxing.android.embedded)
121121
ksp(libs.androidx.room.compiler)
122122
}

app/src/main/AndroidManifest.xml

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,26 @@
4242
android:host="install-config"
4343
android:scheme="v2rayng" />
4444
</intent-filter>
45+
<intent-filter>
46+
<action android:name="android.intent.action.VIEW" />
47+
48+
<category android:name="android.intent.category.DEFAULT" />
49+
<category android:name="android.intent.category.BROWSABLE" />
50+
51+
<data
52+
android:host="install-config"
53+
android:scheme="clashmeta" />
54+
</intent-filter>
55+
<intent-filter>
56+
<action android:name="android.intent.action.VIEW" />
57+
58+
<category android:name="android.intent.category.DEFAULT" />
59+
<category android:name="android.intent.category.BROWSABLE" />
60+
61+
<data
62+
android:host="install-config"
63+
android:scheme="flclashx" />
64+
</intent-filter>
4565
</activity>
4666

4767
<activity-alias

app/src/main/assets/aboutlibraries.json

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -248,13 +248,13 @@
248248
]
249249
},
250250
{
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",
251+
"uniqueId": "org.snakeyaml:snakeyaml-engine",
252+
"artifactVersion": "3.0.1",
253+
"name": "SnakeYAML Engine",
254+
"description": "Core YAML 1.2 parser and emitter for Java",
255+
"website": "https://bitbucket.org/snakeyaml/snakeyaml-engine",
256256
"scm": {
257-
"url": "https://bitbucket.org/snakeyaml/snakeyaml/src"
257+
"url": "https://bitbucket.org/snakeyaml/snakeyaml-engine/src"
258258
},
259259
"licenses": [
260260
"Apache-2.0"

app/src/main/kotlin/app/MainActivity.kt

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,8 @@ import features.proxy.server.qr.AndroidQrCodeScanRequester
2020
import features.resources.runtime.AndroidResourceFilePicker
2121
import features.subscription.SubscriptionFetchUseCase
2222
import features.subscription.SubscriptionInstallConfigUseCase
23-
import features.subscription.isV2rayNgInstallConfigUri
24-
import features.subscription.toV2rayNgInstallConfigOrNull
23+
import features.subscription.isSubscriptionInstallConfigUri
24+
import features.subscription.toSubscriptionInstallConfigOrNull
2525
import features.subscription.usecase.subscriptionUpdateMessage
2626
import kotlinx.coroutines.launch
2727
import ui.feedback.AndroidToastTipNotifier
@@ -163,8 +163,8 @@ class MainActivity : ComponentActivity() {
163163

164164
private fun handleExternalIntent(intent: Intent?) {
165165
val data = intent?.data ?: return
166-
if (!data.isV2rayNgInstallConfigUri()) return
167-
val config = intent.toV2rayNgInstallConfigOrNull()
166+
if (!data.isSubscriptionInstallConfigUri()) return
167+
val config = intent.toSubscriptionInstallConfigOrNull()
168168
if (config == null) {
169169
(application as AsteriskApplication).appScope.launch {
170170
tipNotifier.show(getString(R.string.subscription_install_config_invalid))

app/src/main/kotlin/features/proxy/server/list/ProxyServerListTopBar.kt

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,9 +29,11 @@ import features.proxy.server.usecase.withImportedProxyServers
2929
import features.proxy.server.usecase.withUpdatedSubscriptionServers
3030
import features.subscription.DefaultSubscriptionGroupId
3131
import features.subscription.SubscriptionFetchUseCase
32+
import features.subscription.SubscriptionInstallConfigUseCase
3233
import features.subscription.usecase.subscriptionUpdateMessage
3334
import features.subscription.usecase.toSubscriptionFetchOptions
3435
import features.subscription.usecase.updateSubscriptions
36+
import features.subscription.toSubscriptionInstallConfigOrNull
3537
import kotlinx.coroutines.CoroutineScope
3638
import kotlinx.coroutines.launch
3739
import top.yukonga.miuix.kmp.basic.ScrollBehavior
@@ -77,10 +79,12 @@ internal fun ProxyServerListTopBar(
7779
action = action,
7880
groupState = groupState,
7981
proxyListState = proxyListState,
82+
stateStore = stateStore,
8083
updateAppState = updateAppState,
8184
navigator = navigator,
8285
qrScanner = qrScanner,
8386
proxyServerImportFileUseCase = proxyServerImportFileUseCase,
87+
subscriptionFetchUseCase = subscriptionFetchUseCase,
8488
clipboard = clipboard,
8589
tipNotifier = tipNotifier,
8690
scope = scope,
@@ -134,10 +138,12 @@ private fun handleProxyServerListAddAction(
134138
action: ProxyServerListAddAction,
135139
groupState: ProxyServerListGroups,
136140
proxyListState: ProxyServerListState,
141+
stateStore: AndroidAppStateStore,
137142
updateAppState: ((AppState) -> AppState) -> Unit,
138143
navigator: Navigator,
139144
qrScanner: ProxyServerQrScanUseCase,
140145
proxyServerImportFileUseCase: ProxyServerImportFileUseCase,
146+
subscriptionFetchUseCase: SubscriptionFetchUseCase,
141147
clipboard: Clipboard,
142148
tipNotifier: AndroidToastTipNotifier,
143149
scope: CoroutineScope,
@@ -150,6 +156,17 @@ private fun handleProxyServerListAddAction(
150156
runCatching { qrScanner.scan() }
151157
.onSuccess { scanText ->
152158
if (scanText.isNullOrBlank()) return@onSuccess
159+
if (
160+
installSubscriptionFromQrCode(
161+
text = scanText,
162+
stateStore = stateStore,
163+
subscriptionFetchUseCase = subscriptionFetchUseCase,
164+
tipNotifier = tipNotifier,
165+
messages = messages,
166+
)
167+
) {
168+
return@onSuccess
169+
}
153170
importProxyServers(
154171
text = scanText,
155172
source = ProxyServerImportSource.QrCode,
@@ -213,6 +230,33 @@ private fun handleProxyServerListAddAction(
213230
}
214231
}
215232

233+
private suspend fun installSubscriptionFromQrCode(
234+
text: String,
235+
stateStore: AndroidAppStateStore,
236+
subscriptionFetchUseCase: SubscriptionFetchUseCase,
237+
tipNotifier: AndroidToastTipNotifier,
238+
messages: ProxyServerListMessages,
239+
): Boolean {
240+
val config = text.toSubscriptionInstallConfigOrNull() ?: return false
241+
runCatching {
242+
SubscriptionInstallConfigUseCase(
243+
stateStore = stateStore,
244+
subscriptionFetchUseCase = subscriptionFetchUseCase,
245+
).install(config)
246+
}.onSuccess { result ->
247+
tipNotifier.show(
248+
subscriptionUpdateMessage(
249+
result = result,
250+
successTemplate = messages.subscriptionUpdateResultTemplate,
251+
failedTemplate = messages.subscriptionUpdateResultWithFailedTemplate,
252+
),
253+
)
254+
}.onFailure { error ->
255+
tipNotifier.showError(error)
256+
}
257+
return true
258+
}
259+
216260
private suspend fun importProxyServers(
217261
text: String,
218262
source: ProxyServerImportSource,

app/src/main/kotlin/features/proxy/server/usecase/importer/ProxyServerMihomoV2RayCommon.kt

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -78,10 +78,6 @@ private fun MihomoYamlMap.ensureNoUnsupportedV2RayTlsOptions() {
7878
unsupported("ECH query-server-name is not supported")
7979
}
8080
}
81-
val realityOpts = map("reality-opts")
82-
if (realityOpts?.boolean("support-x25519mlkem768") == true) {
83-
unsupported("Reality X25519-MLKEM768 is not supported")
84-
}
8581
}
8682

8783
private fun MihomoYamlMap.ensureNoUnsupportedWsHeaders() {

app/src/main/kotlin/features/proxy/server/usecase/importer/ProxyServerMihomoVlessImport.kt

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -202,9 +202,6 @@ private fun JsonObjectBuilder.putXrayRealitySettings(
202202
fallback: MihomoYamlMap,
203203
) {
204204
val realityOpts = primary.map("reality-opts") ?: fallback.map("reality-opts") ?: emptyMap()
205-
if (realityOpts.boolean("support-x25519mlkem768") == true) {
206-
unsupported("Reality X25519-MLKEM768 is not supported")
207-
}
208205
putStringIfNotBlank("serverName", primary.string("servername", "sni") ?: fallback.string("servername", "sni"))
209206
putStringIfNotBlank(
210207
"fingerprint",

app/src/main/kotlin/features/proxy/server/usecase/importer/ProxyServerMihomoYamlImport.kt

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,8 @@ import features.proxy.server.model.ProxyServer
55
import features.proxy.server.usecase.EmptyProxyServerImportResult
66
import features.proxy.server.usecase.ProxyServerImportResult
77
import features.proxy.server.usecase.ProxyServerImportSource
8-
import org.yaml.snakeyaml.LoaderOptions
9-
import org.yaml.snakeyaml.Yaml
10-
import org.yaml.snakeyaml.constructor.SafeConstructor
8+
import org.snakeyaml.engine.v2.api.Load
9+
import org.snakeyaml.engine.v2.api.LoadSettings
1110

1211
private const val LogTag = "ProxyServerMihomoYamlImport"
1312

@@ -16,7 +15,13 @@ internal fun parseProxyServersFromMihomoYamlConfig(
1615
source: ProxyServerImportSource,
1716
): ProxyServerImportResult {
1817
val root = runCatching {
19-
newMihomoYamlParser().load<Any?>(text.trimStart(ImportByteOrderMark))
18+
newMihomoYamlParser().loadFromString(text.trimStart(ImportByteOrderMark))
19+
}.onFailure { error ->
20+
AndroidAppLogger.warn(
21+
LogTag,
22+
"Failed to parse ${source.logName} as mihomo YAML",
23+
error,
24+
)
2025
}.getOrNull() ?: return EmptyProxyServerImportResult
2126
val configs = root.mihomoProxyConfigs()
2227
if (configs.isEmpty()) {
@@ -129,8 +134,8 @@ private fun skippedMessage(
129134
"type=${type.ifBlank { "<blank>" }} name=${name.ifBlank { "<blank>" }} reason=$reason"
130135
}
131136

132-
private fun newMihomoYamlParser(): Yaml {
133-
return Yaml(SafeConstructor(LoaderOptions()))
137+
private fun newMihomoYamlParser(): Load {
138+
return Load(LoadSettings.builder().build())
134139
}
135140

136141
private val SupportedMihomoProxyTypes = setOf(

app/src/main/kotlin/features/subscription/SubscriptionDefaults.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,5 +2,7 @@ package features.subscription
22

33
const val DefaultSubscriptionGroupId = 1
44
const val DefaultSubscriptionUserAgent = "v2rayNG/2.2.0"
5+
const val ClashMetaSubscriptionUserAgent = "clash.meta"
6+
const val FlClashXSubscriptionUserAgent = "FlClash X/v0.4.0-pre.12 Platform/android"
57
const val AutoSubscriptionCheckIntervalMillis = 60L * 1000L
68
const val AutoSubscriptionRetryDelayMillis = 15L * 60L * 1000L

app/src/main/kotlin/features/subscription/SubscriptionInstallConfigUseCase.kt

Lines changed: 44 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -11,16 +11,17 @@ import features.proxy.server.usecase.withUpdatedSubscriptionServers
1111
import features.subscription.usecase.toSubscriptionFetchOptions
1212
import features.subscription.usecase.updateSubscriptions
1313

14-
internal data class V2rayNgInstallConfig(
14+
internal data class SubscriptionInstallConfig(
1515
val name: String,
1616
val url: String,
17+
val userAgent: String,
1718
)
1819

1920
internal class SubscriptionInstallConfigUseCase(
2021
private val stateStore: AndroidAppStateStore,
2122
private val subscriptionFetchUseCase: SubscriptionFetchUseCase,
2223
) {
23-
suspend fun install(config: V2rayNgInstallConfig): ProxyServerListSubscriptionUpdateResult {
24+
suspend fun install(config: SubscriptionInstallConfig): ProxyServerListSubscriptionUpdateResult {
2425
val group = stateStore.addSubscriptionGroup(config)
2526
val result = updateSubscriptions(
2627
groups = listOf(group),
@@ -39,29 +40,37 @@ internal class SubscriptionInstallConfigUseCase(
3940
}
4041
}
4142

42-
internal fun Intent.toV2rayNgInstallConfigOrNull(): V2rayNgInstallConfig? {
43+
internal fun Intent.toSubscriptionInstallConfigOrNull(): SubscriptionInstallConfig? {
4344
if (action != Intent.ACTION_VIEW) return null
44-
return data?.toV2rayNgInstallConfigOrNull()
45+
return data?.toSubscriptionInstallConfigOrNull()
4546
}
4647

47-
internal fun Uri.isV2rayNgInstallConfigUri(): Boolean {
48-
return scheme.equals(V2rayNgScheme, ignoreCase = true) &&
48+
internal fun String.toSubscriptionInstallConfigOrNull(): SubscriptionInstallConfig? {
49+
val uri = runCatching { trim().toUri() }.getOrNull() ?: return null
50+
return uri.toSubscriptionInstallConfigOrNull()
51+
}
52+
53+
internal fun Uri.isSubscriptionInstallConfigUri(): Boolean {
54+
return installConfigSource() != null &&
4955
isHierarchical &&
50-
host.equals(V2rayNgInstallConfigHost, ignoreCase = true)
56+
host.equals(InstallConfigHost, ignoreCase = true)
5157
}
5258

53-
private fun Uri.toV2rayNgInstallConfigOrNull(): V2rayNgInstallConfig? {
54-
if (!isV2rayNgInstallConfigUri()) return null
59+
internal fun Uri.toSubscriptionInstallConfigOrNull(): SubscriptionInstallConfig? {
60+
val source = installConfigSource() ?: return null
61+
if (!isHierarchical || !host.equals(InstallConfigHost, ignoreCase = true)) return null
5562
val name = getQueryParameter("name")?.trim().orEmpty()
63+
.ifBlank { source.defaultName.orEmpty() }
5664
val url = getQueryParameter("url")?.trim().orEmpty()
5765
if (name.isBlank() || !url.isValidSubscriptionUrl()) return null
58-
return V2rayNgInstallConfig(
66+
return SubscriptionInstallConfig(
5967
name = name,
6068
url = url,
69+
userAgent = source.userAgent,
6170
)
6271
}
6372

64-
private fun AndroidAppStateStore.addSubscriptionGroup(config: V2rayNgInstallConfig): SubscriptionGroupState {
73+
private fun AndroidAppStateStore.addSubscriptionGroup(config: SubscriptionInstallConfig): SubscriptionGroupState {
6574
var savedGroup: SubscriptionGroupState? = null
6675
update { state ->
6776
val group = state.newSubscriptionGroup(config)
@@ -74,12 +83,12 @@ private fun AndroidAppStateStore.addSubscriptionGroup(config: V2rayNgInstallConf
7483
return checkNotNull(savedGroup)
7584
}
7685

77-
private fun AppState.newSubscriptionGroup(config: V2rayNgInstallConfig): SubscriptionGroupState {
86+
private fun AppState.newSubscriptionGroup(config: SubscriptionInstallConfig): SubscriptionGroupState {
7887
return SubscriptionGroupState(
7988
id = nextSubscriptionGroupId,
8089
name = config.name,
8190
url = config.url,
82-
userAgent = DefaultSubscriptionUserAgent,
91+
userAgent = config.userAgent,
8392
updateInterval = "",
8493
updateViaProxy = false,
8594
enabled = true,
@@ -94,6 +103,26 @@ private fun String.isValidSubscriptionUrl(): Boolean {
94103
uri.host?.isNotBlank() == true
95104
}
96105

97-
private const val V2rayNgScheme = "v2rayng"
98-
private const val V2rayNgInstallConfigHost = "install-config"
106+
private enum class InstallConfigSource(
107+
val scheme: String,
108+
val userAgent: String,
109+
val defaultName: String? = null,
110+
) {
111+
V2rayNg(scheme = "v2rayng", userAgent = DefaultSubscriptionUserAgent),
112+
ClashMeta(scheme = "clashmeta", userAgent = ClashMetaSubscriptionUserAgent),
113+
FlClashX(
114+
scheme = "flclashx",
115+
userAgent = FlClashXSubscriptionUserAgent,
116+
defaultName = "clashsub",
117+
),
118+
}
119+
120+
private fun Uri.installConfigSource(): InstallConfigSource? {
121+
val uriScheme = scheme ?: return null
122+
return InstallConfigSource.entries.firstOrNull { source ->
123+
source.scheme.equals(uriScheme, ignoreCase = true)
124+
}
125+
}
126+
127+
private const val InstallConfigHost = "install-config"
99128
private val SubscriptionUrlSchemes = setOf("http", "https")

0 commit comments

Comments
 (0)