From 27a5dadf8688bc7e9d9d50f7696020cfad3e62c4 Mon Sep 17 00:00:00 2001 From: fangsy Date: Sat, 1 Feb 2025 22:05:49 +0800 Subject: [PATCH 1/9] =?UTF-8?q?=E6=94=AF=E6=8C=81CFW=E7=9A=84YAML=20parser?= =?UTF-8?q?s=E5=8A=9F=E8=83=BD(prepend-proxies,prepend-proxy-groups,prepen?= =?UTF-8?q?d-rules),=E5=9C=A8=E8=AE=A2=E9=98=85=E9=93=BE=E6=8E=A5=E5=A2=9E?= =?UTF-8?q?=E5=8A=A0parsers=3DYmlUrl(URLEncoded)=E5=8D=B3=E5=8F=AF?= =?UTF-8?q?=E9=80=82=E7=94=A8parsers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- core/src/main/golang/native/config/fetch.go | 117 +++++++++++++++++++- 1 file changed, 116 insertions(+), 1 deletion(-) diff --git a/core/src/main/golang/native/config/fetch.go b/core/src/main/golang/native/config/fetch.go index ba08ebccbd..1e1ac4dc30 100644 --- a/core/src/main/golang/native/config/fetch.go +++ b/core/src/main/golang/native/config/fetch.go @@ -4,6 +4,8 @@ import ( "context" "encoding/json" "fmt" + "github.com/metacubex/mihomo/log" + "gopkg.in/yaml.v3" "io" "net/http" U "net/url" @@ -34,6 +36,36 @@ func openUrl(ctx context.Context, url string) (io.ReadCloser, error) { return response.Body, nil } +func openUrlAsString(ctx context.Context, url string) (string, error) { + body, requestErr := openUrl(ctx, url) + + if requestErr != nil { + return "", requestErr + } + + // 读取所有数据并转换为byte数组 + data, err := io.ReadAll(body) + defer body.Close() + if err != nil { + return "", err + } + // 将数据转为字符串 + content := string(data) + return content, nil +} + +func openUrlAsYaml(ctx context.Context, url string) (map[string]interface{}, error) { + content, _ := openUrlAsString(ctx, url) + // 定义一个结构体来存储 YAML 解析结果 + var config map[string]interface{} // 假设 config 是一个 map + // 解析 YAML 内容 + err := yaml.Unmarshal([]byte(content), &config) + if err != nil { + return nil, err + } + return config, nil +} + func openContent(url string) (io.ReadCloser, error) { return app.OpenContent(url) } @@ -60,6 +92,14 @@ func fetch(url *U.URL, file string) error { defer reader.Close() + data, err := io.ReadAll(reader) + if err != nil { + return err + } + content := string(data) + parsedContent := applyParsers(ctx, content, url) + log.Debugln("最终subscribe:%s", parsedContent) + _ = os.MkdirAll(P.Dir(file), 0700) f, err := os.OpenFile(file, os.O_WRONLY|os.O_TRUNC|os.O_CREATE, 0600) @@ -69,7 +109,7 @@ func fetch(url *U.URL, file string) error { defer f.Close() - _, err = io.Copy(f, reader) + _, err = f.WriteString(parsedContent) if err != nil { _ = os.Remove(file) } @@ -77,6 +117,81 @@ func fetch(url *U.URL, file string) error { return err } +func applyParsers(ctx context.Context, subscribeOriginalStr string, subscribeUrl *U.URL) string { + if !subscribeUrl.Query().Has("parsers") { + log.Debugln("需要处理parsers") + return subscribeOriginalStr + } + + // 定义一个结构体来存储 YAML 解析结果 + var subscribe map[string]interface{} + + // 解析 YAML 内容 + err := yaml.Unmarshal([]byte(subscribeOriginalStr), &subscribe) + if err != nil { + // 如果解析出错,返回错误信息作为字符串 + log.Debugln("failed to parse YAML: %v", err) + return fmt.Sprintf("failed to parse YAML: %v", err) + } + + var parsersUrl = subscribeUrl.Query().Get("parsers") + log.Debugln("找到parsersURL: %s", parsersUrl) + parsersContainerYml, parsersErr := openUrlAsYaml(ctx, parsersUrl) + if parsersErr != nil { + log.Debugln("拉取parsers失败: %v", parsersErr) + return subscribeOriginalStr + } + + parsersContainer, parsersContainerExist := parsersContainerYml["parsers"].(map[string]interface{}) + if !parsersContainerExist { + log.Debugln("parsers容器中不存在parsers节点") + return subscribeOriginalStr + } + + parsers, parsersExist := parsersContainer["yaml"].(map[string]interface{}) + if !parsersExist { + log.Debugln("parsers容器中不存在yaml节点") + return subscribeOriginalStr + } + + subscribe = prependArr(subscribe, "proxies", parsers, "prepend-proxies") + subscribe = prependArr(subscribe, "proxy-groups", parsers, "prepend-proxy-groups") + subscribe = prependArr(subscribe, "rules", parsers, "prepend-rules") + + // 将解析后的数据结构转回 YAML 格式的字符串 + yamlBytes, err := yaml.Marshal(subscribe) + if err != nil { + log.Debugln("failed to marshal YAML: %v", err) + return fmt.Sprintf("failed to marshal YAML: %v", err) + } + + // 返回解析后的 YAML 字符串 + return string(yamlBytes) +} + +func prependArr(subscribe map[string]interface{}, subscribeKey string, parsers map[string]interface{}, parserKey string) map[string]interface{} { + // 处理prepend-rules + if arrToPrepend, arrToPrependExist := parsers[parserKey].([]interface{}); arrToPrependExist { + log.Debugln("parses找到%s", parserKey) + // 提取 originalArr 字段 + if originalArr, originalArrExist := subscribe[subscribeKey].([]interface{}); originalArrExist { + log.Debugln("subscribe找到%s", subscribeKey) + // 将新的规则添加到 originalArr 数组的头部 + log.Debugln("subscribe原始%s:%v", subscribeKey, originalArr) + originalArr = append(arrToPrepend, originalArr...) + // 更新 subscribe 中的 originalArr 字段 + subscribe[subscribeKey] = originalArr + log.Debugln("subscribe编辑后%s:%v", subscribeKey, subscribe[subscribeKey]) + } else { + subscribe[subscribeKey] = arrToPrepend + log.Debugln("subscribe编辑后%s:%v", subscribeKey, subscribe[subscribeKey]) + } + } else { + log.Debugln("parses未找到%s", parserKey) + } + return subscribe +} + func FetchAndValid( path string, url string, From e6848776469f30696db29b167c5f7e7c00039a2b Mon Sep 17 00:00:00 2001 From: fangsy Date: Sat, 1 Feb 2025 22:15:49 +0800 Subject: [PATCH 2/9] =?UTF-8?q?=E5=8F=98=E6=9B=B4Build=E5=91=BD=E4=BB=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 58a65bc9cb..5cc436f234 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,7 @@ Feature of [Clash.Meta](https://github.com/MetaCubeX/Clash.Meta) 5. Build ```bash - ./gradlew app:assembleMeta-AlphaRelease + ./gradlew app:assembleAlphaRelease ``` ### Automation From c71524248e81efccae5ca9c52f9889c7b7050a3f Mon Sep 17 00:00:00 2001 From: fsygk Date: Tue, 10 Feb 2026 22:56:25 +0800 Subject: [PATCH 3/9] replace keystore --- release.keystore | Bin 2723 -> 2674 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/release.keystore b/release.keystore index dbbba6310746a073595731838719a2760b396f0c..90632298de99ed04a2d79bf934a1a14f344ba061 100644 GIT binary patch delta 2527 zcmV<52_W{P74j4!FoFth0s#Xsf(jT02`Yw2hW8Bt2LYgh3JC;)3I#BN3IUNKF9kp_ zf(1O2_XQvn4#4(?rOZ3nq-$G!CGR%~tpvWplPd-!f5*{jTI{mZMVcBxwSsF&wFH6$ z(12^EZhct?(m}3-HnX~^<+NEo@)D9z+L$CWX%h@UL&>W`Vi9wFAbq2zncAGGCM+?F zQG%fv6T75%M|_^rG-=ENyt(rL3;6FS= zu`I7!k7=(HNI*a%hw{Q&I0aL`PE|d(QH=Zp>hUp&Gxg4@KH|)mv@zP z_0g62hoLo>q}mZ#-$;4IS~JhnNQ~upZPls^Rb)^!Ho}o;+}lhNh`$AYcTB2HysP zAPb#M_?2fjaF+Ng=x!@-7_;b6kJY}sHt`^dPOyP^+z0o5!XtO(M@C9mDfnU5^1_r% zbf-%%awt;Mf;&h4YIHc;WG`Q#%&w5qvu!?*>+@9L@dbGoUgYDS?cY7ne^Y&t=+OE^ z*4ewk*?uf#Rj1JthV*j@{a)CEfC>zPXKjmo4AgIzUeVM={kbyu+f$Bn&Ctnqk3rue|=5#-YlJ_EybA2#IiLkr8@``h!LIDWxX=WmOc8r#YMqx zZJeJjV2Vd`pI1(n?m4W9h)>5m5cfmPXVjlhyyA+PW=VqW>Sm#DsV~LUc+8&(dAMT* z*sHA}ma~NQa5{gj{>@0&ZxHVNiyZ_A!&*XMymI9HZD}5AH_grwHj(6HGBWFck&~ zDuzgg_YDCF6fp=MNCp670BrzaFd+sBDuzgg_YDCF6)_Y95>#nzWgsy(H!wFdH8V6b zF*GtTf&@_p2`Yw2hW8Bt2L_;m1VS)^1U>=*05F0CHtl&c+U?+6+X6q6{QNf zD7UDAIbHQ=g20RQyU>3@>>^+2q+(tfpOwzkbE%6l8P2K^0f@G#UpT6gj<%olDb+_v zjbd^NEug>?da?DmBn2ZU-UfduS+l0{i%nQ;qW&YXMYMm_GSW#=0zd9NDS4+1u)iUsN55T#LET8_JWt$o( zRa*4S(#wBa_nk_ECY#8LcMFR1mN8p!f9~vrK9Ijy6~vpbw&+ybDv-{~Ymo;$7^Qwo zD36T&y>-!FA;V<0S0GSAX92(aw{&ZOBnM zytsdQ(TMrTaZg}|1PwEXaEG1%Mu>>cwc5u%0?MS}$BTz$cCLF-xKKv`iT)|X_+XXA z@XEYl>9A%_#7w%F!7ybf;1Z1>N%LslBYh97`Q*$Vs8=rQF7&cU^V^h1%YX1CEP19&bt32844KcI}c~J?6YaXA3Fz{Y0F4;J5zuA zA_y&+qNC9O71OfpC00bZ?8>pOQvAYyA7{4~3VB{;vbQN=@ p>dpVMd7Lu6bk&hi1Qa0IJ?7SIxqU;Ec+*>bp8-Go=4}E3ClEil!pZ;u delta 2576 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 From fea19dc6c1cbcb71ce17219938b1811e4c7b5564 Mon Sep 17 00:00:00 2001 From: fangsy Date: Wed, 25 Feb 2026 15:52:18 +0800 Subject: [PATCH 4/9] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E2=80=9C=E6=96=AD?= =?UTF-8?q?=E5=BC=80=E6=8C=87=E5=AE=9AWiFi=E6=97=B6=E8=BF=9E=E6=8E=A5?= =?UTF-8?q?=E5=88=B0VPN=E2=80=9D=E9=80=89=E9=A1=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .idea/codeStyles/Project.xml | 7 - app/src/main/AndroidManifest.xml | 4 + .../com/github/kr328/clash/MainActivity.kt | 29 ++++ .../com/github/kr328/clash/MainApplication.kt | 9 ++ .../com/github/kr328/clash/WifiAutomator.kt | 127 ++++++++++++++++++ .../clash/design/NetworkSettingsDesign.kt | 22 +++ design/src/main/res/values-zh/strings.xml | 4 + design/src/main/res/values/strings.xml | 5 + .../kr328/clash/service/store/ServiceStore.kt | 11 ++ 9 files changed, 211 insertions(+), 7 deletions(-) create mode 100644 app/src/main/java/com/github/kr328/clash/WifiAutomator.kt diff --git a/.idea/codeStyles/Project.xml b/.idea/codeStyles/Project.xml index ccbbe1a7ad..be09e11dce 100644 --- a/.idea/codeStyles/Project.xml +++ b/.idea/codeStyles/Project.xml @@ -13,13 +13,6 @@ - diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index f6fc1ab655..763e9e3492 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -16,6 +16,10 @@ tools:ignore="QueryAllPackagesPermission" /> + + + + () { requestPermissionLauncher.launch(android.Manifest.permission.POST_NOTIFICATIONS) } } + + // Request location permission for WiFi SSID access + val permissionsToRequest = mutableListOf() + if (ContextCompat.checkSelfPermission( + this, + android.Manifest.permission.ACCESS_FINE_LOCATION + ) != PackageManager.PERMISSION_GRANTED) { + permissionsToRequest.add(android.Manifest.permission.ACCESS_FINE_LOCATION) + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && + ContextCompat.checkSelfPermission( + this, + android.Manifest.permission.ACCESS_BACKGROUND_LOCATION + ) != PackageManager.PERMISSION_GRANTED) { + // Note: Background location usually needs to be requested separately or incrementally + // depending on target SDK, but adding it here for completeness if the user flow allows. + // For Android 11+, you must request foreground first, then background. + // For simplicity in this snippet, we'll add it if possible, but be aware of platform restrictions. + permissionsToRequest.add(android.Manifest.permission.ACCESS_BACKGROUND_LOCATION) + } + + if (permissionsToRequest.isNotEmpty()) { + ActivityCompat.requestPermissions( + this, + permissionsToRequest.toTypedArray(), + 1001 + ) + } } } diff --git a/app/src/main/java/com/github/kr328/clash/MainApplication.kt b/app/src/main/java/com/github/kr328/clash/MainApplication.kt index 6bef37f9b6..32f37b9bae 100644 --- a/app/src/main/java/com/github/kr328/clash/MainApplication.kt +++ b/app/src/main/java/com/github/kr328/clash/MainApplication.kt @@ -14,6 +14,8 @@ import java.io.FileOutputStream @Suppress("unused") class MainApplication : Application() { + private var wifiAutomator: WifiAutomator? = null + override fun attachBaseContext(base: Context?) { super.attachBaseContext(base) @@ -30,11 +32,18 @@ class MainApplication : Application() { if (processName == packageName) { Remote.launch() + wifiAutomator = WifiAutomator(this) + wifiAutomator?.start() } else { sendServiceRecreated() } } + override fun onTerminate() { + super.onTerminate() + wifiAutomator?.stop() + } + private fun extractGeoFiles() { clashDir.mkdirs() diff --git a/app/src/main/java/com/github/kr328/clash/WifiAutomator.kt b/app/src/main/java/com/github/kr328/clash/WifiAutomator.kt new file mode 100644 index 0000000000..2934d659a3 --- /dev/null +++ b/app/src/main/java/com/github/kr328/clash/WifiAutomator.kt @@ -0,0 +1,127 @@ +package com.github.kr328.clash + +import android.Manifest +import android.content.Context +import android.content.pm.PackageManager +import android.net.ConnectivityManager +import android.net.Network +import android.net.NetworkCapabilities +import android.net.NetworkRequest +import android.net.wifi.WifiInfo +import android.net.wifi.WifiManager +import android.os.Build +import androidx.core.content.ContextCompat +import com.github.kr328.clash.common.log.Log +import com.github.kr328.clash.service.store.ServiceStore +import com.github.kr328.clash.util.startClashService +import com.github.kr328.clash.util.stopClashService + +class WifiAutomator(private val context: Context) { + private val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + private val wifiManager = context.applicationContext.getSystemService(Context.WIFI_SERVICE) as WifiManager + private val serviceStore = ServiceStore(context) + private var lastConnectedSsid: String? = null + + private val networkCallback = object : ConnectivityManager.NetworkCallback() { + override fun onAvailable(network: Network) { + super.onAvailable(network) + updateCurrentSsid(network) + } + + override fun onLost(network: Network) { + super.onLost(network) + Log.d("WifiAutomator: Network Lost") + + // Check if the lost network was the target WiFi + if (serviceStore.autoConnectVpnOnWifiDisconnect) { + Log.d("WifiAutomator: autoConnectVpnOnWifiDisconnect is enabled and lastConnectedSsid is $lastConnectedSsid") + val targetSsid = serviceStore.wifiSsidForVpn + if (targetSsid?.isNotEmpty() == true && lastConnectedSsid == targetSsid) { + Log.d("WifiAutomator: Disconnected from target WiFi $targetSsid, starting Clash") + context.startClashService() + } + } else { + Log.d("WifiAutomator: autoConnectVpnOnWifiDisconnect is disabled") + } + } + + override fun onCapabilitiesChanged(network: Network, networkCapabilities: NetworkCapabilities) { + super.onCapabilitiesChanged(network, networkCapabilities) + updateCurrentSsid(network) + } + } + + private fun updateCurrentSsid(network: Network) { + val capabilities = connectivityManager.getNetworkCapabilities(network) + if (capabilities?.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) == true) { + var ssid: String? = null + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + val wifiInfo = capabilities.transportInfo as? WifiInfo + ssid = wifiInfo?.ssid + } + + // Fallback or if Q+ API returned null/unknown + if (ssid == null || ssid == WifiManager.UNKNOWN_SSID) { + // Check permissions before calling legacy API to avoid SecurityException + // Note: ACCESS_BACKGROUND_LOCATION is needed for background access on Android 10+ + val hasFineLocation = ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED + val hasBackgroundLocation = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_BACKGROUND_LOCATION) == PackageManager.PERMISSION_GRANTED + } else { + true + } + + if (hasFineLocation) { + try { + val info = wifiManager.connectionInfo + if (info != null && info.supplicantState.name == "COMPLETED") { + ssid = info.ssid + } + } catch (e: Exception) { + Log.w("WifiAutomator: Failed to get legacy wifi info", e) + } + } else { + Log.w("WifiAutomator: Missing location permission for legacy wifi info") + } + } + + val cleanSsid = ssid?.trim('"') + if (cleanSsid != null && cleanSsid != "" && cleanSsid != "0x") { + lastConnectedSsid = cleanSsid + Log.d("WifiAutomator: WiFi Connected to $cleanSsid") + + if (serviceStore.autoConnectVpnOnWifiDisconnect) { + val targetSsid = serviceStore.wifiSsidForVpn + if (targetSsid?.isNotEmpty() == true && cleanSsid == targetSsid) { + Log.d("WifiAutomator: Connected to trusted WiFi $targetSsid, stopping Clash") + context.stopClashService() + } + } + } else { + Log.d("WifiAutomator: Failed to get valid SSID. Raw: $ssid") + } + } + } + + fun start() { + val request = NetworkRequest.Builder() + .addTransportType(NetworkCapabilities.TRANSPORT_WIFI) + .build() + connectivityManager.registerNetworkCallback(request, networkCallback) + + // Initial check + val activeNetwork = connectivityManager.activeNetwork + if (activeNetwork != null) { + updateCurrentSsid(activeNetwork) + } + } + + fun stop() { + try { + connectivityManager.unregisterNetworkCallback(networkCallback) + } catch (e: Exception) { + // Ignore if not registered + } + } +} \ No newline at end of file diff --git a/design/src/main/java/com/github/kr328/clash/design/NetworkSettingsDesign.kt b/design/src/main/java/com/github/kr328/clash/design/NetworkSettingsDesign.kt index 7f942b0f9d..0668b5a109 100644 --- a/design/src/main/java/com/github/kr328/clash/design/NetworkSettingsDesign.kt +++ b/design/src/main/java/com/github/kr328/clash/design/NetworkSettingsDesign.kt @@ -41,6 +41,28 @@ class NetworkSettingsDesign( val screen = preferenceScreen(context) { val vpnDependencies: MutableList = mutableListOf() + lateinit var ssidInput: EditableTextPreference + + val autoConnectVpnSwitch = switch( + value = srvStore::autoConnectVpnOnWifiDisconnect, + title = R.string.auto_connect_vpn_on_wifi_disconnect, + summary = R.string.auto_connect_vpn_on_wifi_disconnect_summary, + ) { + listener = OnChangedListener { + ssidInput.enabled = srvStore.autoConnectVpnOnWifiDisconnect + } + } + + ssidInput = editableText( + value = srvStore::wifiSsidForVpn, + adapter = NullableTextAdapter.String, + title = R.string.wifi_ssid_for_vpn, + placeholder = R.string.wifi_ssid_placeholder, + empty = R.string.empty, + ) { + enabled = srvStore.autoConnectVpnOnWifiDisconnect + } + val vpn = switch( value = uiStore::enableVpn, icon = R.drawable.ic_baseline_vpn_lock, diff --git a/design/src/main/res/values-zh/strings.xml b/design/src/main/res/values-zh/strings.xml index 48daff432b..90ba475049 100644 --- a/design/src/main/res/values-zh/strings.xml +++ b/design/src/main/res/values-zh/strings.xml @@ -68,6 +68,10 @@ 设置 显示流量 在通知中自动刷新流量 + WiFi 断开时自动连接 VPN + WiFi 断开连接时自动连接到 VPN + WiFi SSID 名称 + 输入 WiFi SSID(可选) 允许 Clash 自动重启 自动重启 已停止 diff --git a/design/src/main/res/values/strings.xml b/design/src/main/res/values/strings.xml index 518af90d0d..9e59e8b1c5 100644 --- a/design/src/main/res/values/strings.xml +++ b/design/src/main/res/values/strings.xml @@ -114,6 +114,11 @@ Auto Restart Allow clash auto restart + Auto Connect VPN on WiFi Disconnect + Automatically connect to VPN when WiFi is disconnected + WiFi SSID for VPN + Enter WiFi SSID (optional) + Route System Traffic Auto routing all system traffic via VpnService diff --git a/service/src/main/java/com/github/kr328/clash/service/store/ServiceStore.kt b/service/src/main/java/com/github/kr328/clash/service/store/ServiceStore.kt index d361848ff5..82f3ffd4f4 100644 --- a/service/src/main/java/com/github/kr328/clash/service/store/ServiceStore.kt +++ b/service/src/main/java/com/github/kr328/clash/service/store/ServiceStore.kt @@ -65,4 +65,15 @@ class ServiceStore(context: Context) { key = "dynamic_notification", defaultValue = true ) + + var autoConnectVpnOnWifiDisconnect by store.boolean( + key = "auto_connect_vpn_on_wifi_disconnect", + defaultValue = false + ) + + var wifiSsidForVpn by store.typedString( + key = "wifi_ssid_for_vpn", + from = { if (it.isBlank()) null else it }, + to = { it ?: "" } + ) } \ No newline at end of file From 68d056c87b79f76e2db5e6f89e1dce081fddbd63 Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Fri, 27 Feb 2026 07:14:08 +0000 Subject: [PATCH 5/9] Bump version to 2.11.23.1 (211023) --- build.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle.kts b/build.gradle.kts index 1b5e1dd67e..f56695568b 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -58,7 +58,7 @@ subprojects { minSdk = 21 targetSdk = 35 - versionName = "2.11.23" + versionName = "2.11.23.1" versionCode = 211023 resValue("string", "release_name", "v$versionName") From 41d28b65aa9712591d983307c99ef3049cd02595 Mon Sep 17 00:00:00 2001 From: fangsy Date: Mon, 8 Jun 2026 22:03:35 +0800 Subject: [PATCH 6/9] merge --- core/src/main/golang/native/config/fetch.go | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/core/src/main/golang/native/config/fetch.go b/core/src/main/golang/native/config/fetch.go index b5eb63914b..808c6c34aa 100644 --- a/core/src/main/golang/native/config/fetch.go +++ b/core/src/main/golang/native/config/fetch.go @@ -4,8 +4,6 @@ import ( "context" "encoding/json" "fmt" - "github.com/metacubex/mihomo/log" - "gopkg.in/yaml.v3" "io" "net/http" U "net/url" @@ -14,6 +12,9 @@ import ( "runtime" "time" + "github.com/metacubex/mihomo/log" + "gopkg.in/yaml.v3" + "cfa/native/app" clashHttp "github.com/metacubex/mihomo/component/http" @@ -93,10 +94,10 @@ func fetch(url *U.URL, file string) error { defer reader.Close() - return writeFile(file, reader) + return writeFile(file, reader, ctx, url) } -func writeFile(file string, reader io.Reader) error { +func writeFile(file string, reader io.Reader, ctx context.Context, url *U.URL) error { data, err := io.ReadAll(reader) if err != nil { return err From 8814eb65dd2f8ce84cf1a08bde354fadeb6e0d53 Mon Sep 17 00:00:00 2001 From: fangsy Date: Wed, 16 Sep 2026 09:48:07 +0800 Subject: [PATCH 7/9] build(core): add missing yaml.v3 dependency to go.mod --- core/src/main/golang/go.mod | 1 + core/src/main/golang/go.sum | 1 + 2 files changed, 2 insertions(+) diff --git a/core/src/main/golang/go.mod b/core/src/main/golang/go.mod index 3ed6d64365..64229d896f 100644 --- a/core/src/main/golang/go.mod +++ b/core/src/main/golang/go.mod @@ -142,6 +142,7 @@ require ( golang.org/x/time v0.10.0 // indirect golang.org/x/tools v0.24.0 // indirect google.golang.org/protobuf v1.34.2 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect ) replace google.golang.org/protobuf => github.com/metacubex/protobuf-go v0.0.0-20260306035419-7ceee0674686 diff --git a/core/src/main/golang/go.sum b/core/src/main/golang/go.sum index 24c77a86db..5e59feb4b0 100644 --- a/core/src/main/golang/go.sum +++ b/core/src/main/golang/go.sum @@ -323,5 +323,6 @@ golang.org/x/tools v0.24.0/go.mod h1:YhNqVBIfWHdzvTLs0d8LCuMhkKUgSUKldakyV7W/WDQ golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= software.sslmate.com/src/go-pkcs12 v0.4.0 h1:H2g08FrTvSFKUj+D309j1DPfk5APnIdAQAB8aEykJ5k= From d11277dec943e16b929dfe7f54b0cf6a0c6c4ae9 Mon Sep 17 00:00:00 2001 From: fangsy Date: Wed, 16 Sep 2026 09:57:42 +0800 Subject: [PATCH 8/9] build(core): add missing yaml.v3 dependency to go.mod --- core/src/foss/golang/go.mod | 1 + core/src/foss/golang/go.sum | 1 + 2 files changed, 2 insertions(+) diff --git a/core/src/foss/golang/go.mod b/core/src/foss/golang/go.mod index c2575157cc..59da8ada34 100644 --- a/core/src/foss/golang/go.mod +++ b/core/src/foss/golang/go.mod @@ -139,6 +139,7 @@ require ( golang.org/x/time v0.10.0 // indirect golang.org/x/tools v0.24.0 // indirect google.golang.org/protobuf v1.34.2 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect ) replace cfa => ../../main/golang diff --git a/core/src/foss/golang/go.sum b/core/src/foss/golang/go.sum index 24c77a86db..5e59feb4b0 100644 --- a/core/src/foss/golang/go.sum +++ b/core/src/foss/golang/go.sum @@ -323,5 +323,6 @@ golang.org/x/tools v0.24.0/go.mod h1:YhNqVBIfWHdzvTLs0d8LCuMhkKUgSUKldakyV7W/WDQ golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= software.sslmate.com/src/go-pkcs12 v0.4.0 h1:H2g08FrTvSFKUj+D309j1DPfk5APnIdAQAB8aEykJ5k= From 545dd1c415511796cf66bf9e33d73942cd070e6a Mon Sep 17 00:00:00 2001 From: fangsy Date: Fri, 13 Nov 2026 11:37:24 +0800 Subject: [PATCH 9/9] =?UTF-8?q?feat:=20=E4=BB=A3=E7=A0=81=E8=A7=A3?= =?UTF-8?q?=E8=80=A6=EF=BC=8C=E9=80=BB=E8=BE=91=E6=8A=BD=E5=8F=96=EF=BC=8C?= =?UTF-8?q?=E9=81=BF=E5=85=8D=E4=B8=8E=E4=B8=8A=E6=B8=B8=E5=86=B2=E7=AA=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../github/kr328/clash/PropertiesActivity.kt | 14 +- core/src/main/golang/native/config/fetch.go | 148 +--------------- core/src/main/golang/native/config/parsers.go | 160 ++++++++++++++++++ .../kr328/clash/design/PropertiesDesign.kt | 4 + .../clash/design/PropertiesParserHelper.kt | 105 ++++++++++++ .../src/main/res/values-zh/strings_parser.xml | 7 + design/src/main/res/values/strings_parser.xml | 7 + .../clash/service/util/ProfileParserHelper.kt | 35 ++++ 8 files changed, 333 insertions(+), 147 deletions(-) create mode 100644 core/src/main/golang/native/config/parsers.go create mode 100644 design/src/main/java/com/github/kr328/clash/design/PropertiesParserHelper.kt create mode 100644 design/src/main/res/values-zh/strings_parser.xml create mode 100644 design/src/main/res/values/strings_parser.xml create mode 100644 service/src/main/java/com/github/kr328/clash/service/util/ProfileParserHelper.kt diff --git a/app/src/main/java/com/github/kr328/clash/PropertiesActivity.kt b/app/src/main/java/com/github/kr328/clash/PropertiesActivity.kt index a6fc07550a..c190e853ab 100644 --- a/app/src/main/java/com/github/kr328/clash/PropertiesActivity.kt +++ b/app/src/main/java/com/github/kr328/clash/PropertiesActivity.kt @@ -7,11 +7,14 @@ import com.github.kr328.clash.design.PropertiesDesign import com.github.kr328.clash.design.ui.ToastDuration import com.github.kr328.clash.design.util.showExceptionToast import com.github.kr328.clash.service.model.Profile +import com.github.kr328.clash.service.util.ProfileParserHelper import com.github.kr328.clash.util.withProfile +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.selects.select +import kotlinx.coroutines.withContext import com.github.kr328.clash.design.R class PropertiesActivity : BaseActivity() { @@ -27,6 +30,9 @@ class PropertiesActivity : BaseActivity() { original = withProfile { queryByUUID(uuid) } ?: return finish() design.profile = original + design.parserHelper.setInitial( + withContext(Dispatchers.IO) { ProfileParserHelper.load(this@PropertiesActivity, uuid) } + ) setContentDesign(design) @@ -43,9 +49,11 @@ class PropertiesActivity : BaseActivity() { Event.ActivityStop -> { val profile = design.profile - if (!canceled && profile != original) { + if (!canceled && (profile != original || design.parserHelper.isChanged())) { + val parserUrl = design.parserHelper.parserUrl withProfile { patch(profile.uuid, profile.name, profile.source, profile.interval, profile.ageSecretKey) + ProfileParserHelper.savePending(this@PropertiesActivity, profile.uuid, parserUrl) } } } @@ -73,7 +81,7 @@ class PropertiesActivity : BaseActivity() { design?.apply { launch { if (!progressing) { - if (original == profile || requestExitWithoutSaving()) + if ((original == profile && !parserHelper.isChanged()) || requestExitWithoutSaving()) finish() } } @@ -90,9 +98,11 @@ class PropertiesActivity : BaseActivity() { } else -> { try { + val parserUrl = parserHelper.parserUrl withProcessing { updateStatus -> withProfile { patch(profile.uuid, profile.name, profile.source, profile.interval, profile.ageSecretKey) + ProfileParserHelper.savePending(this@PropertiesActivity, profile.uuid, parserUrl) coroutineScope { commit(profile.uuid) { diff --git a/core/src/main/golang/native/config/fetch.go b/core/src/main/golang/native/config/fetch.go index 054f7e8e58..2b31e25e1d 100644 --- a/core/src/main/golang/native/config/fetch.go +++ b/core/src/main/golang/native/config/fetch.go @@ -14,9 +14,6 @@ import ( "strings" "time" - "github.com/metacubex/mihomo/log" - "gopkg.in/yaml.v3" - "cfa/native/app" "github.com/metacubex/mihomo/adapter/provider" @@ -41,16 +38,6 @@ type fetchHeader struct { ProfileUpdateInterval string } -func openUrlNoHeader(ctx context.Context, url string) (io.ReadCloser, error) { - response, err := clashHttp.HttpRequest(ctx, url, http.MethodGet, http.Header{"User-Agent": {"ClashMetaForAndroid/" + app.VersionName()}}, nil) - - if err != nil { - return nil, err - } - - return response.Body, nil -} - func openUrl(ctx context.Context, url string) (io.ReadCloser, fetchHeader, error) { response, err := clashHttp.HttpRequest(ctx, url, http.MethodGet, http.Header{"User-Agent": {"ClashMetaForAndroid/" + app.VersionName()}}, nil) @@ -64,36 +51,6 @@ func openUrl(ctx context.Context, url string) (io.ReadCloser, fetchHeader, error }, nil } -func openUrlAsString(ctx context.Context, url string) (string, error) { - body, requestErr := openUrlNoHeader(ctx, url) - - if requestErr != nil { - return "", requestErr - } - - // 读取所有数据并转换为byte数组 - data, err := io.ReadAll(body) - defer body.Close() - if err != nil { - return "", err - } - // 将数据转为字符串 - content := string(data) - return content, nil -} - -func openUrlAsYaml(ctx context.Context, url string) (map[string]interface{}, error) { - content, _ := openUrlAsString(ctx, url) - // 定义一个结构体来存储 YAML 解析结果 - var config map[string]interface{} // 假设 config 是一个 map - // 解析 YAML 内容 - err := yaml.Unmarshal([]byte(content), &config) - if err != nil { - return nil, err - } - return config, nil -} - func openContent(url string) (io.ReadCloser, error) { return app.OpenContent(url) } @@ -121,7 +78,7 @@ func fetch(url *U.URL, file string) (fetchHeader, error) { defer reader.Close() - return header, writeFileWithContext(file, reader, ctx, url) + return header, writeFile(file, reader) } func writeFile(file string, reader io.Reader) error { @@ -142,107 +99,6 @@ func writeFile(file string, reader io.Reader) error { return err } -func writeFileWithContext(file string, reader io.Reader, ctx context.Context, url *U.URL) error { - data, err := io.ReadAll(reader) - if err != nil { - return err - } - content := string(data) - parsedContent := applyParsers(ctx, content, url) - log.Debugln("最终subscribe:%s", parsedContent) - - _ = os.MkdirAll(P.Dir(file), 0700) - - f, err := os.OpenFile(file, os.O_WRONLY|os.O_TRUNC|os.O_CREATE, 0600) - if err != nil { - return err - } - - defer f.Close() - - _, err = f.WriteString(parsedContent) - if err != nil { - _ = os.Remove(file) - } - - return err -} - -func applyParsers(ctx context.Context, subscribeOriginalStr string, subscribeUrl *U.URL) string { - if !subscribeUrl.Query().Has("parsers") { - log.Debugln("需要处理parsers") - return subscribeOriginalStr - } - - // 定义一个结构体来存储 YAML 解析结果 - var subscribe map[string]interface{} - - // 解析 YAML 内容 - err := yaml.Unmarshal([]byte(subscribeOriginalStr), &subscribe) - if err != nil { - // 如果解析出错,返回错误信息作为字符串 - log.Debugln("failed to parse YAML: %v", err) - return fmt.Sprintf("failed to parse YAML: %v", err) - } - - var parsersUrl = subscribeUrl.Query().Get("parsers") - log.Debugln("找到parsersURL: %s", parsersUrl) - parsersContainerYml, parsersErr := openUrlAsYaml(ctx, parsersUrl) - if parsersErr != nil { - log.Debugln("拉取parsers失败: %v", parsersErr) - return subscribeOriginalStr - } - - parsersContainer, parsersContainerExist := parsersContainerYml["parsers"].(map[string]interface{}) - if !parsersContainerExist { - log.Debugln("parsers容器中不存在parsers节点") - return subscribeOriginalStr - } - - parsers, parsersExist := parsersContainer["yaml"].(map[string]interface{}) - if !parsersExist { - log.Debugln("parsers容器中不存在yaml节点") - return subscribeOriginalStr - } - - subscribe = prependArr(subscribe, "proxies", parsers, "prepend-proxies") - subscribe = prependArr(subscribe, "proxy-groups", parsers, "prepend-proxy-groups") - subscribe = prependArr(subscribe, "rules", parsers, "prepend-rules") - - // 将解析后的数据结构转回 YAML 格式的字符串 - yamlBytes, err := yaml.Marshal(subscribe) - if err != nil { - log.Debugln("failed to marshal YAML: %v", err) - return fmt.Sprintf("failed to marshal YAML: %v", err) - } - - // 返回解析后的 YAML 字符串 - return string(yamlBytes) -} - -func prependArr(subscribe map[string]interface{}, subscribeKey string, parsers map[string]interface{}, parserKey string) map[string]interface{} { - // 处理prepend-rules - if arrToPrepend, arrToPrependExist := parsers[parserKey].([]interface{}); arrToPrependExist { - log.Debugln("parses找到%s", parserKey) - // 提取 originalArr 字段 - if originalArr, originalArrExist := subscribe[subscribeKey].([]interface{}); originalArrExist { - log.Debugln("subscribe找到%s", subscribeKey) - // 将新的规则添加到 originalArr 数组的头部 - log.Debugln("subscribe原始%s:%v", subscribeKey, originalArr) - originalArr = append(arrToPrepend, originalArr...) - // 更新 subscribe 中的 originalArr 字段 - subscribe[subscribeKey] = originalArr - log.Debugln("subscribe编辑后%s:%v", subscribeKey, subscribe[subscribeKey]) - } else { - subscribe[subscribeKey] = arrToPrepend - log.Debugln("subscribe编辑后%s:%v", subscribeKey, subscribe[subscribeKey]) - } - } else { - log.Debugln("parses未找到%s", parserKey) - } - return subscribe -} - func parseProfileUpdateInterval(value string) (int64, bool) { hours, err := strconv.ParseInt(strings.TrimSpace(value), 10, 64) if err != nil { @@ -320,6 +176,8 @@ func FetchAndValid( return err } + applyParsersToConfig(path, configPath, url) + reportSubscriptionInfo(header, reportStatus) } diff --git a/core/src/main/golang/native/config/parsers.go b/core/src/main/golang/native/config/parsers.go new file mode 100644 index 0000000000..fe74e59cd2 --- /dev/null +++ b/core/src/main/golang/native/config/parsers.go @@ -0,0 +1,160 @@ +package config + +import ( + "context" + "io" + "net/http" + U "net/url" + "os" + P "path" + "strings" + "time" + + "cfa/native/app" + + clashHttp "github.com/metacubex/mihomo/component/http" + "github.com/metacubex/mihomo/log" + "gopkg.in/yaml.v3" +) + +const parserUrlFileName = "parser.url" + +func resolveParsersUrl(profileDir string, subscribeUrl *U.URL) string { + if data, err := os.ReadFile(P.Join(profileDir, parserUrlFileName)); err == nil { + if u := strings.TrimSpace(string(data)); u != "" { + return u + } + } + + if subscribeUrl != nil && subscribeUrl.Query().Has("parsers") { + if u := strings.TrimSpace(subscribeUrl.Query().Get("parsers")); u != "" { + return u + } + } + + return "" +} + +func applyParsersToConfig(profileDir string, configPath string, subscribeUrl *U.URL) { + parsersUrl := resolveParsersUrl(profileDir, subscribeUrl) + if parsersUrl == "" { + return + } + + data, err := os.ReadFile(configPath) + if err != nil { + log.Debugln("读取配置文件失败: %v", err) + return + } + + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + + originalContent := string(data) + parsedContent := applyParsersWithUrl(ctx, originalContent, parsersUrl) + if parsedContent == originalContent { + return + } + + log.Debugln("最终subscribe:%s", parsedContent) + + if err := os.WriteFile(configPath, []byte(parsedContent), 0600); err != nil { + log.Debugln("写入parsers后配置失败: %v", err) + } +} + +func openUrlNoHeader(ctx context.Context, url string) (io.ReadCloser, error) { + response, err := clashHttp.HttpRequest(ctx, url, http.MethodGet, http.Header{"User-Agent": {"ClashMetaForAndroid/" + app.VersionName()}}, nil) + if err != nil { + return nil, err + } + + return response.Body, nil +} + +func openUrlAsString(ctx context.Context, url string) (string, error) { + body, requestErr := openUrlNoHeader(ctx, url) + if requestErr != nil { + return "", requestErr + } + defer body.Close() + + data, err := io.ReadAll(body) + if err != nil { + return "", err + } + + return string(data), nil +} + +func openUrlAsYaml(ctx context.Context, url string) (map[string]interface{}, error) { + content, err := openUrlAsString(ctx, url) + if err != nil { + return nil, err + } + + var cfg map[string]interface{} + if err := yaml.Unmarshal([]byte(content), &cfg); err != nil { + return nil, err + } + + return cfg, nil +} + +func applyParsersWithUrl(ctx context.Context, subscribeOriginalStr string, parsersUrl string) string { + var subscribe map[string]interface{} + if err := yaml.Unmarshal([]byte(subscribeOriginalStr), &subscribe); err != nil { + log.Debugln("failed to parse YAML: %v", err) + return subscribeOriginalStr + } + + log.Debugln("找到parsersURL: %s", parsersUrl) + parsersContainerYml, parsersErr := openUrlAsYaml(ctx, parsersUrl) + if parsersErr != nil { + log.Debugln("拉取parsers失败: %v", parsersErr) + return subscribeOriginalStr + } + + parsersContainer, parsersContainerExist := parsersContainerYml["parsers"].(map[string]interface{}) + if !parsersContainerExist { + log.Debugln("parsers容器中不存在parsers节点") + return subscribeOriginalStr + } + + parsers, parsersExist := parsersContainer["yaml"].(map[string]interface{}) + if !parsersExist { + log.Debugln("parsers容器中不存在yaml节点") + return subscribeOriginalStr + } + + subscribe = prependArr(subscribe, "proxies", parsers, "prepend-proxies") + subscribe = prependArr(subscribe, "proxy-groups", parsers, "prepend-proxy-groups") + subscribe = prependArr(subscribe, "rules", parsers, "prepend-rules") + + yamlBytes, err := yaml.Marshal(subscribe) + if err != nil { + log.Debugln("failed to marshal YAML: %v", err) + return subscribeOriginalStr + } + + return string(yamlBytes) +} + +func prependArr(subscribe map[string]interface{}, subscribeKey string, parsers map[string]interface{}, parserKey string) map[string]interface{} { + if arrToPrepend, arrToPrependExist := parsers[parserKey].([]interface{}); arrToPrependExist { + log.Debugln("parses找到%s", parserKey) + if originalArr, originalArrExist := subscribe[subscribeKey].([]interface{}); originalArrExist { + log.Debugln("subscribe找到%s", subscribeKey) + log.Debugln("subscribe原始%s:%v", subscribeKey, originalArr) + originalArr = append(arrToPrepend, originalArr...) + subscribe[subscribeKey] = originalArr + log.Debugln("subscribe编辑后%s:%v", subscribeKey, subscribe[subscribeKey]) + } else { + subscribe[subscribeKey] = arrToPrepend + log.Debugln("subscribe编辑后%s:%v", subscribeKey, subscribe[subscribeKey]) + } + } else { + log.Debugln("parses未找到%s", parserKey) + } + return subscribe +} diff --git a/design/src/main/java/com/github/kr328/clash/design/PropertiesDesign.kt b/design/src/main/java/com/github/kr328/clash/design/PropertiesDesign.kt index b0c26eff65..0573b0371a 100644 --- a/design/src/main/java/com/github/kr328/clash/design/PropertiesDesign.kt +++ b/design/src/main/java/com/github/kr328/clash/design/PropertiesDesign.kt @@ -2,6 +2,7 @@ package com.github.kr328.clash.design import android.content.Context import android.view.View +import android.view.ViewGroup import com.github.kr328.clash.core.model.FetchStatus import com.github.kr328.clash.design.databinding.DesignPropertiesBinding import com.github.kr328.clash.design.dialog.ModelProgressBarConfigure @@ -26,6 +27,8 @@ class PropertiesDesign(context: Context) : Design(cont private val binding = DesignPropertiesBinding .inflate(context.layoutInflater, context.root, false) + val parserHelper = PropertiesParserHelper(this, binding.scrollRoot.getChildAt(0) as ViewGroup) + override val root: View get() = binding.root @@ -33,6 +36,7 @@ class PropertiesDesign(context: Context) : Design(cont get() = binding.profile!! set(value) { binding.profile = value + parserHelper.onProfileChanged(value) } val progressing: Boolean diff --git a/design/src/main/java/com/github/kr328/clash/design/PropertiesParserHelper.kt b/design/src/main/java/com/github/kr328/clash/design/PropertiesParserHelper.kt new file mode 100644 index 0000000000..4def79b957 --- /dev/null +++ b/design/src/main/java/com/github/kr328/clash/design/PropertiesParserHelper.kt @@ -0,0 +1,105 @@ +package com.github.kr328.clash.design + +import android.view.ViewGroup +import android.widget.LinearLayout +import com.github.kr328.clash.common.compat.getDrawableCompat +import com.github.kr328.clash.design.dialog.requestModelTextInput +import com.github.kr328.clash.design.util.ValidatorHttpUrl +import com.github.kr328.clash.design.view.ActionTextField +import com.github.kr328.clash.service.model.Profile +import kotlinx.coroutines.launch + +class PropertiesParserHelper( + private val design: PropertiesDesign, + container: ViewGroup, +) { + private val context = design.context + private val field = ActionTextField(context) + + private var originalParserUrl: String? = null + private var currentProfile: Profile? = null + + var parserUrl: String? = null + private set + + init { + field.icon = context.getDrawableCompat(R.drawable.ic_baseline_extension) + field.title = context.getText(R.string.parser_url) + field.placeholder = context.getText(R.string.parser_url_hint) + field.setOnClickListener { inputParserUrl() } + + val marginVertical = + context.resources.getDimensionPixelSize(R.dimen.properties_element_margin_vertical) + val layoutParams = LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT + ).apply { + topMargin = marginVertical + bottomMargin = marginVertical + } + + container.addView(field, findInsertIndex(container), layoutParams) + } + + fun setInitial(url: String?) { + originalParserUrl = normalize(url) + parserUrl = originalParserUrl + refreshField() + } + + fun onProfileChanged(profile: Profile) { + currentProfile = profile + refreshField() + } + + fun isChanged(): Boolean { + return parserUrl != originalParserUrl + } + + private fun refreshField() { + val enabled = currentProfile?.type != Profile.Type.File + field.isEnabled = enabled + if (enabled) { + field.text = parserUrl + } + } + + private fun inputParserUrl() { + if (currentProfile?.type == Profile.Type.File) { + return + } + + design.launch { + val result = context.requestModelTextInput( + initial = parserUrl ?: "", + title = context.getText(R.string.parser_url), + reset = context.getText(R.string.reset), + hint = context.getText(R.string.parser_url_hint), + error = context.getText(R.string.parser_url_error), + validator = { it.isBlank() || ValidatorHttpUrl(it.trim()) } + ) + + val normalized = normalize(result) + if (normalized != parserUrl) { + parserUrl = normalized + refreshField() + } + } + } + + private fun findInsertIndex(container: ViewGroup): Int { + val urlTitle = context.getText(R.string.url).toString() + for (i in 0 until container.childCount) { + val child = container.getChildAt(i) + if (child is ActionTextField && child.title?.toString() == urlTitle) { + return i + 1 + } + } + return container.childCount + } + + private fun normalize(value: String?): String? { + return value?.trim()?.takeIf { it.isNotEmpty() } + } +} + diff --git a/design/src/main/res/values-zh/strings_parser.xml b/design/src/main/res/values-zh/strings_parser.xml new file mode 100644 index 0000000000..1b12be7702 --- /dev/null +++ b/design/src/main/res/values-zh/strings_parser.xml @@ -0,0 +1,7 @@ + + + 远程脚本链接 + http(s)://…(可选) + 仅支持 http:// 或 https:// 链接 + + diff --git a/design/src/main/res/values/strings_parser.xml b/design/src/main/res/values/strings_parser.xml new file mode 100644 index 0000000000..b97e4bf1df --- /dev/null +++ b/design/src/main/res/values/strings_parser.xml @@ -0,0 +1,7 @@ + + + Parser URL + http(s)://… (optional) + Only http:// or https:// URLs are supported + + diff --git a/service/src/main/java/com/github/kr328/clash/service/util/ProfileParserHelper.kt b/service/src/main/java/com/github/kr328/clash/service/util/ProfileParserHelper.kt new file mode 100644 index 0000000000..f659a74595 --- /dev/null +++ b/service/src/main/java/com/github/kr328/clash/service/util/ProfileParserHelper.kt @@ -0,0 +1,35 @@ +package com.github.kr328.clash.service.util + +import android.content.Context +import java.util.UUID + +object ProfileParserHelper { + private const val PARSER_URL_FILE = "parser.url" + + fun load(context: Context, uuid: UUID): String? { + val pendingProfileDir = context.pendingDir.resolve(uuid.toString()) + val targetDir = if (pendingProfileDir.exists()) { + pendingProfileDir + } else { + context.importedDir.resolve(uuid.toString()) + } + + val file = targetDir.resolve(PARSER_URL_FILE) + if (!file.isFile) { + return null + } + + return file.readText().trim().takeIf { it.isNotEmpty() } + } + + fun savePending(context: Context, uuid: UUID, parserUrl: String?) { + val dir = context.pendingDir.resolve(uuid.toString()) + if (!dir.exists()) { + dir.mkdirs() + } + + val normalized = parserUrl?.trim().orEmpty() + dir.resolve(PARSER_URL_FILE).writeText(normalized) + } +} +