From 38acd6f110e0d07eaae87b7ec9a6a3062ed5f83a Mon Sep 17 00:00:00 2001 From: KaiserBh <41852205+kaiserbh@users.noreply.github.com> Date: Sat, 28 Feb 2026 03:20:59 +1100 Subject: [PATCH] feat: Add sync events to SyncYomi (jobobby04/TachiyomiSY#1558) * feat: Add sync events to SyncYomi Now it will send the events back to `SyncYomi` server and then forward those to the notifications services that are enabled, such as discord, telegram, and etc. * chore: fix build error. (cherry picked from commit 2776e411272499eba2f91acfea3cb40dc0d4c622) * Improve client management and response handling --------- Co-authored-by: Cuong-Tran <16017808+cuong-tran@users.noreply.github.com> --- .../data/sync/service/SyncYomiSyncService.kt | 126 ++++++++++++++---- 1 file changed, 100 insertions(+), 26 deletions(-) diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/sync/service/SyncYomiSyncService.kt b/app/src/main/java/eu/kanade/tachiyomi/data/sync/service/SyncYomiSyncService.kt index ea04f72ac..3db50c859 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/sync/service/SyncYomiSyncService.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/sync/service/SyncYomiSyncService.kt @@ -5,8 +5,14 @@ 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.POST import eu.kanade.tachiyomi.network.PUT import eu.kanade.tachiyomi.network.await +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.withContext +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable import kotlinx.serialization.SerializationException import kotlinx.serialization.json.Json import kotlinx.serialization.protobuf.ProtoBuf @@ -21,7 +27,6 @@ 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 SyncYomiSyncService( context: Context, @@ -32,9 +37,34 @@ class SyncYomiSyncService( private val protoBuf: ProtoBuf = Injekt.get(), ) : SyncService(context, json, syncPreferences) { + // KMK --> + companion object { + private val client by lazy { OkHttpClient() } + } + // KMK <-- + private class SyncYomiException(message: String?) : Exception(message) + @Serializable + private data class SyncEvent( + val event: SyncEventStatus, + @SerialName("device_name") + val deviceName: String? = null, + val message: String? = null, + ) + + @Serializable + private enum class SyncEventStatus { + SYNC_STARTED, + SYNC_SUCCESS, + SYNC_FAILED, + SYNC_ERROR, + SYNC_CANCELLED, + } + override suspend fun doSync(syncData: SyncData): Backup? { + reportSyncEvent(SyncEventStatus.SYNC_STARTED) + try { val (remoteData, etag) = pullSyncData() @@ -52,11 +82,26 @@ class SyncYomiSyncService( syncData } - pushSyncData(finalSyncData, etag) + val success = pushSyncData(finalSyncData, etag) + + if (success) { + reportSyncEvent(SyncEventStatus.SYNC_SUCCESS) + } else { + reportSyncEvent(SyncEventStatus.SYNC_FAILED, "Failed to push sync data") + // KMK --> + return null + // KMK <-- + } + return finalSyncData.backup } catch (e: Exception) { + if (e is CancellationException) { + reportSyncEvent(SyncEventStatus.SYNC_CANCELLED, e.message) + throw e + } logcat(LogPriority.ERROR) { "Error syncing: ${e.message}" } notifier.showSyncError(e.message) + reportSyncEvent(SyncEventStatus.SYNC_ERROR, e.message) return null } } @@ -78,7 +123,6 @@ class SyncYomiSyncService( headers = headers, ) - val client = OkHttpClient() val response = client.newCall(downloadRequest).await() if (response.code == HttpStatus.SC_NOT_MODIFIED) { @@ -123,13 +167,12 @@ class SyncYomiSyncService( /** * Return true if update success */ - private suspend fun pushSyncData(syncData: SyncData, eTag: String) { - val backup = syncData.backup ?: return + private suspend fun pushSyncData(syncData: SyncData, eTag: String): Boolean { + val backup = syncData.backup ?: return true val host = syncPreferences.clientHost().get() val apiKey = syncPreferences.clientAPIKey().get() val uploadUrl = "$host/api/sync/content" - val timeout = 30L val headersBuilder = Headers.Builder().add("X-API-Token", apiKey) if (eTag.isNotEmpty()) { @@ -137,13 +180,6 @@ class SyncYomiSyncService( } val headers = headersBuilder.build() - // Set timeout to 30 seconds - val client = OkHttpClient.Builder() - .connectTimeout(timeout, TimeUnit.SECONDS) - .readTimeout(timeout, TimeUnit.SECONDS) - .writeTimeout(timeout, TimeUnit.SECONDS) - .build() - val byteArray = protoBuf.encodeToByteArray(Backup.serializer(), backup) if (byteArray.isEmpty()) { throw IllegalStateException(context.stringResource(MR.strings.empty_backup_error)) @@ -156,20 +192,58 @@ class SyncYomiSyncService( body = body, ) - val response = client.newCall(uploadRequest).await() + client.newCall(uploadRequest).await() + // KMK --> + .use { response -> + // KMK <-- + if (response.isSuccessful) { + val newETag = response.headers["ETag"] + .takeIf { it?.isNotEmpty() == true } ?: throw SyncYomiException("Missing ETag") + syncPreferences.lastSyncEtag().set(newETag) + logcat(LogPriority.DEBUG) { "SyncYomi sync completed" } + return true + } else if (response.code == HttpStatus.SC_PRECONDITION_FAILED) { + // other clients updated remote data, will try next time + logcat(LogPriority.DEBUG) { "SyncYomi sync failed with 412" } + return false + } else { + val responseBody = response.body.string() + notifier.showSyncError("Failed to upload sync data: $responseBody") + logcat(LogPriority.ERROR) { "SyncError: $responseBody" } + return false + } + } + } - if (response.isSuccessful) { - val newETag = response.headers["ETag"] - .takeIf { it?.isNotEmpty() == true } ?: throw SyncYomiException("Missing ETag") - syncPreferences.lastSyncEtag().set(newETag) - logcat(LogPriority.DEBUG) { "SyncYomi sync completed" } - } else if (response.code == HttpStatus.SC_PRECONDITION_FAILED) { - // other clients updated remote data, will try next time - logcat(LogPriority.DEBUG) { "SyncYomi sync failed with 412" } - } else { - val responseBody = response.body.string() - notifier.showSyncError("Failed to upload sync data: $responseBody") - logcat(LogPriority.ERROR) { "SyncError: $responseBody" } + private suspend fun reportSyncEvent(event: SyncEventStatus, message: String? = null) { + withContext(NonCancellable) { + try { + val host = syncPreferences.clientHost().get() + val apiKey = syncPreferences.clientAPIKey().get() + val url = "$host/api/sync/event" + + val headersBuilder = Headers.Builder().add("X-API-Token", apiKey) + val headers = headersBuilder.build() + + val bodyObj = SyncEvent( + event = event, + deviceName = android.os.Build.MODEL, + message = message, + ) + + val jsonBody = json.encodeToString(SyncEvent.serializer(), bodyObj) + val requestBody = jsonBody.toRequestBody("application/json; charset=utf-8".toMediaType()) + + val request = POST( + url = url, + headers = headers, + body = requestBody, + ) + + client.newCall(request).await().close() + } catch (e: Exception) { + logcat(LogPriority.ERROR) { "Failed to report sync event: ${e.message}" } + } } } }