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:
parent
e7bdbb5794
commit
696908f94b
5 changed files with 278 additions and 0 deletions
|
|
@ -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"),
|
||||
"",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in a new issue