From 69a77afe49816df8c998924b856264374391df5a Mon Sep 17 00:00:00 2001 From: muzi-xiaoren <106290465+muzi-xiaoren@users.noreply.github.com> Date: Fri, 9 Oct 2026 16:00:49 +0800 Subject: [PATCH 1/2] feat: add WebDAV cloud sync (pull from Clash Verge backups) Add a "WebDAV Sync" settings screen that connects to the same WebDAV server Clash Verge backs up to, downloads the latest backup zip and applies its subscription profiles, like a restore in Clash Verge. - 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); remote (subscription) profiles are applied, local/enhancement profiles are skipped. - Sync mirrors Verge's restore, which replaces the whole profile list: the local list ends up holding exactly the backup's subscriptions and the profile Verge had selected becomes active. Existing subscriptions (matched by Verge uid, then URL, then name) are updated in place so their uuid and proxy selections survive; other local profiles are deleted. An empty backup is ignored. - A dialog shows progress and a per-subscription log while syncing. - Credentials stored in UiStore, EN/zh strings, SnakeYAML R8 keep rules. Pull-only (no upload), so it never changes desktop state. Co-Authored-By: Claude Opus 5.5 --- 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 | 187 ++++++++++++++++++ .../github/kr328/clash/sync/VergeBackup.kt | 68 +++++++ .../github/kr328/clash/sync/WebDavClient.kt | 109 ++++++++++ .../kr328/clash/design/SettingsDesign.kt | 2 +- .../clash/design/WebDavSettingsDesign.kt | 92 +++++++++ .../kr328/clash/design/dialog/SyncLog.kt | 61 ++++++ .../kr328/clash/design/store/UiStore.kt | 22 +++ .../src/main/res/layout/design_settings.xml | 7 + .../src/main/res/layout/dialog_sync_log.xml | 29 +++ design/src/main/res/values-zh/strings.xml | 27 +++ design/src/main/res/values/strings.xml | 27 +++ gradle/libs.versions.toml | 4 + 16 files changed, 651 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 create mode 100644 design/src/main/java/com/github/kr328/clash/design/dialog/SyncLog.kt create mode 100644 design/src/main/res/layout/dialog_sync_log.xml 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..46c95ddc56 --- /dev/null +++ b/app/src/main/java/com/github/kr328/clash/WebDavSettingsActivity.kt @@ -0,0 +1,187 @@ +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.dialog.ModelLogDialogScope +import com.github.kr328.clash.design.dialog.withModelLogDialog +import com.github.kr328.clash.design.ui.ToastDuration +import com.github.kr328.clash.service.model.Profile +import com.github.kr328.clash.sync.ParsedBackup +import com.github.kr328.clash.sync.VergeBackup +import com.github.kr328.clash.sync.WebDavClient +import com.github.kr328.clash.util.withProfile +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.isActive +import kotlinx.coroutines.selects.select +import kotlinx.coroutines.withContext +import java.util.UUID + +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 + } + + withModelLogDialog(getString(R.string.webdav_sync)) { + try { + log(getString(R.string.webdav_log_connecting, url)) + + // Download the newest backup off the WebDAV server and parse out its subscriptions. + val backup = withContext(Dispatchers.IO) { + val client = WebDavClient(url, user, pass) + val latest = client.listBackups().firstOrNull() ?: return@withContext null + latest to VergeBackup.parse(client.download(latest)) + } + + if (backup == null) { + log(getString(R.string.webdav_no_backup_found)) + return@withModelLogDialog + } + + log(getString(R.string.webdav_log_found_backup, backup.first)) + log(getString(R.string.webdav_log_parsed, backup.second.remotes.size, backup.second.skippedLocal)) + + // Replacing everything with an empty list would wipe the phone; treat it as a bad backup. + if (backup.second.remotes.isEmpty()) { + log(getString(R.string.webdav_log_empty_backup)) + return@withModelLogDialog + } + + applyBackup(backup.second) + } catch (e: Exception) { + log(getString(R.string.webdav_sync_failed, e.message ?: e.javaClass.simpleName)) + } + } + } + + /** + * Mirrors Clash Verge's restore, which replaces the whole profile list with the backup's: + * afterwards the local list holds exactly the backup's subscriptions, and the one Verge had + * selected becomes active. Subscriptions that already exist locally (matched by Verge uid, + * then URL, then name) are updated in place rather than recreated, so their uuid and proxy + * selections survive; every other local profile, of any type, is deleted. + */ + private suspend fun ModelLogDialogScope.applyBackup(parsed: ParsedBackup) { + val previouslySynced = decodeSynced(uiStore.webdavSyncedProfiles) + val synced = LinkedHashMap() + + var updated = 0 + var added = 0 + var removed = 0 + var failed = 0 + + withProfile { + val existing = queryAll() + val claimed = HashSet() + + fun match(uid: String, name: String, url: String): Profile? { + val free = existing.filter { it.type == Profile.Type.Url && it.uuid !in claimed } + + return free.firstOrNull { it.uuid == previouslySynced[uid] } + ?: free.firstOrNull { it.source == url } + ?: free.firstOrNull { it.name == name } + } + + parsed.remotes.forEachIndexed { index, remote -> + progress(index, parsed.remotes.size) + + val target = match(remote.uid, remote.name, remote.url) + val uuid = if (target != null) { + log(getString(R.string.webdav_log_updating, remote.name)) + patch(target.uuid, remote.name, remote.url, target.interval, target.ageSecretKey) + target.uuid + } else { + log(getString(R.string.webdav_log_adding, remote.name)) + create(Profile.Type.Url, remote.name, remote.url) + } + + claimed.add(uuid) + + try { + commit(uuid) + + synced[remote.uid] = uuid + + if (target != null) { + updated++ + log(getString(R.string.webdav_log_updated, remote.name)) + } else { + added++ + log(getString(R.string.webdav_log_added, remote.name)) + } + } catch (e: Exception) { + // Fetch/validation failed — drop the pending change; an existing profile + // keeps its last good config and stays synced. + try { + release(uuid) + } catch (_: Exception) { + } + + if (target != null) synced[remote.uid] = uuid + + failed++ + log(getString(R.string.webdav_log_item_failed, remote.name, e.message ?: e.javaClass.simpleName)) + } + } + + progress(parsed.remotes.size, parsed.remotes.size) + + for (profile in existing) { + if (profile.uuid in claimed) continue + + try { + delete(profile.uuid) + removed++ + log(getString(R.string.webdav_log_removed, profile.name)) + } catch (e: Exception) { + failed++ + log(getString(R.string.webdav_log_item_failed, profile.name, e.message ?: e.javaClass.simpleName)) + } + } + + val current = parsed.current?.let { synced[it] }?.let { queryByUUID(it) } + if (current != null && current.imported) { + setActive(current) + log(getString(R.string.webdav_log_activated, current.name)) + } + } + + uiStore.webdavSyncedProfiles = encodeSynced(synced) + + log(getString(R.string.webdav_log_done, updated, added, removed, failed)) + } + + private fun decodeSynced(text: String): Map { + return text.lineSequence().mapNotNull { line -> + val uid = line.substringBefore('\t') + val uuid = runCatching { UUID.fromString(line.substringAfter('\t')) }.getOrNull() + if (uid.isEmpty() || uuid == null) null else uid to uuid + }.toMap() + } + + private fun encodeSynced(synced: Map): String { + return synced.entries.joinToString("\n") { "${it.key}\t${it.value}" } + } +} 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..30ffb2c317 --- /dev/null +++ b/app/src/main/java/com/github/kr328/clash/sync/VergeBackup.kt @@ -0,0 +1,68 @@ +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, keyed by its Verge `uid`. */ +data class RemoteProfile(val uid: String, val name: String, val url: String) + +/** + * Result of parsing a backup: importable subscriptions, how many local configs were skipped, + * and the uid of the profile Verge had selected (`current`), if any. + */ +data class ParsedBackup(val remotes: List, val skippedLocal: Int, val current: String? = null) + +/** + * 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 current = (root["current"] as? String)?.trim()?.ifEmpty { null } + + 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 + val uid = (map["uid"] as? String)?.trim()?.ifEmpty { null } ?: url + remotes.add(RemoteProfile(uid, name, url)) + } + "local" -> skippedLocal++ + } + } + + return ParsedBackup(remotes, skippedLocal, current) + } + + 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/dialog/SyncLog.kt b/design/src/main/java/com/github/kr328/clash/design/dialog/SyncLog.kt new file mode 100644 index 0000000000..8680d040db --- /dev/null +++ b/design/src/main/java/com/github/kr328/clash/design/dialog/SyncLog.kt @@ -0,0 +1,61 @@ +package com.github.kr328.clash.design.dialog + +import android.content.Context +import android.content.DialogInterface +import android.view.View +import com.github.kr328.clash.design.databinding.DialogSyncLogBinding +import com.github.kr328.clash.design.util.layoutInflater +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +interface ModelLogDialogScope { + suspend fun log(line: CharSequence) + suspend fun progress(current: Int, max: Int) +} + +/** + * Shows a dialog with a progress bar and a running log while [block] runs. + * + * Unlike [withModelProgressBar] the dialog stays open afterwards, so the user can read + * the final log; it only becomes dismissible once [block] has finished. + */ +suspend fun Context.withModelLogDialog(title: CharSequence, block: suspend ModelLogDialogScope.() -> Unit) { + val view = DialogSyncLogBinding.inflate(this.layoutInflater) + val dialog = MaterialAlertDialogBuilder(this) + .setTitle(title) + .setCancelable(false) + .setView(view.root) + .setPositiveButton(android.R.string.ok, null) + .show() + val confirm = dialog.getButton(DialogInterface.BUTTON_POSITIVE) + confirm.isEnabled = false + + val scopeImpl = object : ModelLogDialogScope { + override suspend fun log(line: CharSequence) { + withContext(Dispatchers.Main) { + if (view.text.text.isNotEmpty()) view.text.append("\n") + view.text.append(line) + view.scroll.post { view.scroll.fullScroll(View.FOCUS_DOWN) } + } + } + + override suspend fun progress(current: Int, max: Int) { + withContext(Dispatchers.Main) { + view.progressIndicator.isIndeterminate = false + view.progressIndicator.max = max.coerceAtLeast(1) + view.progressIndicator.setProgressCompat(current, true) + } + } + } + + try { + scopeImpl.block() + } finally { + withContext(Dispatchers.Main) { + view.progressIndicator.visibility = View.GONE + dialog.setCancelable(true) + confirm.isEnabled = true + } + } +} 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..d94d4677ec 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,28 @@ 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 = "", + ) + + // Verge profile uid -> CMFA profile uuid of subscriptions created by WebDAV sync, + // one "uiduuid" pair per line. + var webdavSyncedProfiles: String by store.string( + key = "webdav_synced_profiles", + 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/layout/dialog_sync_log.xml b/design/src/main/res/layout/dialog_sync_log.xml new file mode 100644 index 0000000000..2c23a9f6c4 --- /dev/null +++ b/design/src/main/res/layout/dialog_sync_log.xml @@ -0,0 +1,29 @@ + + + + + + + + + + + + diff --git a/design/src/main/res/values-zh/strings.xml b/design/src/main/res/values-zh/strings.xml index be62f50f54..3a08eac77d 100644 --- a/design/src/main/res/values-zh/strings.xml +++ b/design/src/main/res/values-zh/strings.xml @@ -280,4 +280,31 @@ 启动 Clash 服务 停止 Clash 停止 Clash 服务 + + + WebDAV 同步 + WebDAV 服务器 + 同步 + 服务器地址 + 用户名 + 密码 + 未设置 + 立即拉取同步 + 下载最新的 Clash Verge 备份,用其中的订阅替换本地全部配置 + 从 Clash Verge 的 WebDAV 备份同步订阅配置。请填入与 Clash Verge 相同的服务器地址、用户名和密码。与 Clash Verge 的恢复一致:本地配置会被备份中的订阅整体替换(备份里没有的配置,包括本地文件配置,都会被删除),并切换到桌面端当前使用的配置。Clash Verge 的本地配置与增强配置不会被导入。 + 请先填写服务器地址、用户名和密码 + WebDAV 服务器上未找到备份 + 同步失败:%1$s + 正在连接 %1$s… + 最新备份:%1$s + 备份中有 %1$d 个订阅,跳过 %2$d 个本地配置 + 备份中没有订阅,本地配置保持不变 + 正在更新「%1$s」… + 正在新增「%1$s」… + ✓ 已更新「%1$s」 + ✓ 已新增「%1$s」 + ✗「%1$s」失败:%2$s + − 已删除「%1$s」(备份中没有) + 已切换到「%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..44e422bce5 100644 --- a/design/src/main/res/values/strings.xml +++ b/design/src/main/res/values/strings.xml @@ -370,4 +370,31 @@ 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 replace local profiles with its subscriptions + Syncs subscriptions from a Clash Verge WebDAV backup. Use the same server URL, username and password as Clash Verge. Works like a restore in Clash Verge: all local profiles are replaced by the backup\'s subscriptions (profiles not in the backup, including local files, are deleted) and the profile selected on the desktop becomes active. Local and enhancement profiles from Clash Verge are not imported. + Please fill in the server URL, username and password first + No backup found on the WebDAV server + Sync failed: %1$s + Connecting to %1$s… + Latest backup: %1$s + %1$d subscription(s) in backup, %2$d local profile(s) skipped + No subscriptions in the backup; local profiles left unchanged + Updating \"%1$s\"… + Adding \"%1$s\"… + ✓ Updated \"%1$s\" + ✓ Added \"%1$s\" + ✗ \"%1$s\" failed: %2$s + − Removed \"%1$s\" (not in the backup) + Switched to \"%1$s\" (selected on the desktop) + Done · updated %1$d, added %2$d, removed %3$d, failed %4$d 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] From 010a53923163bc44105a9e550a7eb834041a81b8 Mon Sep 17 00:00:00 2001 From: muzi-xiaoren <106290465+muzi-xiaoren@users.noreply.github.com> Date: Fri, 9 Oct 2026 17:03:10 +0800 Subject: [PATCH 2/2] feat: WebDAV test connection, cancellable sync and retry of failed subscriptions - "Test connection" lists the backup directory and reports the backup count and latest file, or an auth or network error. - The sync dialog's cancel button stops after the current subscription; a cancelled sync skips deleting and switching profiles. - When subscriptions fail, the dialog offers "Retry failed (N)", which re-applies only those subscriptions in the same dialog. Co-Authored-By: Claude Opus 5.5 --- .../kr328/clash/WebDavSettingsActivity.kt | 284 ++++++++++++++---- .../github/kr328/clash/sync/WebDavClient.kt | 9 +- .../clash/design/WebDavSettingsDesign.kt | 13 +- .../kr328/clash/design/dialog/SyncLog.kt | 103 ++++++- design/src/main/res/values-zh/strings.xml | 12 + design/src/main/res/values/strings.xml | 12 + 6 files changed, 349 insertions(+), 84 deletions(-) diff --git a/app/src/main/java/com/github/kr328/clash/WebDavSettingsActivity.kt b/app/src/main/java/com/github/kr328/clash/WebDavSettingsActivity.kt index 46c95ddc56..20c457bb54 100644 --- a/app/src/main/java/com/github/kr328/clash/WebDavSettingsActivity.kt +++ b/app/src/main/java/com/github/kr328/clash/WebDavSettingsActivity.kt @@ -2,13 +2,17 @@ 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.dialog.LogDialogRetry import com.github.kr328.clash.design.dialog.ModelLogDialogScope import com.github.kr328.clash.design.dialog.withModelLogDialog import com.github.kr328.clash.design.ui.ToastDuration import com.github.kr328.clash.service.model.Profile +import com.github.kr328.clash.service.remote.IProfileManager import com.github.kr328.clash.sync.ParsedBackup +import com.github.kr328.clash.sync.RemoteProfile 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 @@ -27,6 +31,7 @@ class WebDavSettingsActivity : BaseActivity() { events.onReceive { } design.requests.onReceive { when (it) { + WebDavSettingsDesign.Request.TestConnection -> testConnection(design) WebDavSettingsDesign.Request.SyncNow -> performSync(design) } } @@ -34,30 +39,55 @@ class WebDavSettingsActivity : BaseActivity() { } } - private suspend fun performSync(design: WebDavSettingsDesign) { + private suspend fun clientOrNull(design: WebDavSettingsDesign): WebDavClient? { 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 + return null + } + + return WebDavClient(url, user, pass) + } + + private suspend fun testConnection(design: WebDavSettingsDesign) { + val client = clientOrNull(design) ?: return + + design.showToast(R.string.webdav_testing_connection, ToastDuration.Short) + + val message = try { + val backups = withContext(Dispatchers.IO) { client.listBackups() } + + if (backups.isEmpty()) { + getString(R.string.webdav_test_no_backup) + } else { + getString(R.string.webdav_test_success, backups.size, backups.first()) + } + } catch (e: Exception) { + getString(R.string.webdav_test_failed, describe(e)) } + design.showToast(message, ToastDuration.Long) + } + + private suspend fun performSync(design: WebDavSettingsDesign) { + val client = clientOrNull(design) ?: return + withModelLogDialog(getString(R.string.webdav_sync)) { try { - log(getString(R.string.webdav_log_connecting, url)) + log(getString(R.string.webdav_log_connecting, uiStore.webdavUrl.trim())) // Download the newest backup off the WebDAV server and parse out its subscriptions. val backup = withContext(Dispatchers.IO) { - val client = WebDavClient(url, user, pass) val latest = client.listBackups().firstOrNull() ?: return@withContext null latest to VergeBackup.parse(client.download(latest)) } if (backup == null) { log(getString(R.string.webdav_no_backup_found)) - return@withModelLogDialog + return@withModelLogDialog null } log(getString(R.string.webdav_log_found_backup, backup.first)) @@ -66,12 +96,18 @@ class WebDavSettingsActivity : BaseActivity() { // Replacing everything with an empty list would wipe the phone; treat it as a bad backup. if (backup.second.remotes.isEmpty()) { log(getString(R.string.webdav_log_empty_backup)) - return@withModelLogDialog + return@withModelLogDialog null + } + + if (isCancelRequested) { + log(getString(R.string.webdav_log_cancelled)) + return@withModelLogDialog null } applyBackup(backup.second) } catch (e: Exception) { - log(getString(R.string.webdav_sync_failed, e.message ?: e.javaClass.simpleName)) + log(getString(R.string.webdav_sync_failed, describe(e))) + null } } } @@ -82,95 +118,211 @@ class WebDavSettingsActivity : BaseActivity() { * selected becomes active. Subscriptions that already exist locally (matched by Verge uid, * then URL, then name) are updated in place rather than recreated, so their uuid and proxy * selections survive; every other local profile, of any type, is deleted. + * + * Subscriptions are applied one at a time and the deletion only runs once all of them have + * been processed, so a cancelled sync stops after the current subscription and leaves every + * other local profile untouched. */ - private suspend fun ModelLogDialogScope.applyBackup(parsed: ParsedBackup) { + private suspend fun ModelLogDialogScope.applyBackup(parsed: ParsedBackup): LogDialogRetry? { val previouslySynced = decodeSynced(uiStore.webdavSyncedProfiles) val synced = LinkedHashMap() - var updated = 0 - var added = 0 var removed = 0 - var failed = 0 - withProfile { + val result = withProfile { val existing = queryAll() val claimed = HashSet() + val applied = applyRemotes(this, parsed.remotes, existing, previouslySynced, synced, claimed) - fun match(uid: String, name: String, url: String): Profile? { - val free = existing.filter { it.type == Profile.Type.Url && it.uuid !in claimed } + if (applied.cancelled) return@withProfile applied - return free.firstOrNull { it.uuid == previouslySynced[uid] } - ?: free.firstOrNull { it.source == url } - ?: free.firstOrNull { it.name == name } + for (profile in existing) { + if (profile.uuid in claimed) continue + + try { + delete(profile.uuid) + removed++ + log(getString(R.string.webdav_log_removed, profile.name)) + } catch (e: Exception) { + log(getString(R.string.webdav_log_item_failed, profile.name, describe(e))) + } } - parsed.remotes.forEachIndexed { index, remote -> - progress(index, parsed.remotes.size) + activateCurrent(this, parsed, synced) - val target = match(remote.uid, remote.name, remote.url) - val uuid = if (target != null) { - log(getString(R.string.webdav_log_updating, remote.name)) - patch(target.uuid, remote.name, remote.url, target.interval, target.ageSecretKey) - target.uuid - } else { - log(getString(R.string.webdav_log_adding, remote.name)) - create(Profile.Type.Url, remote.name, remote.url) - } + applied + } - claimed.add(uuid) + if (result.cancelled) { + // Subscriptions not reached yet keep their old mapping for the next sync. + uiStore.webdavSyncedProfiles = encodeSynced(previouslySynced + synced) - try { - commit(uuid) + log(getString(R.string.webdav_log_cancelled)) + log(getString(R.string.webdav_log_done, result.updated, result.added, 0, result.failed.size)) - synced[remote.uid] = uuid + return null + } - if (target != null) { - updated++ - log(getString(R.string.webdav_log_updated, remote.name)) - } else { - added++ - log(getString(R.string.webdav_log_added, remote.name)) - } - } catch (e: Exception) { - // Fetch/validation failed — drop the pending change; an existing profile - // keeps its last good config and stays synced. - try { - release(uuid) - } catch (_: Exception) { - } + uiStore.webdavSyncedProfiles = encodeSynced(synced) - if (target != null) synced[remote.uid] = uuid + log(getString(R.string.webdav_log_done, result.updated, result.added, removed, result.failed.size)) - failed++ - log(getString(R.string.webdav_log_item_failed, remote.name, e.message ?: e.javaClass.simpleName)) - } + return retryOf(parsed, result.failed, synced) + } + + /** Re-applies only [failed]; the rest of the profile list was already settled by the sync. */ + private suspend fun ModelLogDialogScope.retryFailed( + parsed: ParsedBackup, + failed: List, + synced: LinkedHashMap, + ): LogDialogRetry? { + log(getString(R.string.webdav_log_retrying, failed.size)) + + val retried = failed.map { it.uid }.toSet() + + val result = withProfile { + // Profiles belonging to other subscriptions must not be matched by a retried one. + val claimed = synced.filterKeys { it !in retried }.values.toHashSet() + val applied = applyRemotes(this, failed, queryAll(), synced, synced, claimed) + + val current = parsed.current + if (current != null && current in applied.done && applied.failed.none { it.uid == current }) { + activateCurrent(this, parsed, synced) } - progress(parsed.remotes.size, parsed.remotes.size) + applied + } - for (profile in existing) { - if (profile.uuid in claimed) continue + uiStore.webdavSyncedProfiles = encodeSynced(synced) + val succeeded = result.updated + result.added + if (result.cancelled) log(getString(R.string.webdav_log_cancelled)) + log(getString(R.string.webdav_log_retry_done, succeeded, result.failed.size)) + + // After a cancel, the subscriptions not reached yet are offered again too. + val left = result.failed + failed.filter { it.uid !in result.done } + + return retryOf(parsed, left, synced) + } + + private fun retryOf( + parsed: ParsedBackup, + failed: List, + synced: LinkedHashMap, + ): LogDialogRetry? { + if (failed.isEmpty()) return null + + return LogDialogRetry(getString(R.string.webdav_retry_failed, failed.size)) { + try { + retryFailed(parsed, failed, synced) + } catch (e: Exception) { + log(getString(R.string.webdav_sync_failed, describe(e))) + retryOf(parsed, failed, synced) + } + } + } + + private class ApplyResult( + val updated: Int, + val added: Int, + val failed: List, + val done: Set, + val cancelled: Boolean, + ) + + /** + * Updates or creates a profile for each of [remotes], in order, recording the result in + * [synced] and [claimed]. Checks for cancel between subscriptions, never in the middle of one. + */ + private suspend fun ModelLogDialogScope.applyRemotes( + profiles: IProfileManager, + remotes: List, + existing: List, + known: Map, + synced: MutableMap, + claimed: MutableSet, + ): ApplyResult { + var updated = 0 + var added = 0 + val failed = ArrayList() + val done = HashSet() + + fun match(remote: RemoteProfile): Profile? { + val free = existing.filter { it.type == Profile.Type.Url && it.uuid !in claimed } + + return free.firstOrNull { it.uuid == known[remote.uid] } + ?: free.firstOrNull { it.source == remote.url } + ?: free.firstOrNull { it.name == remote.name } + } + + for ((index, remote) in remotes.withIndex()) { + if (isCancelRequested) { + return ApplyResult(updated, added, failed, done, cancelled = true) + } + + progress(index, remotes.size) + + val target = match(remote) + val uuid = if (target != null) { + log(getString(R.string.webdav_log_updating, remote.name)) + profiles.patch(target.uuid, remote.name, remote.url, target.interval, target.ageSecretKey) + target.uuid + } else { + log(getString(R.string.webdav_log_adding, remote.name)) + profiles.create(Profile.Type.Url, remote.name, remote.url) + } + + claimed.add(uuid) + done.add(remote.uid) + + try { + profiles.commit(uuid) + + synced[remote.uid] = uuid + + if (target != null) { + updated++ + log(getString(R.string.webdav_log_updated, remote.name)) + } else { + added++ + log(getString(R.string.webdav_log_added, remote.name)) + } + } catch (e: Exception) { + // Fetch/validation failed — drop the pending change; an existing profile + // keeps its last good config and stays synced. try { - delete(profile.uuid) - removed++ - log(getString(R.string.webdav_log_removed, profile.name)) - } catch (e: Exception) { - failed++ - log(getString(R.string.webdav_log_item_failed, profile.name, e.message ?: e.javaClass.simpleName)) + profiles.release(uuid) + } catch (_: Exception) { } - } - val current = parsed.current?.let { synced[it] }?.let { queryByUUID(it) } - if (current != null && current.imported) { - setActive(current) - log(getString(R.string.webdav_log_activated, current.name)) + if (target != null) synced[remote.uid] = uuid else synced.remove(remote.uid) + + failed.add(remote) + log(getString(R.string.webdav_log_item_failed, remote.name, describe(e))) } } - uiStore.webdavSyncedProfiles = encodeSynced(synced) + progress(remotes.size, remotes.size) + + return ApplyResult(updated, added, failed, done, cancelled = false) + } + + private suspend fun ModelLogDialogScope.activateCurrent( + profiles: IProfileManager, + parsed: ParsedBackup, + synced: Map, + ) { + val current = parsed.current?.let { synced[it] }?.let { profiles.queryByUUID(it) } + if (current != null && current.imported) { + profiles.setActive(current) + log(getString(R.string.webdav_log_activated, current.name)) + } + } + + private fun describe(e: Exception): String { + if (e is WebDavException && e.isUnauthorized) return getString(R.string.webdav_unauthorized) - log(getString(R.string.webdav_log_done, updated, added, removed, failed)) + return e.message ?: e.javaClass.simpleName } private fun decodeSynced(text: String): Map { 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 index 6191348c5e..def4cec2e1 100644 --- a/app/src/main/java/com/github/kr328/clash/sync/WebDavClient.kt +++ b/app/src/main/java/com/github/kr328/clash/sync/WebDavClient.kt @@ -49,7 +49,7 @@ class WebDavClient( // 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}") + throw WebDavException("PROPFIND failed: HTTP ${resp.code}", resp.code) } return parseZipHrefs(resp.body?.string().orEmpty()) } @@ -65,7 +65,7 @@ class WebDavClient( .build() client.newCall(request).execute().use { resp -> - if (!resp.isSuccessful) throw WebDavException("Download failed: HTTP ${resp.code}") + if (!resp.isSuccessful) throw WebDavException("Download failed: HTTP ${resp.code}", resp.code) return resp.body?.bytes() ?: throw WebDavException("Empty response body") } } @@ -106,4 +106,7 @@ class WebDavClient( } } -class WebDavException(message: String) : Exception(message) +class WebDavException(message: String, val code: Int = 0) : Exception(message) { + val isUnauthorized: Boolean + get() = code == 401 || code == 403 +} 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 index 6f7a3ace34..927197f641 100644 --- a/design/src/main/java/com/github/kr328/clash/design/WebDavSettingsDesign.kt +++ b/design/src/main/java/com/github/kr328/clash/design/WebDavSettingsDesign.kt @@ -20,7 +20,8 @@ class WebDavSettingsDesign( uiStore: UiStore, ) : Design(context) { enum class Request { - SyncNow + TestConnection, + SyncNow, } private val binding = DesignSettingsCommonBinding @@ -65,6 +66,16 @@ class WebDavSettingsDesign( placeholder = R.string.webdav_not_configured, ) + clickable( + title = R.string.webdav_test_connection, + icon = R.drawable.ic_outline_check_circle, + summary = R.string.webdav_test_connection_summary, + ) { + clicked { + requests.trySend(Request.TestConnection) + } + } + category(R.string.webdav_actions) clickable( diff --git a/design/src/main/java/com/github/kr328/clash/design/dialog/SyncLog.kt b/design/src/main/java/com/github/kr328/clash/design/dialog/SyncLog.kt index 8680d040db..97a5917bb8 100644 --- a/design/src/main/java/com/github/kr328/clash/design/dialog/SyncLog.kt +++ b/design/src/main/java/com/github/kr328/clash/design/dialog/SyncLog.kt @@ -3,41 +3,86 @@ package com.github.kr328.clash.design.dialog import android.content.Context import android.content.DialogInterface import android.view.View +import com.github.kr328.clash.design.R import com.github.kr328.clash.design.databinding.DialogSyncLogBinding import com.github.kr328.clash.design.util.layoutInflater import com.google.android.material.dialog.MaterialAlertDialogBuilder +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.withContext +import java.util.concurrent.atomic.AtomicBoolean interface ModelLogDialogScope { + /** Set once the user taps cancel; long-running blocks should stop at the next safe point. */ + val isCancelRequested: Boolean + suspend fun log(line: CharSequence) suspend fun progress(current: Int, max: Int) } +/** A follow-up run the dialog offers once a run has finished, e.g. retrying the failed items. */ +class LogDialogRetry( + val label: CharSequence, + val block: suspend ModelLogDialogScope.() -> LogDialogRetry?, +) + /** * Shows a dialog with a progress bar and a running log while [block] runs. * * Unlike [withModelProgressBar] the dialog stays open afterwards, so the user can read - * the final log; it only becomes dismissible once [block] has finished. + * the final log; it only becomes dismissible once [block] has finished. While running, + * a cancel button sets [ModelLogDialogScope.isCancelRequested]; the block decides where + * to stop. If the block returns a [LogDialogRetry], a button offers to run it in the + * same dialog, which may in turn offer another retry. */ -suspend fun Context.withModelLogDialog(title: CharSequence, block: suspend ModelLogDialogScope.() -> Unit) { +suspend fun Context.withModelLogDialog( + title: CharSequence, + block: suspend ModelLogDialogScope.() -> LogDialogRetry?, +) { val view = DialogSyncLogBinding.inflate(this.layoutInflater) val dialog = MaterialAlertDialogBuilder(this) .setTitle(title) .setCancelable(false) .setView(view.root) .setPositiveButton(android.R.string.ok, null) + .setNegativeButton(android.R.string.cancel, null) + .setNeutralButton(" ", null) .show() val confirm = dialog.getButton(DialogInterface.BUTTON_POSITIVE) - confirm.isEnabled = false + val cancel = dialog.getButton(DialogInterface.BUTTON_NEGATIVE) + val retry = dialog.getButton(DialogInterface.BUTTON_NEUTRAL) + + // true = retry tapped, false = dialog dismissed + val choices = Channel(Channel.UNLIMITED) + + // Read by the block on a background thread. + val cancelRequested = AtomicBoolean(false) + + fun append(line: CharSequence) { + if (view.text.text.isNotEmpty()) view.text.append("\n") + view.text.append(line) + view.scroll.post { view.scroll.fullScroll(View.FOCUS_DOWN) } + } + + // Buttons set via the builder dismiss the dialog on click; override them so they don't. + cancel.setOnClickListener { + cancelRequested.set(true) + cancel.isEnabled = false + append(getString(R.string.sync_log_stopping)) + } + retry.setOnClickListener { + retry.visibility = View.GONE + choices.trySend(true) + } + dialog.setOnDismissListener { choices.trySend(false) } val scopeImpl = object : ModelLogDialogScope { + override val isCancelRequested: Boolean + get() = cancelRequested.get() + override suspend fun log(line: CharSequence) { - withContext(Dispatchers.Main) { - if (view.text.text.isNotEmpty()) view.text.append("\n") - view.text.append(line) - view.scroll.post { view.scroll.fullScroll(View.FOCUS_DOWN) } - } + withContext(Dispatchers.Main) { append(line) } } override suspend fun progress(current: Int, max: Int) { @@ -50,12 +95,42 @@ suspend fun Context.withModelLogDialog(title: CharSequence, block: suspend Model } try { - scopeImpl.block() - } finally { - withContext(Dispatchers.Main) { - view.progressIndicator.visibility = View.GONE - dialog.setCancelable(true) - confirm.isEnabled = true + var step: (suspend ModelLogDialogScope.() -> LogDialogRetry?)? = block + + while (step != null) { + withContext(Dispatchers.Main) { + cancelRequested.set(false) + view.progressIndicator.isIndeterminate = true + view.progressIndicator.visibility = View.VISIBLE + dialog.setCancelable(false) + confirm.isEnabled = false + cancel.isEnabled = true + cancel.visibility = View.VISIBLE + retry.visibility = View.GONE + } + + val next = try { + scopeImpl.step() + } finally { + withContext(Dispatchers.Main) { + view.progressIndicator.visibility = View.GONE + cancel.visibility = View.GONE + dialog.setCancelable(true) + confirm.isEnabled = true + } + } + + if (next == null) break + + withContext(Dispatchers.Main) { + retry.text = next.label + retry.visibility = View.VISIBLE + } + + step = if (choices.receive()) next.block else null } + } catch (e: CancellationException) { + dialog.dismiss() + throw e } } diff --git a/design/src/main/res/values-zh/strings.xml b/design/src/main/res/values-zh/strings.xml index 3a08eac77d..0423587bc1 100644 --- a/design/src/main/res/values-zh/strings.xml +++ b/design/src/main/res/values-zh/strings.xml @@ -307,4 +307,16 @@ − 已删除「%1$s」(备份中没有) 已切换到「%1$s」(桌面端当前使用) 完成 · 更新 %1$d,新增 %2$d,删除 %3$d,失败 %4$d + 已取消 · 备份中没有的本地配置已保留,未切换配置 + 正在重试 %1$d 个失败的订阅… + 重试完成 · 成功 %1$d,失败 %2$d + 重试失败项(%1$d) + 测试连接 + 检查服务器地址和账号密码,并查找 Clash Verge 备份 + 正在测试连接… + 连接成功 · 共 %1$d 个备份,最新:%2$s + 连接成功,但服务器上还没有 Clash Verge 备份 + 连接失败:%1$s + 认证失败,请检查用户名和密码 + 正在停止,当前项完成后结束… diff --git a/design/src/main/res/values/strings.xml b/design/src/main/res/values/strings.xml index 92a669ff31..b1f2281da3 100644 --- a/design/src/main/res/values/strings.xml +++ b/design/src/main/res/values/strings.xml @@ -398,4 +398,16 @@ − Removed \"%1$s\" (not in the backup) Switched to \"%1$s\" (selected on the desktop) Done · updated %1$d, added %2$d, removed %3$d, failed %4$d + Cancelled · local profiles not in the backup were kept, nothing was switched + Retrying %1$d failed subscription(s)… + Retry done · succeeded %1$d, failed %2$d + Retry failed (%1$d) + Test connection + Check the server address and credentials, and look for Clash Verge backups + Testing connection… + Connected · %1$d backup(s), latest: %2$s + Connected, but there is no Clash Verge backup on the server yet + Connection failed: %1$s + authentication failed, check the username and password + Stopping after the current item…