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>(.*?)(?:[\\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]