Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
9 changes: 8 additions & 1 deletion app/proguard-rules.pro
Original file line number Diff line number Diff line change
Expand Up @@ -56,4 +56,11 @@
boolean getASSERTIONS_ENABLED() return false;
boolean getDEBUG() return false;
boolean getRECOVER_STACK_TRACES() return false;
}
}

# 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.**
5 changes: 5 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,11 @@
android:configChanges="uiMode"
android:exported="false"
android:label="@string/meta_features" />
<activity
android:name=".WebDavSettingsActivity"
android:configChanges="uiMode"
android:exported="false"
android:label="@string/webdav_sync" />
<activity
android:name=".AccessControlActivity"
android:configChanges="uiMode"
Expand Down
2 changes: 2 additions & 0 deletions app/src/main/java/com/github/kr328/clash/SettingsActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ class SettingsActivity : BaseActivity<SettingsDesign>() {
startActivity(OverrideSettingsActivity::class.intent)
SettingsDesign.Request.StartMetaFeature ->
startActivity(MetaFeatureSettingsActivity::class.intent)
SettingsDesign.Request.StartWebDav ->
startActivity(WebDavSettingsActivity::class.intent)
}
}
}
Expand Down
107 changes: 107 additions & 0 deletions app/src/main/java/com/github/kr328/clash/WebDavSettingsActivity.kt
Original file line number Diff line number Diff line change
@@ -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<WebDavSettingsDesign>() {
override suspend fun main() {
val design = WebDavSettingsDesign(this, uiStore)

setContentDesign(design)

while (isActive) {
select<Unit> {
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,
)
}
}
63 changes: 63 additions & 0 deletions app/src/main/java/com/github/kr328/clash/sync/VergeBackup.kt
Original file line number Diff line number Diff line change
@@ -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<RemoteProfile>, 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<Any?>(profilesYaml) as? Map<String, Any?>
?: return ParsedBackup(emptyList(), 0)
val items = root["items"] as? List<*> ?: return ParsedBackup(emptyList(), 0)

val remotes = ArrayList<RemoteProfile>()
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
}
}
109 changes: 109 additions & 0 deletions app/src/main/java/com/github/kr328/clash/sync/WebDavClient.kt
Original file line number Diff line number Diff line change
@@ -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 "<os>-backup-YYYY-MM-DD_HH-MM-SS.zip", so a descending
* lexical sort puts the most recent backup first.
*/
fun listBackups(): List<String> {
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<String> {
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"

// <d:href>, <D:href> or <href> — capture the inner path, namespace prefix optional.
private val HREF_REGEX =
Regex("<(?:[\\w-]+:)?href>(.*?)</(?:[\\w-]+:)?href>", RegexOption.IGNORE_CASE)

private const val PROPFIND_BODY =
"<?xml version=\"1.0\" encoding=\"utf-8\"?>" +
"<d:propfind xmlns:d=\"DAV:\"><d:prop>" +
"<d:displayname/><d:getlastmodified/></d:prop></d:propfind>"
}
}

class WebDavException(message: String) : Exception(message)
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import com.github.kr328.clash.design.util.root

class SettingsDesign(context: Context) : Design<SettingsDesign.Request>(context) {
enum class Request {
StartApp, StartNetwork, StartOverride, StartMetaFeature,
StartApp, StartNetwork, StartOverride, StartMetaFeature, StartWebDav,
}

private val binding = DesignSettingsBinding
Expand Down
Loading