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 syncInterval() = preferenceStore.getInt("sync_interval", 0)
|
||||||
fun syncService() = preferenceStore.getInt("sync_service", 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(
|
fun googleDriveAccessToken() = preferenceStore.getString(
|
||||||
Preference.appStateKey("google_drive_access_token"),
|
Preference.appStateKey("google_drive_access_token"),
|
||||||
"",
|
"",
|
||||||
|
|
|
||||||
|
|
@ -561,6 +561,9 @@ object SettingsDataScreen : SearchableSettings {
|
||||||
SyncManager.SyncService.NONE.value to stringResource(MR.strings.off),
|
SyncManager.SyncService.NONE.value to stringResource(MR.strings.off),
|
||||||
SyncManager.SyncService.SYNCYOMI.value to stringResource(SYMR.strings.syncyomi),
|
SyncManager.SyncService.SYNCYOMI.value to stringResource(SYMR.strings.syncyomi),
|
||||||
SyncManager.SyncService.GOOGLE_DRIVE.value to stringResource(SYMR.strings.google_drive),
|
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),
|
title = stringResource(SYMR.strings.pref_sync_service),
|
||||||
onValueChanged = {
|
onValueChanged = {
|
||||||
|
|
@ -602,6 +605,9 @@ object SettingsDataScreen : SearchableSettings {
|
||||||
SyncManager.SyncService.NONE -> emptyList()
|
SyncManager.SyncService.NONE -> emptyList()
|
||||||
SyncManager.SyncService.SYNCYOMI -> getSelfHostPreferences(syncPreferences)
|
SyncManager.SyncService.SYNCYOMI -> getSelfHostPreferences(syncPreferences)
|
||||||
SyncManager.SyncService.GOOGLE_DRIVE -> getGoogleDrivePreferences()
|
SyncManager.SyncService.GOOGLE_DRIVE -> getGoogleDrivePreferences()
|
||||||
|
// KMK -->
|
||||||
|
SyncManager.SyncService.WebDAV -> getWebDavPreferences(syncPreferences)
|
||||||
|
// KMK <--
|
||||||
}
|
}
|
||||||
|
|
||||||
return if (syncServiceType != SyncManager.SyncService.NONE) {
|
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
|
@Composable
|
||||||
private fun getSyncNowPref(): Preference.PreferenceGroup {
|
private fun getSyncNowPref(): Preference.PreferenceGroup {
|
||||||
val context = LocalContext.current
|
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.GoogleDriveSyncService
|
||||||
import eu.kanade.tachiyomi.data.sync.service.SyncData
|
import eu.kanade.tachiyomi.data.sync.service.SyncData
|
||||||
import eu.kanade.tachiyomi.data.sync.service.SyncYomiSyncService
|
import eu.kanade.tachiyomi.data.sync.service.SyncYomiSyncService
|
||||||
|
import eu.kanade.tachiyomi.data.sync.service.WebDavSyncService
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import kotlinx.serialization.protobuf.ProtoBuf
|
import kotlinx.serialization.protobuf.ProtoBuf
|
||||||
import logcat.LogPriority
|
import logcat.LogPriority
|
||||||
|
|
@ -55,6 +56,9 @@ class SyncManager(
|
||||||
NONE(0),
|
NONE(0),
|
||||||
SYNCYOMI(1),
|
SYNCYOMI(1),
|
||||||
GOOGLE_DRIVE(2),
|
GOOGLE_DRIVE(2),
|
||||||
|
// KMK -->
|
||||||
|
WebDAV(3),
|
||||||
|
// KMK <--
|
||||||
;
|
;
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
|
@ -137,6 +141,12 @@ class SyncManager(
|
||||||
GoogleDriveSyncService(context, json, syncPreferences)
|
GoogleDriveSyncService(context, json, syncPreferences)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// KMK -->
|
||||||
|
SyncService.WebDAV -> {
|
||||||
|
WebDavSyncService(context, json, syncPreferences, notifier)
|
||||||
|
}
|
||||||
|
// KMK <--
|
||||||
|
|
||||||
else -> {
|
else -> {
|
||||||
logcat(LogPriority.ERROR) { "Invalid sync service type: $syncService" }
|
logcat(LogPriority.ERROR) { "Invalid sync service type: $syncService" }
|
||||||
null
|
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 -->
|
<!-- Sync section -->
|
||||||
<string name="pref_show_restoring_progress_banner">Show restoring progress banner</string>
|
<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="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 -->
|
<!-- Advanced section -->
|
||||||
<string name="pref_clean_invalid_downloads">Clean invalid downloads</string>
|
<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>
|
<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