feat(sync): Add WebDAV synchronization service (#1104)

* Add WebMAV synchronization service

* extract custom client builder

* validation

* FIX: If the subfolder does not exist, create it first.

* FIX: Handle existing folders more leniently.

* refactor

* Fix strings

* lint & comment

* Default file name & log

---------

Co-authored-by: Cuong-Tran <16017808+cuong-tran@users.noreply.github.com>
This commit is contained in:
Kudomaga 2026-02-03 16:36:57 +08:00 committed by GitHub
parent e7bdbb5794
commit 696908f94b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 278 additions and 0 deletions

View file

@ -19,6 +19,13 @@ class SyncPreferences(
fun syncInterval() = preferenceStore.getInt("sync_interval", 0)
fun syncService() = preferenceStore.getInt("sync_service", 0)
// KMK -->
fun webDavUrl() = preferenceStore.getString("webdav_url", "")
fun webDavUsername() = preferenceStore.getString("webdav_username", "")
fun webDavPassword() = preferenceStore.getString("webdav_password", "")
fun webDavFolder() = preferenceStore.getString("webdav_folder", "komikku")
// KMK <--
fun googleDriveAccessToken() = preferenceStore.getString(
Preference.appStateKey("google_drive_access_token"),
"",

View file

@ -561,6 +561,9 @@ object SettingsDataScreen : SearchableSettings {
SyncManager.SyncService.NONE.value to stringResource(MR.strings.off),
SyncManager.SyncService.SYNCYOMI.value to stringResource(SYMR.strings.syncyomi),
SyncManager.SyncService.GOOGLE_DRIVE.value to stringResource(SYMR.strings.google_drive),
// KMK -->
SyncManager.SyncService.WebDAV.value to stringResource(KMR.strings.web_dav),
// KMK <--
),
title = stringResource(SYMR.strings.pref_sync_service),
onValueChanged = {
@ -602,6 +605,9 @@ object SettingsDataScreen : SearchableSettings {
SyncManager.SyncService.NONE -> emptyList()
SyncManager.SyncService.SYNCYOMI -> getSelfHostPreferences(syncPreferences)
SyncManager.SyncService.GOOGLE_DRIVE -> getGoogleDrivePreferences()
// KMK -->
SyncManager.SyncService.WebDAV -> getWebDavPreferences(syncPreferences)
// KMK <--
}
return if (syncServiceType != SyncManager.SyncService.NONE) {
@ -774,6 +780,60 @@ object SettingsDataScreen : SearchableSettings {
)
}
// KMK -->
@Composable
private fun getWebDavPreferences(syncPreferences: SyncPreferences): List<Preference> {
val scope = rememberCoroutineScope()
return listOf(
Preference.PreferenceItem.EditTextPreference(
preference = syncPreferences.webDavUrl(),
title = stringResource(KMR.strings.pref_webdav_url),
subtitle = stringResource(KMR.strings.pref_webdav_url_summ),
onValueChanged = { newValue ->
scope.launch {
syncPreferences.webDavUrl().set(newValue.trim())
}
true
},
),
Preference.PreferenceItem.EditTextPreference(
preference = syncPreferences.webDavUsername(),
title = stringResource(KMR.strings.pref_webdav_username),
subtitle = stringResource(KMR.strings.pref_webdav_username_summ),
onValueChanged = { newValue ->
scope.launch {
syncPreferences.webDavUsername().set(newValue.trim())
}
true
},
),
Preference.PreferenceItem.EditTextPreference(
preference = syncPreferences.webDavPassword(),
title = stringResource(KMR.strings.pref_webdav_password),
subtitle = stringResource(KMR.strings.pref_webdav_password_summ),
onValueChanged = { newValue ->
scope.launch {
syncPreferences.webDavPassword().set(newValue)
}
true
},
),
Preference.PreferenceItem.EditTextPreference(
preference = syncPreferences.webDavFolder(),
title = stringResource(KMR.strings.pref_webdav_folder),
subtitle = stringResource(KMR.strings.pref_webdav_folder_summ),
onValueChanged = { newValue ->
scope.launch {
syncPreferences.webDavFolder().set(newValue.trim())
}
true
},
),
)
}
// MK <--
@Composable
private fun getSyncNowPref(): Preference.PreferenceGroup {
val context = LocalContext.current

View file

@ -14,6 +14,7 @@ import eu.kanade.tachiyomi.data.backup.restore.restorers.MangaRestorer
import eu.kanade.tachiyomi.data.sync.service.GoogleDriveSyncService
import eu.kanade.tachiyomi.data.sync.service.SyncData
import eu.kanade.tachiyomi.data.sync.service.SyncYomiSyncService
import eu.kanade.tachiyomi.data.sync.service.WebDavSyncService
import kotlinx.serialization.json.Json
import kotlinx.serialization.protobuf.ProtoBuf
import logcat.LogPriority
@ -55,6 +56,9 @@ class SyncManager(
NONE(0),
SYNCYOMI(1),
GOOGLE_DRIVE(2),
// KMK -->
WebDAV(3),
// KMK <--
;
companion object {
@ -137,6 +141,12 @@ class SyncManager(
GoogleDriveSyncService(context, json, syncPreferences)
}
// KMK -->
SyncService.WebDAV -> {
WebDavSyncService(context, json, syncPreferences, notifier)
}
// KMK <--
else -> {
logcat(LogPriority.ERROR) { "Invalid sync service type: $syncService" }
null

View file

@ -0,0 +1,192 @@
package eu.kanade.tachiyomi.data.sync.service
import android.content.Context
import eu.kanade.domain.sync.SyncPreferences
import eu.kanade.tachiyomi.data.backup.models.Backup
import eu.kanade.tachiyomi.data.sync.SyncNotifier
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.PUT
import eu.kanade.tachiyomi.network.await
import exh.log.xLogD
import exh.log.xLogE
import exh.log.xLogI
import exh.log.xLogW
import kotlinx.serialization.json.Json
import kotlinx.serialization.protobuf.ProtoBuf
import okhttp3.ConnectionPool
import okhttp3.Credentials
import okhttp3.Headers
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import org.apache.http.HttpStatus
import tachiyomi.core.common.i18n.stringResource
import tachiyomi.i18n.MR
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import java.util.concurrent.TimeUnit
class WebDavSyncService(
context: Context,
json: Json,
syncPreferences: SyncPreferences,
private val notifier: SyncNotifier,
private val protoBuf: ProtoBuf = Injekt.get(),
) : SyncService(context, json, syncPreferences) {
private class WebDavException(message: String?) : Exception(message)
private val url: String = syncPreferences.webDavUrl().get().trim()
private val folder: String = syncPreferences.webDavFolder().get().trim('/')
private val username: String = syncPreferences.webDavUsername().get().trim()
private val password: String = syncPreferences.webDavPassword().get().trim()
private val credentials: String = Credentials.basic(username, password)
private fun buildWebDavFileUrl(fileName: String = "backup.proto"): String {
val cleanBase = url.trimEnd('/')
return if (folder.isNotEmpty()) "$cleanBase/$folder/$fileName" else "$cleanBase/$fileName"
}
private val client: OkHttpClient by lazy {
OkHttpClient.Builder()
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.writeTimeout(30, TimeUnit.SECONDS)
.connectionPool(ConnectionPool(5, 5, TimeUnit.MINUTES))
.build()
}
private fun validateSettings(): Boolean {
if (url.isEmpty() || !url.startsWith("http")) {
notifier.showSyncError("Invalid WebDAV URL. Please check your settings.")
return false
}
if (username.isBlank() || password.isBlank()) {
notifier.showSyncError("Username or password cannot be empty.")
return false
}
return true
}
private suspend fun ensureFolderExists() {
if (folder.isEmpty()) return
if (!validateSettings()) return
val baseUrl = url.trimEnd('/')
val folderParts = folder.split('/').filter { it.isNotEmpty() }
if (folderParts.isEmpty()) return
var currentPath = baseUrl
for (part in folderParts) {
currentPath = "$currentPath/$part"
if (!createSingleFolder(currentPath)) {
throw WebDavException("Failed to create folder: $currentPath")
}
}
}
private suspend fun createSingleFolder(folderUrl: String): Boolean {
val request = Request.Builder()
.url(folderUrl)
.method("MKCOL", null)
.header("Authorization", credentials)
.header("Content-Length", "0")
.build()
val response = client.newCall(request).await()
val success = response.isSuccessful || response.code == 405 || response.code == 409
response.close()
return success
}
override suspend fun doSync(syncData: SyncData): Backup? {
ensureFolderExists()
try {
val (remoteData, etag) = pullSyncData()
val finalSyncData = if (remoteData != null) {
assert(etag.isNotEmpty()) { "ETag should never be empty if remote data is not null" }
xLogD("Try update remote data with ETag(%s)", etag)
mergeSyncData(syncData, remoteData)
} else {
xLogD("Try overwrite remote data with ETag(%s)", etag)
syncData
}
pushSyncData(finalSyncData, etag)
return finalSyncData.backup
} catch (e: Exception) {
xLogE("WebDAV sync error:", e)
notifier.showSyncError(e.message)
return null
}
}
private suspend fun pullSyncData(): Pair<SyncData?, String> {
if (!validateSettings()) return Pair(null, "")
val lastETag = syncPreferences.lastSyncEtag().get()
val headersBuilder = Headers.Builder().add("Authorization", credentials)
if (lastETag.isNotEmpty()) headersBuilder.add("If-None-Match", lastETag)
val requestUrl = buildWebDavFileUrl()
val request = GET(requestUrl, headers = headersBuilder.build())
val response = client.newCall(request).await()
return when (response.code) {
HttpStatus.SC_NOT_MODIFIED -> Pair(null, lastETag)
HttpStatus.SC_NOT_FOUND -> Pair(null, "")
else -> {
if (response.isSuccessful) {
val newETag = response.headers["ETag"]?.trim('"') ?: ""
val bytes = response.body.byteStream().use { it.readBytes() }
try {
val backup = protoBuf.decodeFromByteArray(Backup.serializer(), bytes)
Pair(SyncData(backup = backup), newETag)
} catch (e: Exception) {
xLogE("Invalid backup format:", e)
Pair(null, "")
}
} else {
val body = response.body.string()
throw WebDavException("Failed to download: $body")
}
}
}
}
private suspend fun pushSyncData(syncData: SyncData, eTag: String) {
val backup = syncData.backup ?: return
if (!validateSettings()) return
val byteArray = protoBuf.encodeToByteArray(Backup.serializer(), backup)
if (byteArray.isEmpty()) throw IllegalStateException(context.stringResource(MR.strings.empty_backup_error))
val body = byteArray.toRequestBody("application/octet-stream".toMediaType())
val headersBuilder = Headers.Builder().add("Authorization", credentials)
if (eTag.isNotEmpty()) headersBuilder.add("If-Match", eTag)
val requestUrl = buildWebDavFileUrl()
val request = PUT(requestUrl, headers = headersBuilder.build(), body = body)
val response = client.newCall(request).await()
when {
response.isSuccessful -> {
val newETag = response.headers["ETag"]?.trim('"') ?: ""
if (newETag.isNotEmpty()) syncPreferences.lastSyncEtag().set(newETag)
xLogI("WebDAV sync completed")
}
response.code == HttpStatus.SC_PRECONDITION_FAILED -> {
val message = "Sync conflict detected. Another device may have updated the remote backup. " +
"Please retry syncing or check your WebDAV folder."
xLogW(message)
notifier.showSyncError(message)
}
else -> {
val bodyStr = response.body.string()
throw WebDavException("Upload failed: $bodyStr")
}
}
}
}

View file

@ -118,6 +118,15 @@
<!-- Sync section -->
<string name="pref_show_restoring_progress_banner">Show restoring progress banner</string>
<string name="pref_show_syncing_progress_banner">Show syncing progress banner</string>
<string name="web_dav">WebDAV</string>
<string name="pref_webdav_url">WebDAV Server URL</string>
<string name="pref_webdav_url_summ">Enter the WebDAV server address</string>
<string name="pref_webdav_username">Username</string>
<string name="pref_webdav_username_summ">Enter your WebDAV account username</string>
<string name="pref_webdav_password">Password</string>
<string name="pref_webdav_password_summ">Enter your WebDAV account password</string>
<string name="pref_webdav_folder">WebDAV data folder</string>
<string name="pref_webdav_folder_summ">The application will store the data in this folder</string>
<!-- Advanced section -->
<string name="pref_clean_invalid_downloads">Clean invalid downloads</string>
<string name="pref_clean_invalid_downloads_summary">Find and remove all downloads, files, folders which are not saved in your library</string>