mirror of
				https://github.com/mihonapp/mihon.git
				synced 2025-10-31 14:27:57 +01:00 
			
		
		
		
	Split sync feature part 1
Co-authored-by: KaiserBh <kaiserbh@proton.me>
This commit is contained in:
		| @@ -24,6 +24,7 @@ fun Chapter.copyFromSChapter(sChapter: SChapter): Chapter { | ||||
|         dateUpload = sChapter.date_upload, | ||||
|         chapterNumber = sChapter.chapter_number, | ||||
|         scanlator = sChapter.scanlator?.ifBlank { null }, | ||||
|         lastModifiedAt = null, | ||||
|     ) | ||||
| } | ||||
|  | ||||
| @@ -34,6 +35,7 @@ fun Chapter.copyFrom(other: Chapters): Chapter { | ||||
|         dateUpload = other.date_upload, | ||||
|         chapterNumber = other.chapter_number, | ||||
|         scanlator = other.scanlator?.ifBlank { null }, | ||||
|         lastModifiedAt = other.last_modified_at, | ||||
|     ) | ||||
| } | ||||
|  | ||||
| @@ -50,4 +52,5 @@ fun Chapter.toDbChapter(): DbChapter = ChapterImpl().also { | ||||
|     it.date_upload = dateUpload | ||||
|     it.chapter_number = chapterNumber | ||||
|     it.source_order = sourceOrder.toInt() | ||||
|     it.last_modified = lastModifiedAt | ||||
| } | ||||
|   | ||||
| @@ -17,6 +17,7 @@ import androidx.compose.material.icons.outlined.Label | ||||
| import androidx.compose.material.icons.outlined.QueryStats | ||||
| import androidx.compose.material.icons.outlined.Settings | ||||
| import androidx.compose.material.icons.outlined.SettingsBackupRestore | ||||
| import androidx.compose.material.icons.outlined.Sync | ||||
| import androidx.compose.runtime.Composable | ||||
| import androidx.compose.ui.Modifier | ||||
| import androidx.compose.ui.graphics.vector.ImageVector | ||||
| @@ -46,6 +47,7 @@ fun MoreScreen( | ||||
|     onClickCategories: () -> Unit, | ||||
|     onClickStats: () -> Unit, | ||||
|     onClickBackupAndRestore: () -> Unit, | ||||
|     onClickSync: () -> Unit, | ||||
|     onClickSettings: () -> Unit, | ||||
|     onClickAbout: () -> Unit, | ||||
| ) { | ||||
| @@ -146,6 +148,13 @@ fun MoreScreen( | ||||
|                     onPreferenceClick = onClickBackupAndRestore, | ||||
|                 ) | ||||
|             } | ||||
|             item { | ||||
|                 TextPreferenceWidget( | ||||
|                     title = stringResource(R.string.label_sync), | ||||
|                     icon = Icons.Outlined.Sync, | ||||
|                     onPreferenceClick = onClickSync, | ||||
|                 ) | ||||
|             } | ||||
|  | ||||
|             item { Divider() } | ||||
|  | ||||
|   | ||||
| @@ -228,6 +228,12 @@ object SettingsMainScreen : Screen() { | ||||
|             icon = Icons.Outlined.SettingsBackupRestore, | ||||
|             screen = SettingsBackupScreen, | ||||
|         ), | ||||
|         Item( | ||||
|             titleRes = R.string.label_sync, | ||||
|             subtitleRes = R.string.pref_sync_summary, | ||||
|             icon = Icons.Outlined.Sync, | ||||
|             screen = SettingsSyncScreen, | ||||
|         ), | ||||
|         Item( | ||||
|             titleRes = R.string.pref_category_security, | ||||
|             subtitleRes = R.string.pref_security_summary, | ||||
|   | ||||
| @@ -291,6 +291,7 @@ private val settingScreens = listOf( | ||||
|     SettingsTrackingScreen, | ||||
|     SettingsBrowseScreen, | ||||
|     SettingsBackupScreen, | ||||
|     SettingsSyncScreen, | ||||
|     SettingsSecurityScreen, | ||||
|     SettingsAdvancedScreen, | ||||
| ) | ||||
|   | ||||
| @@ -0,0 +1,185 @@ | ||||
| package eu.kanade.presentation.more.settings.screen | ||||
|  | ||||
| import android.text.format.DateUtils | ||||
| import androidx.annotation.StringRes | ||||
| import androidx.compose.material.icons.Icons | ||||
| import androidx.compose.material.icons.outlined.Cloud | ||||
| import androidx.compose.material.icons.outlined.Devices | ||||
| import androidx.compose.material.icons.outlined.Sync | ||||
| import androidx.compose.material.icons.outlined.VpnKey | ||||
| import androidx.compose.material3.AlertDialog | ||||
| import androidx.compose.material3.Text | ||||
| import androidx.compose.material3.TextButton | ||||
| import androidx.compose.runtime.Composable | ||||
| import androidx.compose.runtime.ReadOnlyComposable | ||||
| import androidx.compose.runtime.getValue | ||||
| import androidx.compose.runtime.mutableStateOf | ||||
| import androidx.compose.runtime.remember | ||||
| import androidx.compose.runtime.rememberCoroutineScope | ||||
| import androidx.compose.ui.platform.LocalContext | ||||
| import androidx.compose.ui.res.stringResource | ||||
| import eu.kanade.presentation.more.settings.Preference | ||||
| import eu.kanade.presentation.util.collectAsState | ||||
| import eu.kanade.tachiyomi.R | ||||
| import eu.kanade.tachiyomi.data.sync.SyncDataJob | ||||
| import eu.kanade.tachiyomi.util.system.toast | ||||
| import kotlinx.coroutines.launch | ||||
| import tachiyomi.domain.sync.SyncPreferences | ||||
| import uy.kohesive.injekt.Injekt | ||||
| import uy.kohesive.injekt.api.get | ||||
|  | ||||
| object SettingsSyncScreen : SearchableSettings { | ||||
|  | ||||
|     @ReadOnlyComposable | ||||
|     @Composable | ||||
|     @StringRes | ||||
|     override fun getTitleRes() = R.string.label_sync | ||||
|  | ||||
|     @Composable | ||||
|     override fun getPreferences(): List<Preference> { | ||||
|         val syncPreferences = Injekt.get<SyncPreferences>() | ||||
|         val syncService by syncPreferences.syncService().collectAsState() | ||||
|  | ||||
|         return listOf( | ||||
|             Preference.PreferenceItem.ListPreference( | ||||
|                 pref = syncPreferences.syncService(), | ||||
|                 title = stringResource(R.string.pref_sync_service), | ||||
|                 entries = mapOf( | ||||
|                     0 to stringResource(R.string.off), | ||||
|                     1 to stringResource(R.string.self_host), | ||||
|                 ), | ||||
|                 onValueChanged = { true }, | ||||
|             ), | ||||
|         ) + getSyncServicePreferences(syncPreferences, syncService) | ||||
|     } | ||||
|  | ||||
|     @Composable | ||||
|     private fun getSyncServicePreferences(syncPreferences: SyncPreferences, syncService: Int): List<Preference> { | ||||
|         val servicePreferences = when (syncService) { | ||||
|             1 -> getSelfHostPreferences(syncPreferences) | ||||
|             else -> emptyList() | ||||
|         } | ||||
|  | ||||
|         return if (syncService != 0) { | ||||
|             servicePreferences + getSyncNowPref() + getAutomaticSyncGroup(syncPreferences) | ||||
|         } else { | ||||
|             servicePreferences | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     @Composable | ||||
|     private fun getSelfHostPreferences(syncPreferences: SyncPreferences): List<Preference> { | ||||
|         return listOf( | ||||
|             Preference.PreferenceItem.EditTextPreference( | ||||
|                 title = stringResource(R.string.pref_sync_device_name), | ||||
|                 subtitle = stringResource(R.string.pref_sync_device_name_summ), | ||||
|                 icon = Icons.Outlined.Devices, | ||||
|                 pref = syncPreferences.deviceName(), | ||||
|             ), | ||||
|             Preference.PreferenceItem.EditTextPreference( | ||||
|                 title = stringResource(R.string.pref_sync_host), | ||||
|                 subtitle = stringResource(R.string.pref_sync_host_summ), | ||||
|                 icon = Icons.Outlined.Cloud, | ||||
|                 pref = syncPreferences.syncHost(), | ||||
|             ), | ||||
|             Preference.PreferenceItem.EditTextPreference( | ||||
|                 title = stringResource(R.string.pref_sync_api_key), | ||||
|                 subtitle = stringResource(R.string.pref_sync_api_key_summ), | ||||
|                 icon = Icons.Outlined.VpnKey, | ||||
|                 pref = syncPreferences.syncAPIKey(), | ||||
|             ), | ||||
|         ) | ||||
|     } | ||||
|  | ||||
|     @Composable | ||||
|     private fun getSyncNowPref(): Preference.PreferenceGroup { | ||||
|         val scope = rememberCoroutineScope() | ||||
|         val showDialog = remember { mutableStateOf(false) } | ||||
|         val context = LocalContext.current | ||||
|  | ||||
|         if (showDialog.value) { | ||||
|             SyncConfirmationDialog( | ||||
|                 onConfirm = { | ||||
|                     showDialog.value = false | ||||
|                     scope.launch { | ||||
|                         if (!SyncDataJob.isManualJobRunning(context)) { | ||||
|                             SyncDataJob.startNow(context) | ||||
|                         } else { | ||||
|                             context.toast(R.string.sync_in_progress) | ||||
|                         } | ||||
|                     } | ||||
|                 }, | ||||
|                 onDismissRequest = { showDialog.value = false }, | ||||
|             ) | ||||
|         } | ||||
|         return Preference.PreferenceGroup( | ||||
|             title = stringResource(R.string.pref_sync_now_group_title), | ||||
|             preferenceItems = listOf( | ||||
|                 Preference.PreferenceItem.TextPreference( | ||||
|                     title = stringResource(R.string.pref_sync_now), | ||||
|                     subtitle = stringResource(R.string.pref_sync_now_subtitle), | ||||
|                     onClick = { | ||||
|                         showDialog.value = true | ||||
|                     }, | ||||
|                     icon = Icons.Outlined.Sync, | ||||
|                 ), | ||||
|             ), | ||||
|         ) | ||||
|     } | ||||
|  | ||||
|     @Composable | ||||
|     private fun getAutomaticSyncGroup(syncPreferences: SyncPreferences): Preference.PreferenceGroup { | ||||
|         val context = LocalContext.current | ||||
|         val syncIntervalPref = syncPreferences.syncInterval() | ||||
|         val lastSync by syncPreferences.syncLastSync().collectAsState() | ||||
|         val formattedLastSync = DateUtils.getRelativeTimeSpanString(lastSync.toEpochMilli(), System.currentTimeMillis(), DateUtils.MINUTE_IN_MILLIS) | ||||
|  | ||||
|         return Preference.PreferenceGroup( | ||||
|             title = stringResource(R.string.pref_sync_service_category), | ||||
|             preferenceItems = listOf( | ||||
|                 Preference.PreferenceItem.ListPreference( | ||||
|                     pref = syncIntervalPref, | ||||
|                     title = stringResource(R.string.pref_sync_interval), | ||||
|                     entries = mapOf( | ||||
|                         0 to stringResource(R.string.off), | ||||
|                         30 to stringResource(R.string.update_30min), | ||||
|                         60 to stringResource(R.string.update_1hour), | ||||
|                         180 to stringResource(R.string.update_3hour), | ||||
|                         360 to stringResource(R.string.update_6hour), | ||||
|                         720 to stringResource(R.string.update_12hour), | ||||
|                         1440 to stringResource(R.string.update_24hour), | ||||
|                         2880 to stringResource(R.string.update_48hour), | ||||
|                         10080 to stringResource(R.string.update_weekly), | ||||
|                     ), | ||||
|                     onValueChanged = { | ||||
|                         SyncDataJob.setupTask(context, it) | ||||
|                         true | ||||
|                     }, | ||||
|                 ), | ||||
|                 Preference.PreferenceItem.InfoPreference(stringResource(R.string.last_synchronization) + ": " + formattedLastSync), | ||||
|             ), | ||||
|         ) | ||||
|     } | ||||
|  | ||||
|     @Composable | ||||
|     fun SyncConfirmationDialog( | ||||
|         onConfirm: () -> Unit, | ||||
|         onDismissRequest: () -> Unit, | ||||
|     ) { | ||||
|         AlertDialog( | ||||
|             onDismissRequest = onDismissRequest, | ||||
|             title = { Text(text = stringResource(R.string.pref_sync_confirmation_title)) }, | ||||
|             text = { Text(text = stringResource(R.string.pref_sync_confirmation_message)) }, | ||||
|             dismissButton = { | ||||
|                 TextButton(onClick = onDismissRequest) { | ||||
|                     Text(text = stringResource(R.string.action_cancel)) | ||||
|                 } | ||||
|             }, | ||||
|             confirmButton = { | ||||
|                 TextButton(onClick = onConfirm) { | ||||
|                     Text(text = stringResource(android.R.string.ok)) | ||||
|                 } | ||||
|             }, | ||||
|         ) | ||||
|     } | ||||
| } | ||||
| @@ -48,6 +48,7 @@ import tachiyomi.domain.backup.service.BackupPreferences | ||||
| import tachiyomi.domain.download.service.DownloadPreferences | ||||
| import tachiyomi.domain.library.service.LibraryPreferences | ||||
| import tachiyomi.domain.source.service.SourceManager | ||||
| import tachiyomi.domain.sync.SyncPreferences | ||||
| import tachiyomi.source.local.image.LocalCoverManager | ||||
| import tachiyomi.source.local.io.LocalSourceFileSystem | ||||
| import uy.kohesive.injekt.api.InjektModule | ||||
| @@ -197,6 +198,9 @@ class PreferenceModule(val application: Application) : InjektModule { | ||||
|                 preferenceStore = get(), | ||||
|             ) | ||||
|         } | ||||
|         addSingletonFactory { | ||||
|             SyncPreferences(get()) | ||||
|         } | ||||
|         addSingletonFactory { | ||||
|             UiPreferences(get()) | ||||
|         } | ||||
|   | ||||
| @@ -5,7 +5,6 @@ import android.content.Context | ||||
| import android.net.Uri | ||||
| import com.hippo.unifile.UniFile | ||||
| import eu.kanade.domain.chapter.model.copyFrom | ||||
| import eu.kanade.domain.manga.model.copyFrom | ||||
| import eu.kanade.tachiyomi.R | ||||
| import eu.kanade.tachiyomi.data.backup.BackupConst.BACKUP_CATEGORY | ||||
| import eu.kanade.tachiyomi.data.backup.BackupConst.BACKUP_CATEGORY_MASK | ||||
| @@ -134,7 +133,7 @@ class BackupManager( | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private fun backupExtensionInfo(mangas: List<Manga>): List<BackupSource> { | ||||
|     fun backupExtensionInfo(mangas: List<Manga>): List<BackupSource> { | ||||
|         return mangas | ||||
|             .asSequence() | ||||
|             .map(Manga::source) | ||||
| @@ -149,7 +148,7 @@ class BackupManager( | ||||
|      * | ||||
|      * @return list of [BackupCategory] to be backed up | ||||
|      */ | ||||
|     private suspend fun backupCategories(options: Int): List<BackupCategory> { | ||||
|     suspend fun backupCategories(options: Int): List<BackupCategory> { | ||||
|         // Check if user wants category information in backup | ||||
|         return if (options and BACKUP_CATEGORY_MASK == BACKUP_CATEGORY) { | ||||
|             getCategories.await() | ||||
| @@ -160,7 +159,7 @@ class BackupManager( | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private suspend fun backupMangas(mangas: List<Manga>, flags: Int): List<BackupManga> { | ||||
|     suspend fun backupMangas(mangas: List<Manga>, flags: Int): List<BackupManga> { | ||||
|         return mangas.map { | ||||
|             backupManga(it, flags) | ||||
|         } | ||||
| @@ -455,7 +454,7 @@ class BackupManager( | ||||
|                 updatedChapter = updatedChapter.copy(id = dbChapter._id) | ||||
|                 updatedChapter = updatedChapter.copyFrom(dbChapter) | ||||
|                 if (dbChapter.read && !updatedChapter.read) { | ||||
|                     updatedChapter = updatedChapter.copy(read = true, lastPageRead = dbChapter.last_page_read) | ||||
|                     updatedChapter = updatedChapter.copy(read = chapter.read, lastPageRead = dbChapter.last_page_read) | ||||
|                 } else if (updatedChapter.lastPageRead == 0L && dbChapter.last_page_read != 0L) { | ||||
|                     updatedChapter = updatedChapter.copy(lastPageRead = dbChapter.last_page_read) | ||||
|                 } | ||||
| @@ -513,7 +512,7 @@ class BackupManager( | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private suspend fun updateManga(manga: Manga): Long { | ||||
|     suspend fun updateManga(manga: Manga): Long { | ||||
|         handler.await(true) { | ||||
|             mangasQueries.update( | ||||
|                 source = manga.source, | ||||
|   | ||||
| @@ -79,9 +79,9 @@ class BackupNotifier(private val context: Context) { | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     fun showRestoreProgress(content: String = "", progress: Int = 0, maxAmount: Int = 100): NotificationCompat.Builder { | ||||
|     fun showRestoreProgress(content: String = "", contentTitle: String = context.getString(R.string.restoring_backup), progress: Int = 0, maxAmount: Int = 100): NotificationCompat.Builder { | ||||
|         val builder = with(progressNotificationBuilder) { | ||||
|             setContentTitle(context.getString(R.string.restoring_backup)) | ||||
|             setContentTitle(contentTitle) | ||||
|  | ||||
|             if (!preferences.hideNotificationContent().get()) { | ||||
|                 setContentText(content) | ||||
| @@ -114,7 +114,7 @@ class BackupNotifier(private val context: Context) { | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     fun showRestoreComplete(time: Long, errorCount: Int, path: String?, file: String?) { | ||||
|     fun showRestoreComplete(time: Long, errorCount: Int, path: String?, file: String?, contentTitle: String = context.getString(R.string.restore_completed)) { | ||||
|         context.cancelNotification(Notifications.ID_RESTORE_PROGRESS) | ||||
|  | ||||
|         val timeString = context.getString( | ||||
| @@ -126,7 +126,7 @@ class BackupNotifier(private val context: Context) { | ||||
|         ) | ||||
|  | ||||
|         with(completeNotificationBuilder) { | ||||
|             setContentTitle(context.getString(R.string.restore_completed)) | ||||
|             setContentTitle(contentTitle) | ||||
|             setContentText(context.resources.getQuantityString(R.plurals.restore_completed_message, errorCount, timeString, errorCount)) | ||||
|  | ||||
|             clearActions() | ||||
|   | ||||
| @@ -11,6 +11,7 @@ import androidx.work.WorkerParameters | ||||
| import androidx.work.workDataOf | ||||
| import eu.kanade.tachiyomi.R | ||||
| import eu.kanade.tachiyomi.data.notification.Notifications | ||||
| import eu.kanade.tachiyomi.data.sync.SyncHolder | ||||
| import eu.kanade.tachiyomi.util.system.cancelNotification | ||||
| import eu.kanade.tachiyomi.util.system.isRunning | ||||
| import eu.kanade.tachiyomi.util.system.workManager | ||||
| @@ -26,6 +27,8 @@ class BackupRestoreJob(private val context: Context, workerParams: WorkerParamet | ||||
|     override suspend fun doWork(): Result { | ||||
|         val uri = inputData.getString(LOCATION_URI_KEY)?.toUri() | ||||
|             ?: return Result.failure() | ||||
|         val sync = inputData.getBoolean(SYNC, false) | ||||
|         val useBackupHolder = inputData.getBoolean(USE_BACKUP_HOLDER, false) | ||||
|  | ||||
|         try { | ||||
|             setForeground(getForegroundInfo()) | ||||
| @@ -35,7 +38,12 @@ class BackupRestoreJob(private val context: Context, workerParams: WorkerParamet | ||||
|  | ||||
|         return try { | ||||
|             val restorer = BackupRestorer(context, notifier) | ||||
|             restorer.restoreBackup(uri) | ||||
|             if (useBackupHolder) { | ||||
|                 restorer.restoreBackup(uri, sync) | ||||
|                 SyncHolder.backup = null | ||||
|             } else { | ||||
|                 restorer.restoreBackup(uri, sync) | ||||
|             } | ||||
|             Result.success() | ||||
|         } catch (e: Exception) { | ||||
|             if (e is CancellationException) { | ||||
| @@ -63,9 +71,11 @@ class BackupRestoreJob(private val context: Context, workerParams: WorkerParamet | ||||
|             return context.workManager.isRunning(TAG) | ||||
|         } | ||||
|  | ||||
|         fun start(context: Context, uri: Uri) { | ||||
|         fun start(context: Context, uri: Uri, sync: Boolean = false, useBackupHolder: Boolean = false) { | ||||
|             val inputData = workDataOf( | ||||
|                 LOCATION_URI_KEY to uri.toString(), | ||||
|                 SYNC to sync, | ||||
|                 USE_BACKUP_HOLDER to useBackupHolder, | ||||
|             ) | ||||
|             val request = OneTimeWorkRequestBuilder<BackupRestoreJob>() | ||||
|                 .addTag(TAG) | ||||
| @@ -83,3 +93,7 @@ class BackupRestoreJob(private val context: Context, workerParams: WorkerParamet | ||||
| private const val TAG = "BackupRestore" | ||||
|  | ||||
| private const val LOCATION_URI_KEY = "location_uri" // String | ||||
|  | ||||
| private const val SYNC = "sync" // Boolean | ||||
|  | ||||
| private const val USE_BACKUP_HOLDER = "use_backup_holder" // Boolean | ||||
|   | ||||
| @@ -7,6 +7,7 @@ import eu.kanade.tachiyomi.data.backup.models.BackupCategory | ||||
| import eu.kanade.tachiyomi.data.backup.models.BackupHistory | ||||
| import eu.kanade.tachiyomi.data.backup.models.BackupManga | ||||
| import eu.kanade.tachiyomi.data.backup.models.BackupSource | ||||
| import eu.kanade.tachiyomi.data.sync.SyncHolder | ||||
| import eu.kanade.tachiyomi.util.BackupUtil | ||||
| import eu.kanade.tachiyomi.util.system.createFileInCacheDir | ||||
| import kotlinx.coroutines.coroutineScope | ||||
| @@ -36,12 +37,12 @@ class BackupRestorer( | ||||
|  | ||||
|     private val errors = mutableListOf<Pair<Date, String>>() | ||||
|  | ||||
|     suspend fun restoreBackup(uri: Uri): Boolean { | ||||
|     suspend fun restoreBackup(uri: Uri, sync: Boolean): Boolean { | ||||
|         val startTime = System.currentTimeMillis() | ||||
|         restoreProgress = 0 | ||||
|         errors.clear() | ||||
|  | ||||
|         if (!performRestore(uri)) { | ||||
|         if (!performRestore(uri, sync)) { | ||||
|             return false | ||||
|         } | ||||
|  | ||||
| @@ -50,7 +51,11 @@ class BackupRestorer( | ||||
|  | ||||
|         val logFile = writeErrorLog() | ||||
|  | ||||
|         notifier.showRestoreComplete(time, errors.size, logFile.parent, logFile.name) | ||||
|         if (sync) { | ||||
|             notifier.showRestoreComplete(time, errors.size, logFile.parent, logFile.name, contentTitle = context.getString(R.string.sync_complete)) | ||||
|         } else { | ||||
|             notifier.showRestoreComplete(time, errors.size, logFile.parent, logFile.name) | ||||
|         } | ||||
|         return true | ||||
|     } | ||||
|  | ||||
| @@ -73,8 +78,12 @@ class BackupRestorer( | ||||
|         return File("") | ||||
|     } | ||||
|  | ||||
|     private suspend fun performRestore(uri: Uri): Boolean { | ||||
|         val backup = BackupUtil.decodeBackup(context, uri) | ||||
|     private suspend fun performRestore(uri: Uri, sync: Boolean): Boolean { | ||||
|         val backup = if (sync) { | ||||
|             SyncHolder.backup ?: throw IllegalStateException("syncBackup cannot be null when sync is true") | ||||
|         } else { | ||||
|             BackupUtil.decodeBackup(context, uri) | ||||
|         } | ||||
|  | ||||
|         restoreAmount = backup.backupManga.size + 1 // +1 for categories | ||||
|  | ||||
| @@ -94,7 +103,7 @@ class BackupRestorer( | ||||
|                     return@coroutineScope false | ||||
|                 } | ||||
|  | ||||
|                 restoreManga(it, backup.backupCategories) | ||||
|                 restoreManga(it, backup.backupCategories, sync) | ||||
|             } | ||||
|             // TODO: optionally trigger online library + tracker update | ||||
|             true | ||||
| @@ -105,10 +114,10 @@ class BackupRestorer( | ||||
|         backupManager.restoreCategories(backupCategories) | ||||
|  | ||||
|         restoreProgress += 1 | ||||
|         showRestoreProgress(restoreProgress, restoreAmount, context.getString(R.string.categories)) | ||||
|         showRestoreProgress(restoreProgress, restoreAmount, context.getString(R.string.categories), context.getString(R.string.restoring_backup)) | ||||
|     } | ||||
|  | ||||
|     private suspend fun restoreManga(backupManga: BackupManga, backupCategories: List<BackupCategory>) { | ||||
|     private suspend fun restoreManga(backupManga: BackupManga, backupCategories: List<BackupCategory>, sync: Boolean) { | ||||
|         val manga = backupManga.getMangaImpl() | ||||
|         val chapters = backupManga.getChaptersImpl() | ||||
|         val categories = backupManga.categories.map { it.toInt() } | ||||
| @@ -134,7 +143,11 @@ class BackupRestorer( | ||||
|         } | ||||
|  | ||||
|         restoreProgress += 1 | ||||
|         showRestoreProgress(restoreProgress, restoreAmount, manga.title) | ||||
|         if (sync) { | ||||
|             showRestoreProgress(restoreProgress, restoreAmount, manga.title, context.getString(R.string.syncing_data)) | ||||
|         } else { | ||||
|             showRestoreProgress(restoreProgress, restoreAmount, manga.title, context.getString(R.string.restoring_backup)) | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
| @@ -182,7 +195,7 @@ class BackupRestorer( | ||||
|      * @param amount total restoreAmount of manga | ||||
|      * @param title title of restored manga | ||||
|      */ | ||||
|     private fun showRestoreProgress(progress: Int, amount: Int, title: String) { | ||||
|         notifier.showRestoreProgress(title, progress, amount) | ||||
|     private fun showRestoreProgress(progress: Int, amount: Int, title: String, contentTitle: String) { | ||||
|         notifier.showRestoreProgress(title, contentTitle, progress, amount) | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -20,6 +20,7 @@ data class BackupChapter( | ||||
|     // chapterNumber is called number is 1.x | ||||
|     @ProtoNumber(9) var chapterNumber: Float = 0F, | ||||
|     @ProtoNumber(10) var sourceOrder: Long = 0, | ||||
|     @ProtoNumber(11) var lastModifiedAt: Long? = null, | ||||
| ) { | ||||
|     fun toChapterImpl(): Chapter { | ||||
|         return Chapter.create().copy( | ||||
| @@ -33,11 +34,12 @@ data class BackupChapter( | ||||
|             dateFetch = this@BackupChapter.dateFetch, | ||||
|             dateUpload = this@BackupChapter.dateUpload, | ||||
|             sourceOrder = this@BackupChapter.sourceOrder, | ||||
|             lastModifiedAt = this@BackupChapter.lastModifiedAt, | ||||
|         ) | ||||
|     } | ||||
| } | ||||
|  | ||||
| val backupChapterMapper = { _: Long, _: Long, url: String, name: String, scanlator: String?, read: Boolean, bookmark: Boolean, lastPageRead: Long, chapterNumber: Float, source_order: Long, dateFetch: Long, dateUpload: Long -> | ||||
| val backupChapterMapper = { _: Long, _: Long, url: String, name: String, scanlator: String?, read: Boolean, bookmark: Boolean, lastPageRead: Long, chapterNumber: Float, source_order: Long, dateFetch: Long, dateUpload: Long, lastModifiedAt: Long? -> | ||||
|     BackupChapter( | ||||
|         url = url, | ||||
|         name = name, | ||||
| @@ -49,5 +51,6 @@ val backupChapterMapper = { _: Long, _: Long, url: String, name: String, scanlat | ||||
|         dateFetch = dateFetch, | ||||
|         dateUpload = dateUpload, | ||||
|         sourceOrder = source_order, | ||||
|         lastModifiedAt = lastModifiedAt, | ||||
|     ) | ||||
| } | ||||
|   | ||||
| @@ -39,6 +39,7 @@ data class BackupManga( | ||||
|     @ProtoNumber(103) var viewer_flags: Int? = null, | ||||
|     @ProtoNumber(104) var history: List<BackupHistory> = emptyList(), | ||||
|     @ProtoNumber(105) var updateStrategy: UpdateStrategy = UpdateStrategy.ALWAYS_UPDATE, | ||||
|     @ProtoNumber(106) var lastModifiedAt: Long? = 0, | ||||
| ) { | ||||
|     fun getMangaImpl(): Manga { | ||||
|         return Manga.create().copy( | ||||
| @@ -56,6 +57,7 @@ data class BackupManga( | ||||
|             viewerFlags = (this@BackupManga.viewer_flags ?: this@BackupManga.viewer).toLong(), | ||||
|             chapterFlags = this@BackupManga.chapterFlags.toLong(), | ||||
|             updateStrategy = this@BackupManga.updateStrategy, | ||||
|             lastModifiedAt = this@BackupManga.lastModifiedAt, | ||||
|         ) | ||||
|     } | ||||
|  | ||||
| @@ -89,6 +91,7 @@ data class BackupManga( | ||||
|                 viewer_flags = manga.viewerFlags.toInt(), | ||||
|                 chapterFlags = manga.chapterFlags.toInt(), | ||||
|                 updateStrategy = manga.updateStrategy, | ||||
|                 lastModifiedAt = manga.lastModifiedAt, | ||||
|             ) | ||||
|         } | ||||
|     } | ||||
|   | ||||
| @@ -19,6 +19,8 @@ interface Chapter : SChapter, Serializable { | ||||
|     var date_fetch: Long | ||||
|  | ||||
|     var source_order: Int | ||||
|  | ||||
|     var last_modified: Long? | ||||
| } | ||||
|  | ||||
| fun Chapter.toDomainChapter(): DomainChapter? { | ||||
| @@ -36,5 +38,6 @@ fun Chapter.toDomainChapter(): DomainChapter? { | ||||
|         dateUpload = date_upload, | ||||
|         chapterNumber = chapter_number, | ||||
|         scanlator = scanlator, | ||||
|         lastModifiedAt = last_modified, | ||||
|     ) | ||||
| } | ||||
|   | ||||
| @@ -26,6 +26,8 @@ class ChapterImpl : Chapter { | ||||
|  | ||||
|     override var source_order: Int = 0 | ||||
|  | ||||
|     override var last_modified: Long? = null | ||||
|  | ||||
|     override fun equals(other: Any?): Boolean { | ||||
|         if (this === other) return true | ||||
|         if (other == null || javaClass != other.javaClass) return false | ||||
|   | ||||
| @@ -11,6 +11,7 @@ import eu.kanade.tachiyomi.R | ||||
| import eu.kanade.tachiyomi.data.backup.BackupRestoreJob | ||||
| import eu.kanade.tachiyomi.data.download.DownloadManager | ||||
| import eu.kanade.tachiyomi.data.library.LibraryUpdateJob | ||||
| import eu.kanade.tachiyomi.data.sync.SyncDataJob | ||||
| import eu.kanade.tachiyomi.data.updater.AppUpdateService | ||||
| import eu.kanade.tachiyomi.ui.main.MainActivity | ||||
| import eu.kanade.tachiyomi.ui.reader.ReaderActivity | ||||
| @@ -83,6 +84,8 @@ class NotificationReceiver : BroadcastReceiver() { | ||||
|                     intent.getIntExtra(EXTRA_NOTIFICATION_ID, -1), | ||||
|                 ) | ||||
|             ACTION_CANCEL_RESTORE -> cancelRestore(context) | ||||
|  | ||||
|             ACTION_CANCEL_SYNC -> cancelSync(context) | ||||
|             // Cancel library update and dismiss notification | ||||
|             ACTION_CANCEL_LIBRARY_UPDATE -> cancelLibraryUpdate(context) | ||||
|             // Cancel downloading app update | ||||
| @@ -213,6 +216,15 @@ class NotificationReceiver : BroadcastReceiver() { | ||||
|         AppUpdateService.stop(context) | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Method called when user wants to stop a backup restore job. | ||||
|      * | ||||
|      * @param context context of application | ||||
|      */ | ||||
|     private fun cancelSync(context: Context) { | ||||
|         SyncDataJob.stop(context) | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Method called when user wants to mark manga chapters as read | ||||
|      * | ||||
| @@ -266,6 +278,8 @@ class NotificationReceiver : BroadcastReceiver() { | ||||
|  | ||||
|         private const val ACTION_CANCEL_RESTORE = "$ID.$NAME.CANCEL_RESTORE" | ||||
|  | ||||
|         private const val ACTION_CANCEL_SYNC = "$ID.$NAME.CANCEL_SYNC" | ||||
|  | ||||
|         private const val ACTION_CANCEL_LIBRARY_UPDATE = "$ID.$NAME.CANCEL_LIBRARY_UPDATE" | ||||
|  | ||||
|         private const val ACTION_CANCEL_APP_UPDATE_DOWNLOAD = "$ID.$NAME.CANCEL_APP_UPDATE_DOWNLOAD" | ||||
| @@ -570,5 +584,20 @@ class NotificationReceiver : BroadcastReceiver() { | ||||
|             } | ||||
|             return PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) | ||||
|         } | ||||
|  | ||||
|         /** | ||||
|          * Returns [PendingIntent] that cancels a sync restore job. | ||||
|          * | ||||
|          * @param context context of application | ||||
|          * @param notificationId id of notification | ||||
|          * @return [PendingIntent] | ||||
|          */ | ||||
|         internal fun cancelSyncPendingBroadcast(context: Context, notificationId: Int): PendingIntent { | ||||
|             val intent = Intent(context, NotificationReceiver::class.java).apply { | ||||
|                 action = ACTION_CANCEL_SYNC | ||||
|                 putExtra(EXTRA_NOTIFICATION_ID, notificationId) | ||||
|             } | ||||
|             return PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) | ||||
|         } | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -0,0 +1,99 @@ | ||||
| package eu.kanade.tachiyomi.data.sync | ||||
|  | ||||
| import android.content.Context | ||||
| import androidx.work.CoroutineWorker | ||||
| import androidx.work.ExistingPeriodicWorkPolicy | ||||
| import androidx.work.ExistingWorkPolicy | ||||
| import androidx.work.ForegroundInfo | ||||
| import androidx.work.OneTimeWorkRequestBuilder | ||||
| import androidx.work.PeriodicWorkRequestBuilder | ||||
| import androidx.work.WorkerParameters | ||||
| import eu.kanade.tachiyomi.data.notification.Notifications | ||||
| import eu.kanade.tachiyomi.util.system.cancelNotification | ||||
| import eu.kanade.tachiyomi.util.system.isRunning | ||||
| import eu.kanade.tachiyomi.util.system.workManager | ||||
| import logcat.LogPriority | ||||
| import tachiyomi.core.util.system.logcat | ||||
| import tachiyomi.domain.sync.SyncPreferences | ||||
| import uy.kohesive.injekt.Injekt | ||||
| import uy.kohesive.injekt.api.get | ||||
| import java.util.concurrent.TimeUnit | ||||
| import kotlin.random.Random | ||||
|  | ||||
| class SyncDataJob(private val context: Context, workerParams: WorkerParameters) : | ||||
|     CoroutineWorker(context, workerParams) { | ||||
|  | ||||
|     private val notifier = SyncNotifier(context) | ||||
|  | ||||
|     override suspend fun doWork(): Result { | ||||
|         try { | ||||
|             setForeground(getForegroundInfo()) | ||||
|         } catch (e: IllegalStateException) { | ||||
|             logcat(LogPriority.ERROR, e) { "Not allowed to run on foreground service" } | ||||
|         } | ||||
|  | ||||
|         return try { | ||||
|             SyncManager(context).syncData() | ||||
|             Result.success() | ||||
|         } catch (e: Exception) { | ||||
|             logcat(LogPriority.ERROR, e) | ||||
|             notifier.showSyncError(e.message) | ||||
|             Result.failure() | ||||
|         } finally { | ||||
|             context.cancelNotification(Notifications.ID_RESTORE_PROGRESS) | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     override suspend fun getForegroundInfo(): ForegroundInfo { | ||||
|         return ForegroundInfo( | ||||
|             Notifications.ID_RESTORE_PROGRESS, | ||||
|             notifier.showSyncProgress().build(), | ||||
|         ) | ||||
|     } | ||||
|  | ||||
|     companion object { | ||||
|         private const val TAG_JOB = "SyncDataJob" | ||||
|         private const val TAG_AUTO = "$TAG_JOB:auto" | ||||
|         private const val TAG_MANUAL = "$TAG_JOB:manual" | ||||
|  | ||||
|         fun isManualJobRunning(context: Context): Boolean { | ||||
|             return context.workManager.isRunning(TAG_MANUAL) | ||||
|         } | ||||
|  | ||||
|         fun setupTask(context: Context, prefInterval: Int? = null) { | ||||
|             val syncPreferences = Injekt.get<SyncPreferences>() | ||||
|             val interval = prefInterval ?: syncPreferences.syncInterval().get() | ||||
|             if (interval > 0) { | ||||
|                 // Generate a random delay in minutes (e.g., between 0 and 15 minutes) to avoid conflicts. | ||||
|                 val randomDelay = Random.nextInt(0, 16) | ||||
|  | ||||
|                 val randomDelayMillis = TimeUnit.MINUTES.toMillis(randomDelay.toLong()) | ||||
|  | ||||
|                 val request = PeriodicWorkRequestBuilder<SyncDataJob>( | ||||
|                     interval.toLong(), | ||||
|                     TimeUnit.MINUTES, | ||||
|                     10, | ||||
|                     TimeUnit.MINUTES, | ||||
|                 ) | ||||
|                     .addTag(TAG_AUTO) | ||||
|                     .setInitialDelay(randomDelayMillis, TimeUnit.MILLISECONDS) | ||||
|                     .build() | ||||
|  | ||||
|                 context.workManager.enqueueUniquePeriodicWork(TAG_AUTO, ExistingPeriodicWorkPolicy.UPDATE, request) | ||||
|             } else { | ||||
|                 context.workManager.cancelUniqueWork(TAG_AUTO) | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         fun startNow(context: Context) { | ||||
|             val request = OneTimeWorkRequestBuilder<SyncDataJob>() | ||||
|                 .addTag(TAG_MANUAL) | ||||
|                 .build() | ||||
|             context.workManager.enqueueUniqueWork(TAG_MANUAL, ExistingWorkPolicy.KEEP, request) | ||||
|         } | ||||
|  | ||||
|         fun stop(context: Context) { | ||||
|             context.workManager.cancelUniqueWork(TAG_MANUAL) | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,7 @@ | ||||
| package eu.kanade.tachiyomi.data.sync | ||||
|  | ||||
| import eu.kanade.tachiyomi.data.backup.models.Backup | ||||
|  | ||||
| object SyncHolder { | ||||
|     var backup: Backup? = null | ||||
| } | ||||
							
								
								
									
										209
									
								
								app/src/main/java/eu/kanade/tachiyomi/data/sync/SyncManager.kt
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										209
									
								
								app/src/main/java/eu/kanade/tachiyomi/data/sync/SyncManager.kt
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,209 @@ | ||||
| package eu.kanade.tachiyomi.data.sync | ||||
|  | ||||
| import android.content.Context | ||||
| import androidx.core.net.toUri | ||||
| import eu.kanade.tachiyomi.data.backup.BackupConst.BACKUP_ALL | ||||
| import eu.kanade.tachiyomi.data.backup.BackupManager | ||||
| import eu.kanade.tachiyomi.data.backup.BackupRestoreJob | ||||
| import eu.kanade.tachiyomi.data.backup.models.Backup | ||||
| import eu.kanade.tachiyomi.data.backup.models.BackupChapter | ||||
| import eu.kanade.tachiyomi.data.backup.models.BackupManga | ||||
| import eu.kanade.tachiyomi.data.sync.models.SData | ||||
| import eu.kanade.tachiyomi.data.sync.models.SyncDevice | ||||
| import eu.kanade.tachiyomi.data.sync.models.SyncStatus | ||||
| import eu.kanade.tachiyomi.data.sync.service.SyncYomiSyncService | ||||
| import kotlinx.serialization.json.Json | ||||
| import logcat.LogPriority | ||||
| import tachiyomi.core.util.system.logcat | ||||
| import tachiyomi.data.Chapters | ||||
| import tachiyomi.data.DatabaseHandler | ||||
| import tachiyomi.data.manga.mangaMapper | ||||
| import tachiyomi.domain.category.interactor.GetCategories | ||||
| import tachiyomi.domain.manga.interactor.GetFavorites | ||||
| import tachiyomi.domain.manga.model.Manga | ||||
| import tachiyomi.domain.sync.SyncPreferences | ||||
| import uy.kohesive.injekt.Injekt | ||||
| import uy.kohesive.injekt.api.get | ||||
| import java.time.Instant | ||||
|  | ||||
| /** | ||||
|  * A manager to handle synchronization tasks in the app, such as updating | ||||
|  * sync preferences and performing synchronization with a remote server. | ||||
|  * | ||||
|  * @property context The application context. | ||||
|  */ | ||||
| class SyncManager( | ||||
|     private val context: Context, | ||||
|     private val handler: DatabaseHandler = Injekt.get(), | ||||
|     private val syncPreferences: SyncPreferences = Injekt.get(), | ||||
|     private var json: Json = Json { | ||||
|         encodeDefaults = true | ||||
|         ignoreUnknownKeys = true | ||||
|     }, | ||||
|     private val getFavorites: GetFavorites = Injekt.get(), | ||||
|     private val getCategories: GetCategories = Injekt.get(), | ||||
|  | ||||
| ) { | ||||
|     private val backupManager: BackupManager = BackupManager(context) | ||||
|     private val notifier: SyncNotifier = SyncNotifier(context) | ||||
|  | ||||
|     enum class SyncService(val value: Int) { | ||||
|         NONE(0), | ||||
|         SELF_HOSTED(1), | ||||
|         ; | ||||
|  | ||||
|         companion object { | ||||
|             fun fromInt(value: Int) = values().firstOrNull { it.value == value } ?: NONE | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Syncs data with a sync service. | ||||
|      * | ||||
|      * This function retrieves local data (favorites, manga, extensions, and categories) | ||||
|      * from the database using the BackupManager, then synchronizes the data with a sync service. | ||||
|      */ | ||||
|     suspend fun syncData() { | ||||
|         val databaseManga = getAllMangaFromDB() | ||||
|  | ||||
|         logcat(LogPriority.DEBUG) { "Mangas to sync: $databaseManga" } | ||||
|         val backup = Backup( | ||||
|             backupManager.backupMangas(databaseManga, BACKUP_ALL), | ||||
|             backupManager.backupCategories(BACKUP_ALL), | ||||
|             emptyList(), | ||||
|             backupManager.backupExtensionInfo(databaseManga), | ||||
|         ) | ||||
|  | ||||
|         // Create the SyncStatus object | ||||
|         val syncStatus = SyncStatus( | ||||
|             lastSynced = Instant.now().toString(), | ||||
|             status = "completed", | ||||
|         ) | ||||
|  | ||||
|         // Create the Device object | ||||
|         val device = SyncDevice( | ||||
|             id = syncPreferences.deviceID().get(), | ||||
|             name = syncPreferences.deviceName().get(), | ||||
|         ) | ||||
|  | ||||
|         // Create the SyncData object | ||||
|         val syncData = SData( | ||||
|             sync = syncStatus, | ||||
|             backup = backup, | ||||
|             device = device, | ||||
|         ) | ||||
|  | ||||
|         // Handle sync based on the selected service | ||||
|         val syncService = when (val syncService = SyncService.fromInt(syncPreferences.syncService().get())) { | ||||
|             SyncService.SELF_HOSTED -> { | ||||
|                 SyncYomiSyncService( | ||||
|                     context, | ||||
|                     json, | ||||
|                     syncPreferences, | ||||
|                     notifier, | ||||
|                 ) | ||||
|             } | ||||
|  | ||||
|             else -> { | ||||
|                 logcat(LogPriority.ERROR) { "Invalid sync service type: $syncService" } | ||||
|                 null | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         val remoteBackup = syncService?.doSync(syncData) | ||||
|  | ||||
|         if (remoteBackup != null) { | ||||
|             val (filteredFavorites, nonFavorites) = filterFavoritesAndNonFavorites(remoteBackup) | ||||
|             updateNonFavorites(nonFavorites) | ||||
|             SyncHolder.backup = backup.copy(backupManga = filteredFavorites) | ||||
|             BackupRestoreJob.start(context, "".toUri(), true) | ||||
|             syncPreferences.syncLastSync().set(Instant.now()) | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Retrieves all manga from the local database. | ||||
|      * | ||||
|      * @return a list of all manga stored in the database | ||||
|      */ | ||||
|     private suspend fun getAllMangaFromDB(): List<Manga> { | ||||
|         return handler.awaitList { mangasQueries.getAllManga(mangaMapper) } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Compares two Manga objects (one from the local database and one from the backup) to check if they are different. | ||||
|      * @param localManga the Manga object from the local database. | ||||
|      * @param remoteManga the BackupManga object from the backup. | ||||
|      * @return true if the Manga objects are different, otherwise false. | ||||
|      */ | ||||
|     private suspend fun isMangaDifferent(localManga: Manga, remoteManga: BackupManga): Boolean { | ||||
|         val localChapters = handler.await { chaptersQueries.getChaptersByMangaId(localManga.id).executeAsList() } | ||||
|         val localCategories = getCategories.await(localManga.id).map { it.order } | ||||
|  | ||||
|         return localManga.source != remoteManga.source || localManga.url != remoteManga.url || localManga.title != remoteManga.title || localManga.artist != remoteManga.artist || localManga.author != remoteManga.author || localManga.description != remoteManga.description || localManga.genre != remoteManga.genre || localManga.status.toInt() != remoteManga.status || localManga.thumbnailUrl != remoteManga.thumbnailUrl || localManga.dateAdded != remoteManga.dateAdded || localManga.chapterFlags.toInt() != remoteManga.chapterFlags || localManga.favorite != remoteManga.favorite || localManga.viewerFlags.toInt() != remoteManga.viewer_flags || localManga.updateStrategy != remoteManga.updateStrategy || areChaptersDifferent(localChapters, remoteManga.chapters) || localCategories != remoteManga.categories | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Compares two lists of chapters (one from the local database and one from the backup) to check if they are different. | ||||
|      * @param localChapters the list of chapters from the local database. | ||||
|      * @param remoteChapters the list of BackupChapter objects from the backup. | ||||
|      * @return true if the lists of chapters are different, otherwise false. | ||||
|      */ | ||||
|     private fun areChaptersDifferent(localChapters: List<Chapters>, remoteChapters: List<BackupChapter>): Boolean { | ||||
|         if (localChapters.size != remoteChapters.size) { | ||||
|             return true | ||||
|         } | ||||
|  | ||||
|         val localChapterMap = localChapters.associateBy { it.url } | ||||
|  | ||||
|         return remoteChapters.any { remoteChapter -> | ||||
|             localChapterMap[remoteChapter.url]?.let { localChapter -> | ||||
|                 localChapter.name != remoteChapter.name || localChapter.scanlator != remoteChapter.scanlator || localChapter.read != remoteChapter.read || localChapter.bookmark != remoteChapter.bookmark || localChapter.last_page_read != remoteChapter.lastPageRead || localChapter.date_fetch != remoteChapter.dateFetch || localChapter.date_upload != remoteChapter.dateUpload || localChapter.chapter_number != remoteChapter.chapterNumber || localChapter.source_order != remoteChapter.sourceOrder | ||||
|             } ?: true | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Filters the favorite and non-favorite manga from the backup and checks if the favorite manga is different from the local database. | ||||
|      * @param backup the Backup object containing the backup data. | ||||
|      * @return a Pair of lists, where the first list contains different favorite manga and the second list contains non-favorite manga. | ||||
|      */ | ||||
|     private suspend fun filterFavoritesAndNonFavorites(backup: Backup): Pair<List<BackupManga>, List<BackupManga>> { | ||||
|         val databaseMangaFavorites = getFavorites.await() | ||||
|         val localMangaMap = databaseMangaFavorites.associateBy { it.url } | ||||
|         val favorites = mutableListOf<BackupManga>() | ||||
|         val nonFavorites = mutableListOf<BackupManga>() | ||||
|  | ||||
|         backup.backupManga.forEach { remoteManga -> | ||||
|             if (remoteManga.favorite) { | ||||
|                 localMangaMap[remoteManga.url]?.let { localManga -> | ||||
|                     if (isMangaDifferent(localManga, remoteManga)) { | ||||
|                         favorites.add(remoteManga) | ||||
|                     } | ||||
|                 } ?: favorites.add(remoteManga) | ||||
|             } else { | ||||
|                 nonFavorites.add(remoteManga) | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         return Pair(favorites, nonFavorites) | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Updates the non-favorite manga in the local database with their favorite status from the backup. | ||||
|      * @param nonFavorites the list of non-favorite BackupManga objects from the backup. | ||||
|      */ | ||||
|     private suspend fun updateNonFavorites(nonFavorites: List<BackupManga>) { | ||||
|         val localMangaList = getAllMangaFromDB() | ||||
|         val localMangaMap = localMangaList.associateBy { it.url } | ||||
|  | ||||
|         nonFavorites.forEach { nonFavorite -> | ||||
|             localMangaMap[nonFavorite.url]?.let { localManga -> | ||||
|                 if (localManga.favorite != nonFavorite.favorite) { | ||||
|                     val updatedManga = localManga.copy(favorite = nonFavorite.favorite) | ||||
|                     backupManager.updateManga(updatedManga) | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,71 @@ | ||||
| package eu.kanade.tachiyomi.data.sync | ||||
|  | ||||
| import android.content.Context | ||||
| import android.graphics.BitmapFactory | ||||
| import androidx.core.app.NotificationCompat | ||||
| import eu.kanade.tachiyomi.R | ||||
| import eu.kanade.tachiyomi.core.security.SecurityPreferences | ||||
| import eu.kanade.tachiyomi.data.notification.NotificationReceiver | ||||
| import eu.kanade.tachiyomi.data.notification.Notifications | ||||
| import eu.kanade.tachiyomi.util.system.cancelNotification | ||||
| import eu.kanade.tachiyomi.util.system.notificationBuilder | ||||
| import eu.kanade.tachiyomi.util.system.notify | ||||
| import uy.kohesive.injekt.injectLazy | ||||
|  | ||||
| class SyncNotifier(private val context: Context) { | ||||
|  | ||||
|     private val preferences: SecurityPreferences by injectLazy() | ||||
|  | ||||
|     private val progressNotificationBuilder = context.notificationBuilder(Notifications.CHANNEL_BACKUP_RESTORE_PROGRESS) { | ||||
|         setLargeIcon(BitmapFactory.decodeResource(context.resources, R.mipmap.ic_launcher)) | ||||
|         setSmallIcon(R.drawable.ic_tachi) | ||||
|         setAutoCancel(false) | ||||
|         setOngoing(true) | ||||
|         setOnlyAlertOnce(true) | ||||
|     } | ||||
|  | ||||
|     private val completeNotificationBuilder = context.notificationBuilder(Notifications.CHANNEL_BACKUP_RESTORE_PROGRESS) { | ||||
|         setLargeIcon(BitmapFactory.decodeResource(context.resources, R.mipmap.ic_launcher)) | ||||
|         setSmallIcon(R.drawable.ic_tachi) | ||||
|         setAutoCancel(false) | ||||
|     } | ||||
|  | ||||
|     private fun NotificationCompat.Builder.show(id: Int) { | ||||
|         context.notify(id, build()) | ||||
|     } | ||||
|  | ||||
|     fun showSyncProgress(content: String = "", progress: Int = 0, maxAmount: Int = 100): NotificationCompat.Builder { | ||||
|         val builder = with(progressNotificationBuilder) { | ||||
|             setContentTitle(context.getString(R.string.syncing_data)) | ||||
|  | ||||
|             if (!preferences.hideNotificationContent().get()) { | ||||
|                 setContentText(content) | ||||
|             } | ||||
|  | ||||
|             setProgress(maxAmount, progress, true) | ||||
|             setOnlyAlertOnce(true) | ||||
|  | ||||
|             clearActions() | ||||
|             addAction( | ||||
|                 R.drawable.ic_close_24dp, | ||||
|                 context.getString(R.string.action_cancel), | ||||
|                 NotificationReceiver.cancelSyncPendingBroadcast(context, Notifications.ID_RESTORE_PROGRESS), | ||||
|             ) | ||||
|         } | ||||
|  | ||||
|         builder.show(Notifications.ID_RESTORE_PROGRESS) | ||||
|  | ||||
|         return builder | ||||
|     } | ||||
|  | ||||
|     fun showSyncError(error: String?) { | ||||
|         context.cancelNotification(Notifications.ID_RESTORE_PROGRESS) | ||||
|  | ||||
|         with(completeNotificationBuilder) { | ||||
|             setContentTitle(context.getString(R.string.sync_error)) | ||||
|             setContentText(error) | ||||
|  | ||||
|             show(Notifications.ID_RESTORE_COMPLETE) | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,24 @@ | ||||
| package eu.kanade.tachiyomi.data.sync.models | ||||
|  | ||||
| import eu.kanade.tachiyomi.data.backup.models.Backup | ||||
| import kotlinx.serialization.SerialName | ||||
| import kotlinx.serialization.Serializable | ||||
|  | ||||
| @Serializable | ||||
| data class SyncStatus( | ||||
|     @SerialName("last_synced") val lastSynced: String? = null, | ||||
|     val status: String? = null, | ||||
| ) | ||||
|  | ||||
| @Serializable | ||||
| data class SyncDevice( | ||||
|     val id: Int? = null, | ||||
|     val name: String? = null, | ||||
| ) | ||||
|  | ||||
| @Serializable | ||||
| data class SData( | ||||
|     val sync: SyncStatus? = null, | ||||
|     val backup: Backup? = null, | ||||
|     val device: SyncDevice? = null, | ||||
| ) | ||||
| @@ -0,0 +1,26 @@ | ||||
| package eu.kanade.tachiyomi.data.sync.service | ||||
|  | ||||
| import android.content.Context | ||||
| import eu.kanade.tachiyomi.data.backup.models.Backup | ||||
| import eu.kanade.tachiyomi.data.sync.models.SData | ||||
| import kotlinx.serialization.json.Json | ||||
| import tachiyomi.domain.sync.SyncPreferences | ||||
|  | ||||
| abstract class SyncService( | ||||
|     val context: Context, | ||||
|     val json: Json, | ||||
|     val syncPreferences: SyncPreferences, | ||||
| ) { | ||||
|     abstract suspend fun doSync(syncData: SData): Backup? | ||||
|  | ||||
|     /** | ||||
|      * Decodes the given sync data string into a Backup object. | ||||
|      * | ||||
|      * @param data The sync data string to be decoded. | ||||
|      * @return The decoded Backup object. | ||||
|      */ | ||||
|     protected fun decodeSyncBackup(data: String): Backup { | ||||
|         val sData = json.decodeFromString(SData.serializer(), data) | ||||
|         return sData.backup!! | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,71 @@ | ||||
| package eu.kanade.tachiyomi.data.sync.service | ||||
|  | ||||
| import android.content.Context | ||||
| import eu.kanade.tachiyomi.data.backup.models.Backup | ||||
| import eu.kanade.tachiyomi.data.sync.SyncNotifier | ||||
| import eu.kanade.tachiyomi.data.sync.models.SData | ||||
| import eu.kanade.tachiyomi.network.POST | ||||
| import kotlinx.serialization.encodeToString | ||||
| import kotlinx.serialization.json.Json | ||||
| import logcat.LogPriority | ||||
| import okhttp3.Headers | ||||
| import okhttp3.MediaType.Companion.toMediaTypeOrNull | ||||
| import okhttp3.OkHttpClient | ||||
| import okhttp3.RequestBody.Companion.gzip | ||||
| import okhttp3.RequestBody.Companion.toRequestBody | ||||
| import tachiyomi.core.util.system.logcat | ||||
| import tachiyomi.domain.sync.SyncPreferences | ||||
|  | ||||
| class SyncYomiSyncService( | ||||
|     context: Context, | ||||
|     json: Json, | ||||
|     syncPreferences: SyncPreferences, | ||||
|     private val notifier: SyncNotifier, | ||||
| ) : SyncService(context, json, syncPreferences) { | ||||
|     override suspend fun doSync(syncData: SData): Backup? { | ||||
|         logcat( | ||||
|             LogPriority.DEBUG, | ||||
|         ) { "SyncYomi sync started!" } | ||||
|  | ||||
|         val jsonData = json.encodeToString(syncData) | ||||
|  | ||||
|         val host = syncPreferences.syncHost().get() | ||||
|         val apiKey = syncPreferences.syncAPIKey().get() | ||||
|         val url = "$host/api/sync/data" | ||||
|  | ||||
|         val client = OkHttpClient() | ||||
|         val mediaType = "application/gzip".toMediaTypeOrNull() | ||||
|         val body = jsonData.toRequestBody(mediaType).gzip() | ||||
|  | ||||
|         val headers = Headers.Builder().add("Content-Type", "application/gzip").add("Content-Encoding", "gzip").add("X-API-Token", apiKey).build() | ||||
|  | ||||
|         val request = POST( | ||||
|             url = url, | ||||
|             headers = headers, | ||||
|             body = body, | ||||
|         ) | ||||
|  | ||||
|         client.newCall(request).execute().use { response -> | ||||
|             val responseBody = response.body.string() | ||||
|  | ||||
|             if (response.isSuccessful) { | ||||
|                 val syncDataResponse: SData = json.decodeFromString(responseBody) | ||||
|  | ||||
|                 // If the device ID is 0 and not equal to the server device ID (this happens when the DB is fresh and the app is not), update it | ||||
|                 if (syncPreferences.deviceID().get() == 0 || syncPreferences.deviceID().get() != syncDataResponse.device?.id) { | ||||
|                     syncDataResponse.device?.id?.let { syncPreferences.deviceID().set(it) } | ||||
|                 } | ||||
|  | ||||
|                 logcat( | ||||
|                     LogPriority.DEBUG, | ||||
|                 ) { "SyncYomi sync completed!" } | ||||
|  | ||||
|                 return decodeSyncBackup(responseBody) | ||||
|             } else { | ||||
|                 notifier.showSyncError("Failed to sync: $responseBody") | ||||
|                 responseBody.let { logcat(LogPriority.ERROR) { "SyncError:$it" } } | ||||
|                 return null | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -72,6 +72,7 @@ object MoreTab : Tab { | ||||
|             onClickCategories = { navigator.push(CategoryScreen()) }, | ||||
|             onClickStats = { navigator.push(StatsScreen()) }, | ||||
|             onClickBackupAndRestore = { navigator.push(SettingsScreen.toBackupScreen()) }, | ||||
|             onClickSync = { navigator.push(SettingsScreen.toSyncScreen()) }, | ||||
|             onClickSettings = { navigator.push(SettingsScreen.toMainScreen()) }, | ||||
|             onClickAbout = { navigator.push(SettingsScreen.toAboutScreen()) }, | ||||
|         ) | ||||
|   | ||||
| @@ -16,6 +16,7 @@ import eu.kanade.presentation.more.settings.screen.AboutScreen | ||||
| import eu.kanade.presentation.more.settings.screen.SettingsAppearanceScreen | ||||
| import eu.kanade.presentation.more.settings.screen.SettingsBackupScreen | ||||
| import eu.kanade.presentation.more.settings.screen.SettingsMainScreen | ||||
| import eu.kanade.presentation.more.settings.screen.SettingsSyncScreen | ||||
| import eu.kanade.presentation.util.DefaultNavigatorScreenTransition | ||||
| import eu.kanade.presentation.util.LocalBackPress | ||||
| import eu.kanade.presentation.util.Screen | ||||
| @@ -24,6 +25,7 @@ import tachiyomi.presentation.core.components.TwoPanelBox | ||||
|  | ||||
| class SettingsScreen private constructor( | ||||
|     val toBackup: Boolean, | ||||
|     val toSync: Boolean, | ||||
|     val toAbout: Boolean, | ||||
| ) : Screen() { | ||||
|  | ||||
| @@ -34,6 +36,8 @@ class SettingsScreen private constructor( | ||||
|             Navigator( | ||||
|                 screen = if (toBackup) { | ||||
|                     SettingsBackupScreen | ||||
|                 } else if (toSync) { | ||||
|                     SettingsSyncScreen | ||||
|                 } else if (toAbout) { | ||||
|                     AboutScreen | ||||
|                 } else { | ||||
| @@ -56,6 +60,8 @@ class SettingsScreen private constructor( | ||||
|             Navigator( | ||||
|                 screen = if (toBackup) { | ||||
|                     SettingsBackupScreen | ||||
|                 } else if (toSync) { | ||||
|                     SettingsSyncScreen | ||||
|                 } else if (toAbout) { | ||||
|                     AboutScreen | ||||
|                 } else { | ||||
| @@ -79,10 +85,12 @@ class SettingsScreen private constructor( | ||||
|     } | ||||
|  | ||||
|     companion object { | ||||
|         fun toMainScreen() = SettingsScreen(toBackup = false, toAbout = false) | ||||
|         fun toMainScreen() = SettingsScreen(toBackup = false, toSync = false, toAbout = false) | ||||
|  | ||||
|         fun toBackupScreen() = SettingsScreen(toBackup = true, toAbout = false) | ||||
|         fun toBackupScreen() = SettingsScreen(toBackup = true, toSync = false, toAbout = false) | ||||
|  | ||||
|         fun toAboutScreen() = SettingsScreen(toBackup = false, toAbout = true) | ||||
|         fun toSyncScreen() = SettingsScreen(toBackup = false, toSync = true, toAbout = false) | ||||
|  | ||||
|         fun toAboutScreen() = SettingsScreen(toBackup = false, toSync = false, toAbout = true) | ||||
|     } | ||||
| } | ||||
|   | ||||
		Reference in New Issue
	
	Block a user