diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/sync/service/SyncService.kt b/app/src/main/java/eu/kanade/tachiyomi/data/sync/service/SyncService.kt index f091cfe3e..39eac5483 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/sync/service/SyncService.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/sync/service/SyncService.kt @@ -2,16 +2,36 @@ package eu.kanade.tachiyomi.data.sync.service import android.content.Context import eu.kanade.tachiyomi.data.backup.models.Backup +import eu.kanade.tachiyomi.data.backup.models.BackupCategory +import eu.kanade.tachiyomi.data.backup.models.BackupChapter +import eu.kanade.tachiyomi.data.backup.models.BackupManga import eu.kanade.tachiyomi.data.sync.models.SyncData import kotlinx.serialization.json.Json import tachiyomi.domain.sync.SyncPreferences +import java.time.Instant abstract class SyncService( val context: Context, val json: Json, val syncPreferences: SyncPreferences, ) { - abstract suspend fun doSync(syncData: SyncData): Backup? + open suspend fun doSync(syncData: SyncData): Backup? { + beforeSync() + + val remoteSData = pushSyncData() + + val finalSyncData = + if (remoteSData == null) { + pullSyncData(syncData) + syncData + } else { + val mergedSyncData = mergeSyncData(syncData, remoteSData) + pullSyncData(mergedSyncData) + mergedSyncData + } + + return finalSyncData.backup + } /** * Decodes the given sync data string into a Backup object. @@ -23,4 +43,189 @@ abstract class SyncService( val syncData = json.decodeFromString(SyncData.serializer(), data) return syncData.backup!! } + + /** + * For refreshing tokens and other possible operations before connecting to the remote storage + */ + open suspend fun beforeSync() {} + + /** + * Download sync data from the remote storage + */ + abstract suspend fun pushSyncData(): SyncData? + + /** + * Upload sync data to the remote storage + */ + abstract suspend fun pullSyncData(syncData: SyncData) + + /** + * Merges the local and remote sync data into a single JSON string. + * + * @param localSyncData The SData containing the local sync data. + * @param remoteSyncData The SData containing the remote sync data. + * @return The JSON string containing the merged sync data. + */ + fun mergeSyncData(localSyncData: SyncData, remoteSyncData: SyncData): SyncData { + val mergedMangaList = mergeMangaLists(localSyncData.backup?.backupManga, remoteSyncData.backup?.backupManga) + val mergedCategoriesList = mergeCategoriesLists(localSyncData.backup?.backupCategories, remoteSyncData.backup?.backupCategories) + + // Create the merged Backup object + val mergedBackup = Backup( + backupManga = mergedMangaList, + backupCategories = mergedCategoriesList, + backupBrokenSources = localSyncData.backup?.backupBrokenSources ?: emptyList(), + backupSources = localSyncData.backup?.backupSources ?: emptyList(), + ) + + // Create the merged SData object + return SyncData( + sync = localSyncData.sync, // always use the local sync info + backup = mergedBackup, + device = localSyncData.device, // always use the local device info + ) + } + + /** + * Merges two lists of SyncManga objects, prioritizing the manga with the most recent lastModifiedAt value. + * If lastModifiedAt is null, the function defaults to Instant.MIN for comparison purposes. + * + * @param localMangaList The list of local SyncManga objects. + * @param remoteMangaList The list of remote SyncManga objects. + * @return The merged list of SyncManga objects. + */ + private fun mergeMangaLists(localMangaList: List?, remoteMangaList: List?): List { + if (localMangaList == null) return remoteMangaList ?: emptyList() + if (remoteMangaList == null) return localMangaList + + val localMangaMap = localMangaList.associateBy { Pair(it.source, it.url) } + val remoteMangaMap = remoteMangaList.associateBy { Pair(it.source, it.url) } + + val mergedMangaMap = mutableMapOf, BackupManga>() + + localMangaMap.forEach { (key, localManga) -> + val remoteManga = remoteMangaMap[key] + if (remoteManga != null) { + val localInstant = localManga.lastModifiedAt?.let { Instant.ofEpochMilli(it) } + val remoteInstant = remoteManga.lastModifiedAt?.let { Instant.ofEpochMilli(it) } + + val mergedManga = if ((localInstant ?: Instant.MIN) >= ( + remoteInstant + ?: Instant.MIN + ) + ) { + localManga + } else { + remoteManga + } + + val localChapters = localManga.chapters + val remoteChapters = remoteManga.chapters + val mergedChapters = mergeChapters(localChapters, remoteChapters) + + val isFavorite = if ((localInstant ?: Instant.MIN) >= ( + remoteInstant + ?: Instant.MIN + ) + ) { + localManga.favorite + } else { + remoteManga.favorite + } + + mergedMangaMap[key] = mergedManga.copy(chapters = mergedChapters, favorite = isFavorite) + } else { + mergedMangaMap[key] = localManga + } + } + + remoteMangaMap.forEach { (key, remoteManga) -> + if (!mergedMangaMap.containsKey(key)) { + mergedMangaMap[key] = remoteManga + } + } + + return mergedMangaMap.values.toList() + } + + /** + * Merges two lists of SyncChapter objects, prioritizing the chapter with the most recent lastModifiedAt value. + * If lastModifiedAt is null, the function defaults to Instant.MIN for comparison purposes. + * + * @param localChapters The list of local SyncChapter objects. + * @param remoteChapters The list of remote SyncChapter objects. + * @return The merged list of SyncChapter objects. + */ + private fun mergeChapters(localChapters: List, remoteChapters: List): List { + val localChapterMap = localChapters.associateBy { it.url } + val remoteChapterMap = remoteChapters.associateBy { it.url } + val mergedChapterMap = mutableMapOf() + + localChapterMap.forEach { (url, localChapter) -> + val remoteChapter = remoteChapterMap[url] + if (remoteChapter != null) { + val localInstant = localChapter.lastModifiedAt?.let { Instant.ofEpochMilli(it) } + val remoteInstant = remoteChapter.lastModifiedAt?.let { Instant.ofEpochMilli(it) } + + val mergedChapter = + if ((localInstant ?: Instant.MIN) >= (remoteInstant ?: Instant.MIN)) { + localChapter + } else { + remoteChapter + } + mergedChapterMap[url] = mergedChapter + } else { + mergedChapterMap[url] = localChapter + } + } + + remoteChapterMap.forEach { (url, remoteChapter) -> + if (!mergedChapterMap.containsKey(url)) { + mergedChapterMap[url] = remoteChapter + } + } + + return mergedChapterMap.values.toList() + } + + /** + * Merges two lists of SyncCategory objects, prioritizing the category with the most recent order value. + * + * @param localCategoriesList The list of local SyncCategory objects. + * @param remoteCategoriesList The list of remote SyncCategory objects. + * @return The merged list of SyncCategory objects. + */ + private fun mergeCategoriesLists(localCategoriesList: List?, remoteCategoriesList: List?): List { + if (localCategoriesList == null) return remoteCategoriesList ?: emptyList() + if (remoteCategoriesList == null) return localCategoriesList + val localCategoriesMap = localCategoriesList.associateBy { it.name } + val remoteCategoriesMap = remoteCategoriesList.associateBy { it.name } + + val mergedCategoriesMap = mutableMapOf() + + localCategoriesMap.forEach { (name, localCategory) -> + val remoteCategory = remoteCategoriesMap[name] + if (remoteCategory != null) { + // Compare and merge local and remote categories + val mergedCategory = if (localCategory.order >= remoteCategory.order) { + localCategory + } else { + remoteCategory + } + mergedCategoriesMap[name] = mergedCategory + } else { + // If the category is only in the local list, add it to the merged list + mergedCategoriesMap[name] = localCategory + } + } + + // Add any categories from the remote list that are not in the local list + remoteCategoriesMap.forEach { (name, remoteCategory) -> + if (!mergedCategoriesMap.containsKey(name)) { + mergedCategoriesMap[name] = remoteCategory + } + } + + return mergedCategoriesMap.values.toList() + } } 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 dda74817a..ed8c4193f 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 @@ -1,10 +1,6 @@ package eu.kanade.tachiyomi.data.sync.service import android.content.Context -import eu.kanade.tachiyomi.data.backup.models.Backup -import eu.kanade.tachiyomi.data.backup.models.BackupCategory -import eu.kanade.tachiyomi.data.backup.models.BackupChapter -import eu.kanade.tachiyomi.data.backup.models.BackupManga import eu.kanade.tachiyomi.data.sync.SyncNotifier import eu.kanade.tachiyomi.data.sync.models.SyncData import eu.kanade.tachiyomi.network.GET @@ -15,12 +11,10 @@ import logcat.LogPriority import okhttp3.Headers import okhttp3.MediaType.Companion.toMediaTypeOrNull import okhttp3.OkHttpClient -import okhttp3.RequestBody import okhttp3.RequestBody.Companion.gzip import okhttp3.RequestBody.Companion.toRequestBody import tachiyomi.core.util.system.logcat import tachiyomi.domain.sync.SyncPreferences -import java.time.Instant class SyncYomiSyncService( context: Context, @@ -28,33 +22,7 @@ class SyncYomiSyncService( syncPreferences: SyncPreferences, private val notifier: SyncNotifier, ) : SyncService(context, json, syncPreferences) { - override suspend fun doSync(syncData: SyncData): Backup? { - logcat( - LogPriority.DEBUG, - ) { "SyncYomi sync started!" } - - val jsonData = json.encodeToString(syncData) - - val mediaType = "application/gzip".toMediaTypeOrNull() - val body = jsonData.toRequestBody(mediaType).gzip() - - val remoteSyncData = downloadSyncData() - - val finalSyncData = - if (remoteSyncData?.backup == null) { - uploadSyncData(body) - syncData - } else { - val mergedSyncData = mergeSyncData(syncData, remoteSyncData) - val encodeMergedData = json.encodeToString(mergedSyncData) - uploadSyncData(encodeMergedData.toRequestBody(mediaType).gzip()) - mergedSyncData - } - - return finalSyncData.backup - } - - suspend fun downloadSyncData(): SyncData? { + override suspend fun pushSyncData(): SyncData? { val host = syncPreferences.syncHost().get() val apiKey = syncPreferences.syncAPIKey().get() val deviceId = syncPreferences.deviceID().get() @@ -81,7 +49,7 @@ class SyncYomiSyncService( } } - suspend fun uploadSyncData(body: RequestBody) { + override suspend fun pullSyncData(syncData: SyncData) { val host = syncPreferences.syncHost().get() val apiKey = syncPreferences.syncAPIKey().get() val uploadUrl = "$host/api/sync/upload" @@ -90,6 +58,11 @@ class SyncYomiSyncService( val headers = Headers.Builder().add("Content-Type", "application/gzip").add("Content-Encoding", "gzip").add("X-API-Token", apiKey).build() + val mediaType = "application/gzip".toMediaTypeOrNull() + + val jsonData = json.encodeToString(syncData) + val body = jsonData.toRequestBody(mediaType).gzip() + val uploadRequest = POST( url = uploadUrl, headers = headers, @@ -108,174 +81,4 @@ class SyncYomiSyncService( } } } - - /** - * Merges the local and remote sync data into a single JSON string. - * - * @param localSyncData The SyncData containing the local sync data. - * @param remoteSyncData The SyncData containing the remote sync data. - * @return The JSON string containing the merged sync data. - */ - fun mergeSyncData(localSyncData: SyncData, remoteSyncData: SyncData): SyncData { - val mergedMangaList = mergeMangaLists(localSyncData.backup?.backupManga, remoteSyncData.backup?.backupManga) - val mergedCategoriesList = mergeCategoriesLists(localSyncData.backup?.backupCategories, remoteSyncData.backup?.backupCategories) - - // Create the merged Backup object - val mergedBackup = Backup( - backupManga = mergedMangaList, - backupCategories = mergedCategoriesList, - backupBrokenSources = localSyncData.backup?.backupBrokenSources ?: emptyList(), - backupSources = localSyncData.backup?.backupSources ?: emptyList(), - ) - - // Create the merged SyncData object - return SyncData( - sync = localSyncData.sync, // always use the local sync info - backup = mergedBackup, - device = localSyncData.device, // always use the local device info - ) - } - - /** - * Merges two lists of SyncManga objects, prioritizing the manga with the most recent lastModifiedAt value. - * If lastModifiedAt is null, the function defaults to Instant.MIN for comparison purposes. - * - * @param localMangaList The list of local SyncManga objects. - * @param remoteMangaList The list of remote SyncManga objects. - * @return The merged list of SyncManga objects. - */ - private fun mergeMangaLists(localMangaList: List?, remoteMangaList: List?): List { - if (localMangaList == null) return remoteMangaList ?: emptyList() - if (remoteMangaList == null) return localMangaList - - val localMangaMap = localMangaList.associateBy { Pair(it.source, it.url) } - val remoteMangaMap = remoteMangaList.associateBy { Pair(it.source, it.url) } - - val mergedMangaMap = mutableMapOf, BackupManga>() - - localMangaMap.forEach { (key, localManga) -> - val remoteManga = remoteMangaMap[key] - if (remoteManga != null) { - val localInstant = localManga.lastModifiedAt?.let { Instant.ofEpochMilli(it) } - val remoteInstant = remoteManga.lastModifiedAt?.let { Instant.ofEpochMilli(it) } - - val mergedManga = if ((localInstant ?: Instant.MIN) >= ( - remoteInstant - ?: Instant.MIN - ) - ) { - localManga - } else { - remoteManga - } - - val localChapters = localManga.chapters - val remoteChapters = remoteManga.chapters - val mergedChapters = mergeChapters(localChapters, remoteChapters) - - val isFavorite = if ((localInstant ?: Instant.MIN) >= ( - remoteInstant - ?: Instant.MIN - ) - ) { - localManga.favorite - } else { - remoteManga.favorite - } - - mergedMangaMap[key] = mergedManga.copy(chapters = mergedChapters, favorite = isFavorite) - } else { - mergedMangaMap[key] = localManga - } - } - - remoteMangaMap.forEach { (key, remoteManga) -> - if (!mergedMangaMap.containsKey(key)) { - mergedMangaMap[key] = remoteManga - } - } - - return mergedMangaMap.values.toList() - } - - /** - * Merges two lists of SyncChapter objects, prioritizing the chapter with the most recent lastModifiedAt value. - * If lastModifiedAt is null, the function defaults to Instant.MIN for comparison purposes. - * - * @param localChapters The list of local SyncChapter objects. - * @param remoteChapters The list of remote SyncChapter objects. - * @return The merged list of SyncChapter objects. - */ - private fun mergeChapters(localChapters: List, remoteChapters: List): List { - val localChapterMap = localChapters.associateBy { it.url } - val remoteChapterMap = remoteChapters.associateBy { it.url } - val mergedChapterMap = mutableMapOf() - - localChapterMap.forEach { (url, localChapter) -> - val remoteChapter = remoteChapterMap[url] - if (remoteChapter != null) { - val localInstant = localChapter.lastModifiedAt?.let { Instant.ofEpochMilli(it) } - val remoteInstant = remoteChapter.lastModifiedAt?.let { Instant.ofEpochMilli(it) } - - val mergedChapter = - if ((localInstant ?: Instant.MIN) >= (remoteInstant ?: Instant.MIN)) { - localChapter - } else { - remoteChapter - } - mergedChapterMap[url] = mergedChapter - } else { - mergedChapterMap[url] = localChapter - } - } - - remoteChapterMap.forEach { (url, remoteChapter) -> - if (!mergedChapterMap.containsKey(url)) { - mergedChapterMap[url] = remoteChapter - } - } - - return mergedChapterMap.values.toList() - } - - /** - * Merges two lists of SyncCategory objects, prioritizing the category with the most recent order value. - * - * @param localCategoriesList The list of local SyncCategory objects. - * @param remoteCategoriesList The list of remote SyncCategory objects. - * @return The merged list of SyncCategory objects. - */ - private fun mergeCategoriesLists(localCategoriesList: List?, remoteCategoriesList: List?): List { - if (localCategoriesList == null) return remoteCategoriesList ?: emptyList() - if (remoteCategoriesList == null) return localCategoriesList - val localCategoriesMap = localCategoriesList.associateBy { it.name } - val remoteCategoriesMap = remoteCategoriesList.associateBy { it.name } - - val mergedCategoriesMap = mutableMapOf() - - localCategoriesMap.forEach { (name, localCategory) -> - val remoteCategory = remoteCategoriesMap[name] - if (remoteCategory != null) { - // Compare and merge local and remote categories - val mergedCategory = if (localCategory.order >= remoteCategory.order) { - localCategory - } else { - remoteCategory - } - mergedCategoriesMap[name] = mergedCategory - } else { - // If the category is only in the local list, add it to the merged list - mergedCategoriesMap[name] = localCategory - } - } - - // Add any categories from the remote list that are not in the local list - remoteCategoriesMap.forEach { (name, remoteCategory) -> - if (!mergedCategoriesMap.containsKey(name)) { - mergedCategoriesMap[name] = remoteCategory - } - } - - return mergedCategoriesMap.values.toList() - } -} + }