From 5cfb8c2aefad5e0d8ac01e9ea638c1fb82c289e9 Mon Sep 17 00:00:00 2001 From: donglin2 Date: Mon, 29 Jun 2026 17:06:39 +0800 Subject: [PATCH] feat: add WebDAV cloud sync (pull from Clash Verge backups) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a "WebDAV Sync" settings screen that connects to the same WebDAV server Clash Verge backs up to, downloads the latest backup zip, and imports its subscription profiles into CMFA — for seamless one-way sync from a Clash Verge desktop install. - WebDavClient: PROPFIND list + GET download (OkHttp, Basic auth) against the fixed `clash-verge-rev-backup/` directory Clash Verge uses - VergeBackup: unzip + parse profiles.yaml (SnakeYAML), extract remote (subscription) profiles; local/enhancement profiles are skipped - Idempotent import through the existing ProfileManager create+commit pipeline (dedup by source URL), validated by the mihomo core - New WebDAV settings entry/screen, credential storage in UiStore, EN/zh strings, SnakeYAML R8 keep rules Pull-only for now (no upload), so it can never overwrite desktop state. Co-Authored-By: Claude Opus 4.8 --- app/build.gradle.kts | 2 + app/proguard-rules.pro | 9 +- app/src/main/AndroidManifest.xml | 5 + .../github/kr328/clash/SettingsActivity.kt | 2 + .../kr328/clash/WebDavSettingsActivity.kt | 107 +++++++++++++++++ .../github/kr328/clash/sync/VergeBackup.kt | 63 ++++++++++ .../github/kr328/clash/sync/WebDavClient.kt | 109 ++++++++++++++++++ .../kr328/clash/design/SettingsDesign.kt | 2 +- .../clash/design/WebDavSettingsDesign.kt | 92 +++++++++++++++ .../kr328/clash/design/store/UiStore.kt | 15 +++ .../src/main/res/layout/design_settings.xml | 7 ++ design/src/main/res/values-zh/strings.xml | 17 +++ design/src/main/res/values/strings.xml | 17 +++ gradle/libs.versions.toml | 4 + 14 files changed, 449 insertions(+), 2 deletions(-) create mode 100644 app/src/main/java/com/github/kr328/clash/WebDavSettingsActivity.kt create mode 100644 app/src/main/java/com/github/kr328/clash/sync/VergeBackup.kt create mode 100644 app/src/main/java/com/github/kr328/clash/sync/WebDavClient.kt create mode 100644 design/src/main/java/com/github/kr328/clash/design/WebDavSettingsDesign.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 27a8f5b26c..e55737a4fa 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -26,6 +26,8 @@ dependencies { implementation(libs.google.material) implementation(libs.quickie.bundled) implementation(libs.androidx.activity.ktx) + implementation(libs.okhttp) + implementation(libs.snakeyaml) } tasks.getByName("clean", type = Delete::class) { diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index bdc9a3ad81..91290849d8 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -56,4 +56,11 @@ boolean getASSERTIONS_ENABLED() return false; boolean getDEBUG() return false; boolean getRECOVER_STACK_TRACES() return false; -} \ No newline at end of file +} + +# SnakeYAML (WebDAV sync: parsing Clash Verge's profiles.yaml). +# It loads constructors/representers reflectively and references java.beans, +# which is absent on Android — keep its classes and silence the missing refs. +-keep class org.yaml.snakeyaml.** { *; } +-dontwarn org.yaml.snakeyaml.** +-dontwarn java.beans.** \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 9900007e38..5c266ab220 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -172,6 +172,11 @@ android:configChanges="uiMode" android:exported="false" android:label="@string/meta_features" /> + () { startActivity(OverrideSettingsActivity::class.intent) SettingsDesign.Request.StartMetaFeature -> startActivity(MetaFeatureSettingsActivity::class.intent) + SettingsDesign.Request.StartWebDav -> + startActivity(WebDavSettingsActivity::class.intent) } } } diff --git a/app/src/main/java/com/github/kr328/clash/WebDavSettingsActivity.kt b/app/src/main/java/com/github/kr328/clash/WebDavSettingsActivity.kt new file mode 100644 index 0000000000..c04c795286 --- /dev/null +++ b/app/src/main/java/com/github/kr328/clash/WebDavSettingsActivity.kt @@ -0,0 +1,107 @@ +package com.github.kr328.clash + +import com.github.kr328.clash.design.R +import com.github.kr328.clash.design.WebDavSettingsDesign +import com.github.kr328.clash.design.ui.ToastDuration +import com.github.kr328.clash.service.model.Profile +import com.github.kr328.clash.sync.VergeBackup +import com.github.kr328.clash.sync.WebDavClient +import com.github.kr328.clash.sync.WebDavException +import com.github.kr328.clash.util.withProfile +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.isActive +import kotlinx.coroutines.selects.select +import kotlinx.coroutines.withContext + +class WebDavSettingsActivity : BaseActivity() { + override suspend fun main() { + val design = WebDavSettingsDesign(this, uiStore) + + setContentDesign(design) + + while (isActive) { + select { + events.onReceive { } + design.requests.onReceive { + when (it) { + WebDavSettingsDesign.Request.SyncNow -> performSync(design) + } + } + } + } + } + + private suspend fun performSync(design: WebDavSettingsDesign) { + val url = uiStore.webdavUrl.trim() + val user = uiStore.webdavUsername + val pass = uiStore.webdavPassword + + if (url.isEmpty() || user.isEmpty() || pass.isEmpty()) { + design.showToast(R.string.webdav_missing_credentials, ToastDuration.Long) + return + } + + design.showToast(R.string.webdav_syncing, ToastDuration.Long) + + // Download the newest backup off the WebDAV server and parse out its subscriptions. + val parsed = try { + withContext(Dispatchers.IO) { + val client = WebDavClient(url, user, pass) + val backups = client.listBackups() + if (backups.isEmpty()) null else VergeBackup.parse(client.download(backups.first())) + } + } catch (e: WebDavException) { + design.showToast(getString(R.string.webdav_sync_failed, e.message ?: ""), ToastDuration.Long) + return + } catch (e: Exception) { + design.showToast( + getString(R.string.webdav_sync_failed, e.message ?: e.javaClass.simpleName), + ToastDuration.Long, + ) + return + } + + if (parsed == null) { + design.showToast(R.string.webdav_no_backup_found, ToastDuration.Long) + return + } + + // Import each subscription, skipping ones already present (matched by source URL), + // so repeated syncs are idempotent rather than creating duplicates. + var added = 0 + var existed = 0 + var failed = 0 + + withProfile { + val knownSources = queryAll() + .mapNotNull { it.source.takeIf(String::isNotEmpty) } + .toHashSet() + + for (remote in parsed.remotes) { + if (remote.url in knownSources) { + existed++ + continue + } + + val uuid = create(Profile.Type.Url, remote.name, remote.url) + try { + commit(uuid) + knownSources.add(remote.url) + added++ + } catch (e: Exception) { + // Fetch/validation failed — drop the half-created pending profile. + try { + release(uuid) + } catch (_: Exception) { + } + failed++ + } + } + } + + design.showToast( + getString(R.string.webdav_sync_result, added, existed, failed, parsed.skippedLocal), + ToastDuration.Long, + ) + } +} diff --git a/app/src/main/java/com/github/kr328/clash/sync/VergeBackup.kt b/app/src/main/java/com/github/kr328/clash/sync/VergeBackup.kt new file mode 100644 index 0000000000..94cf6391a2 --- /dev/null +++ b/app/src/main/java/com/github/kr328/clash/sync/VergeBackup.kt @@ -0,0 +1,63 @@ +package com.github.kr328.clash.sync + +import org.yaml.snakeyaml.Yaml +import java.io.ByteArrayInputStream +import java.util.zip.ZipInputStream + +/** A subscription profile recovered from a Clash Verge backup. */ +data class RemoteProfile(val name: String, val url: String) + +/** Result of parsing a backup: importable subscriptions and how many local configs were skipped. */ +data class ParsedBackup(val remotes: List, val skippedLocal: Int) + +/** + * Reads a Clash Verge backup zip and pulls out the parts CMFA can actually use. + * + * A backup zip contains `profiles.yaml` (the profile index) plus a `profiles/` directory. + * Only `type: remote` entries (subscriptions, identified by their `url`) map onto a CMFA + * profile. Verge-specific entries — local/merge/script/rules/proxies/groups — have no + * equivalent here; `local` configs are counted so the user knows they were skipped, the + * rest are sub-components and ignored silently. + */ +object VergeBackup { + fun parse(zipBytes: ByteArray): ParsedBackup { + val profilesYaml = readEntry(zipBytes, "profiles.yaml") + ?: throw WebDavException("profiles.yaml not found in backup") + + @Suppress("UNCHECKED_CAST") + val root = Yaml().load(profilesYaml) as? Map + ?: return ParsedBackup(emptyList(), 0) + val items = root["items"] as? List<*> ?: return ParsedBackup(emptyList(), 0) + + val remotes = ArrayList() + var skippedLocal = 0 + + for (item in items) { + val map = item as? Map<*, *> ?: continue + when ((map["type"] as? String)?.lowercase()) { + "remote" -> { + val url = (map["url"] as? String)?.trim().orEmpty() + if (url.isEmpty()) continue + val name = (map["name"] as? String)?.trim()?.ifEmpty { null } ?: url + remotes.add(RemoteProfile(name, url)) + } + "local" -> skippedLocal++ + } + } + + return ParsedBackup(remotes, skippedLocal) + } + + private fun readEntry(zipBytes: ByteArray, name: String): String? { + ZipInputStream(ByteArrayInputStream(zipBytes)).use { zis -> + var entry = zis.nextEntry + while (entry != null) { + if (!entry.isDirectory && entry.name.substringAfterLast('/') == name) { + return zis.readBytes().toString(Charsets.UTF_8) + } + entry = zis.nextEntry + } + } + return null + } +} diff --git a/app/src/main/java/com/github/kr328/clash/sync/WebDavClient.kt b/app/src/main/java/com/github/kr328/clash/sync/WebDavClient.kt new file mode 100644 index 0000000000..6191348c5e --- /dev/null +++ b/app/src/main/java/com/github/kr328/clash/sync/WebDavClient.kt @@ -0,0 +1,109 @@ +package com.github.kr328.clash.sync + +import okhttp3.Credentials +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import java.net.URLDecoder +import java.util.concurrent.TimeUnit + +/** + * Minimal read-only WebDAV client compatible with Clash Verge's cloud backup layout. + * + * Clash Verge stores every backup as a zip inside a fixed sub-directory of the configured + * WebDAV root (see clash-verge-rev: src-tauri/src/utils/dirs.rs `BACKUP_DIR`). We only need + * to list that directory and download a zip, so just PROPFIND + GET are implemented. + */ +class WebDavClient( + baseUrl: String, + username: String, + password: String, +) { + private val root = baseUrl.trim().trimEnd('/') + private val dirUrl = "$root/$BACKUP_DIR" + private val credential = Credentials.basic(username, password) + + private val client = OkHttpClient.Builder() + .connectTimeout(30, TimeUnit.SECONDS) + .readTimeout(TIMEOUT_SECONDS, TimeUnit.SECONDS) + .writeTimeout(TIMEOUT_SECONDS, TimeUnit.SECONDS) + .build() + + /** + * PROPFIND the backup directory and return the *.zip file names, newest first. + * Verge names backups "-backup-YYYY-MM-DD_HH-MM-SS.zip", so a descending + * lexical sort puts the most recent backup first. + */ + fun listBackups(): List { + val body = PROPFIND_BODY.toRequestBody("application/xml".toMediaType()) + val request = Request.Builder() + .url("$dirUrl/") + .header("Authorization", credential) + .header("Depth", "1") + .header("User-Agent", USER_AGENT) + .method("PROPFIND", body) + .build() + + client.newCall(request).execute().use { resp -> + // Directory missing yet -> no backups instead of a hard failure. + if (resp.code == 404) return emptyList() + if (!resp.isSuccessful && resp.code != HTTP_MULTI_STATUS) { + throw WebDavException("PROPFIND failed: HTTP ${resp.code}") + } + return parseZipHrefs(resp.body?.string().orEmpty()) + } + } + + /** GET a backup zip by file name and return its raw bytes. */ + fun download(fileName: String): ByteArray { + val request = Request.Builder() + .url("$dirUrl/$fileName") + .header("Authorization", credential) + .header("User-Agent", USER_AGENT) + .get() + .build() + + client.newCall(request).execute().use { resp -> + if (!resp.isSuccessful) throw WebDavException("Download failed: HTTP ${resp.code}") + return resp.body?.bytes() ?: throw WebDavException("Empty response body") + } + } + + private fun parseZipHrefs(xml: String): List { + return HREF_REGEX.findAll(xml) + .map { it.groupValues[1].trim() } + .map { it.substringAfterLast('/') } + .map { decode(it) } + .filter { it.endsWith(".zip", ignoreCase = true) } + .distinct() + .sortedDescending() + .toList() + } + + private fun decode(s: String): String = try { + URLDecoder.decode(s, "UTF-8") + } catch (e: Exception) { + s + } + + companion object { + // Keep in sync with clash-verge-rev `BACKUP_DIR`. + const val BACKUP_DIR = "clash-verge-rev-backup" + + private const val TIMEOUT_SECONDS = 300L + private const val HTTP_MULTI_STATUS = 207 + private const val USER_AGENT = "ClashMetaForAndroid WebDAV-Client" + + // , or — capture the inner path, namespace prefix optional. + private val HREF_REGEX = + Regex("<(?:[\\w-]+:)?href>(.*?)", RegexOption.IGNORE_CASE) + + private const val PROPFIND_BODY = + "" + + "" + + "" + } +} + +class WebDavException(message: String) : Exception(message) diff --git a/design/src/main/java/com/github/kr328/clash/design/SettingsDesign.kt b/design/src/main/java/com/github/kr328/clash/design/SettingsDesign.kt index 0536a587cd..6222796188 100644 --- a/design/src/main/java/com/github/kr328/clash/design/SettingsDesign.kt +++ b/design/src/main/java/com/github/kr328/clash/design/SettingsDesign.kt @@ -10,7 +10,7 @@ import com.github.kr328.clash.design.util.root class SettingsDesign(context: Context) : Design(context) { enum class Request { - StartApp, StartNetwork, StartOverride, StartMetaFeature, + StartApp, StartNetwork, StartOverride, StartMetaFeature, StartWebDav, } private val binding = DesignSettingsBinding diff --git a/design/src/main/java/com/github/kr328/clash/design/WebDavSettingsDesign.kt b/design/src/main/java/com/github/kr328/clash/design/WebDavSettingsDesign.kt new file mode 100644 index 0000000000..6f7a3ace34 --- /dev/null +++ b/design/src/main/java/com/github/kr328/clash/design/WebDavSettingsDesign.kt @@ -0,0 +1,92 @@ +package com.github.kr328.clash.design + +import android.content.Context +import android.view.View +import com.github.kr328.clash.design.databinding.DesignSettingsCommonBinding +import com.github.kr328.clash.design.preference.NullableTextAdapter +import com.github.kr328.clash.design.preference.category +import com.github.kr328.clash.design.preference.clickable +import com.github.kr328.clash.design.preference.editableText +import com.github.kr328.clash.design.preference.preferenceScreen +import com.github.kr328.clash.design.preference.tips +import com.github.kr328.clash.design.store.UiStore +import com.github.kr328.clash.design.util.applyFrom +import com.github.kr328.clash.design.util.bindAppBarElevation +import com.github.kr328.clash.design.util.layoutInflater +import com.github.kr328.clash.design.util.root + +class WebDavSettingsDesign( + context: Context, + uiStore: UiStore, +) : Design(context) { + enum class Request { + SyncNow + } + + private val binding = DesignSettingsCommonBinding + .inflate(context.layoutInflater, context.root, false) + + override val root: View + get() = binding.root + + init { + binding.surface = surface + + binding.activityBarLayout.applyFrom(context) + + binding.scrollRoot.bindAppBarElevation(binding.activityBarLayout) + + val screen = preferenceScreen(context) { + tips(R.string.webdav_tips) + + category(R.string.webdav_server) + + editableText( + value = uiStore::webdavUrl, + adapter = NON_NULL_STRING, + title = R.string.webdav_server_url, + icon = R.drawable.ic_baseline_dns, + placeholder = R.string.webdav_not_configured, + ) + + editableText( + value = uiStore::webdavUsername, + adapter = NON_NULL_STRING, + title = R.string.webdav_username, + icon = R.drawable.ic_baseline_info, + placeholder = R.string.webdav_not_configured, + ) + + editableText( + value = uiStore::webdavPassword, + adapter = NON_NULL_STRING, + title = R.string.webdav_password, + icon = R.drawable.ic_baseline_key, + placeholder = R.string.webdav_not_configured, + ) + + category(R.string.webdav_actions) + + clickable( + title = R.string.webdav_pull_sync, + icon = R.drawable.ic_baseline_cloud_download, + summary = R.string.webdav_pull_sync_summary, + ) { + clicked { + requests.trySend(Request.SyncNow) + } + } + } + + binding.content.addView(screen.root) + } + + companion object { + // editableText needs a NullableTextAdapter matching the (non-null String) property. + // Show an empty value as the placeholder by mapping "" -> null on the way out. + private val NON_NULL_STRING = object : NullableTextAdapter { + override fun from(value: String): String? = value.ifEmpty { null } + override fun to(text: String?): String = text?.trim() ?: "" + } + } +} diff --git a/design/src/main/java/com/github/kr328/clash/design/store/UiStore.kt b/design/src/main/java/com/github/kr328/clash/design/store/UiStore.kt index ddc9d600fd..37a8f68cf9 100644 --- a/design/src/main/java/com/github/kr328/clash/design/store/UiStore.kt +++ b/design/src/main/java/com/github/kr328/clash/design/store/UiStore.kt @@ -78,6 +78,21 @@ class UiStore(context: Context) { defaultValue = false, ) + var webdavUrl: String by store.string( + key = "webdav_url", + defaultValue = "", + ) + + var webdavUsername: String by store.string( + key = "webdav_username", + defaultValue = "", + ) + + var webdavPassword: String by store.string( + key = "webdav_password", + defaultValue = "", + ) + companion object { private const val PREFERENCE_NAME = "ui" diff --git a/design/src/main/res/layout/design_settings.xml b/design/src/main/res/layout/design_settings.xml index 885ec4a5c8..1366b69062 100644 --- a/design/src/main/res/layout/design_settings.xml +++ b/design/src/main/res/layout/design_settings.xml @@ -56,6 +56,13 @@ app:icon="@drawable/ic_baseline_meta" app:text="@string/meta_features" /> + + diff --git a/design/src/main/res/values-zh/strings.xml b/design/src/main/res/values-zh/strings.xml index be62f50f54..ec5ca9af97 100644 --- a/design/src/main/res/values-zh/strings.xml +++ b/design/src/main/res/values-zh/strings.xml @@ -280,4 +280,21 @@ 启动 Clash 服务 停止 Clash 停止 Clash 服务 + + + WebDAV 同步 + WebDAV 服务器 + 同步 + 服务器地址 + 用户名 + 密码 + 未设置 + 立即拉取同步 + 下载最新的 Clash Verge 备份并导入其中的订阅 + 从 Clash Verge 的 WebDAV 备份同步订阅配置。请填入与 Clash Verge 相同的服务器地址、用户名和密码。仅导入订阅类配置,本地配置与增强配置会被跳过。 + 请先填写服务器地址、用户名和密码 + 正在从 WebDAV 同步… + WebDAV 服务器上未找到备份 + 同步失败:%1$s + 同步完成 · 新增 %1$d,已存在 %2$d,失败 %3$d,跳过本地 %4$d diff --git a/design/src/main/res/values/strings.xml b/design/src/main/res/values/strings.xml index 2fe54fd226..6eb1f74636 100644 --- a/design/src/main/res/values/strings.xml +++ b/design/src/main/res/values/strings.xml @@ -370,4 +370,21 @@ Start Clash service Stop Clash Stop Clash service + + + WebDAV Sync + WebDAV Server + Sync + Server URL + Username + Password + Not set + Pull and sync now + Download the latest Clash Verge backup and import its subscriptions + Syncs subscriptions from a Clash Verge WebDAV backup. Use the same server URL, username and password as Clash Verge. Only subscription profiles are imported; local and enhancement profiles are skipped. + Please fill in the server URL, username and password first + Syncing from WebDAV… + No backup found on the WebDAV server + Sync failed: %1$s + Sync done · added %1$d, existing %2$d, failed %3$d, skipped %4$d local diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 3190850eff..7dc8f63442 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -18,6 +18,8 @@ room = "2.4.2" multiprocess = "1.0.0" quickie = "1.11.0" androidx-activity-ktx = "1.9.0" +okhttp = "4.12.0" +snakeyaml = "2.2" [libraries] build-android = { module = "com.android.tools.build:gradle", version.ref = "agp" } @@ -43,5 +45,7 @@ kaidl-runtime = { module = "com.github.kr328.kaidl:kaidl-runtime", version.ref = rikkax-multiprocess = { module = "dev.rikka.rikkax.preference:multiprocess", version.ref = "multiprocess" } quickie-bundled = { group = "io.github.g00fy2.quickie", name = "quickie-bundled", version.ref = "quickie" } androidx-activity-ktx = { group = "androidx.activity", name = "activity-ktx", version.ref = "androidx-activity-ktx" } +okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" } +snakeyaml = { module = "org.yaml:snakeyaml", version.ref = "snakeyaml" } [plugins]