diff --git a/app/src/main/java/eu/kanade/domain/sync/SyncPreferences.kt b/app/src/main/java/eu/kanade/domain/sync/SyncPreferences.kt index 18d2a3e40..463dff7d3 100644 --- a/app/src/main/java/eu/kanade/domain/sync/SyncPreferences.kt +++ b/app/src/main/java/eu/kanade/domain/sync/SyncPreferences.kt @@ -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"), "", diff --git a/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsDataScreen.kt b/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsDataScreen.kt index d64af1377..aba5089e4 100644 --- a/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsDataScreen.kt +++ b/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsDataScreen.kt @@ -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 { + 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 diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/sync/SyncManager.kt b/app/src/main/java/eu/kanade/tachiyomi/data/sync/SyncManager.kt index a1f3d865f..a4dfb1815 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/sync/SyncManager.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/sync/SyncManager.kt @@ -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 diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/sync/service/WebDavSyncService.kt b/app/src/main/java/eu/kanade/tachiyomi/data/sync/service/WebDavSyncService.kt new file mode 100644 index 000000000..cac0a49d9 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/sync/service/WebDavSyncService.kt @@ -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 { + 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") + } + } + } +} diff --git a/i18n-kmk/src/commonMain/moko-resources/base/strings.xml b/i18n-kmk/src/commonMain/moko-resources/base/strings.xml index 09375740c..e5d247255 100644 --- a/i18n-kmk/src/commonMain/moko-resources/base/strings.xml +++ b/i18n-kmk/src/commonMain/moko-resources/base/strings.xml @@ -118,6 +118,15 @@ Show restoring progress banner Show syncing progress banner + WebDAV + WebDAV Server URL + Enter the WebDAV server address + Username + Enter your WebDAV account username + Password + Enter your WebDAV account password + WebDAV data folder + The application will store the data in this folder Clean invalid downloads Find and remove all downloads, files, folders which are not saved in your library