diff --git a/app/src/main/java/eu/kanade/domain/chapter/model/Chapter.kt b/app/src/main/java/eu/kanade/domain/chapter/model/Chapter.kt index b3c7edb71..fc22cae4f 100644 --- a/app/src/main/java/eu/kanade/domain/chapter/model/Chapter.kt +++ b/app/src/main/java/eu/kanade/domain/chapter/model/Chapter.kt @@ -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 } diff --git a/app/src/main/java/eu/kanade/presentation/more/MoreScreen.kt b/app/src/main/java/eu/kanade/presentation/more/MoreScreen.kt index 80f864de9..4ddf0a55d 100644 --- a/app/src/main/java/eu/kanade/presentation/more/MoreScreen.kt +++ b/app/src/main/java/eu/kanade/presentation/more/MoreScreen.kt @@ -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() } diff --git a/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsMainScreen.kt b/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsMainScreen.kt index c788640c7..c69d817a0 100644 --- a/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsMainScreen.kt +++ b/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsMainScreen.kt @@ -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, diff --git a/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsSearchScreen.kt b/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsSearchScreen.kt index cad51c044..2bd855214 100644 --- a/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsSearchScreen.kt +++ b/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsSearchScreen.kt @@ -291,6 +291,7 @@ private val settingScreens = listOf( SettingsTrackingScreen, SettingsBrowseScreen, SettingsBackupScreen, + SettingsSyncScreen, SettingsSecurityScreen, SettingsAdvancedScreen, ) diff --git a/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsSyncScreen.kt b/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsSyncScreen.kt new file mode 100644 index 000000000..e156eeee7 --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsSyncScreen.kt @@ -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 { + val syncPreferences = Injekt.get() + 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 { + 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 { + 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)) + } + }, + ) + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/AppModule.kt b/app/src/main/java/eu/kanade/tachiyomi/AppModule.kt index 41cdec738..c49a3cd7c 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/AppModule.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/AppModule.kt @@ -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()) } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupManager.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupManager.kt index 9b3a731d4..fdf29955c 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupManager.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupManager.kt @@ -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): List { + fun backupExtensionInfo(mangas: List): List { 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 { + suspend fun backupCategories(options: Int): List { // 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, flags: Int): List { + suspend fun backupMangas(mangas: List, flags: Int): List { 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, diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupNotifier.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupNotifier.kt index d7b139598..f75923278 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupNotifier.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupNotifier.kt @@ -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() diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupRestoreJob.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupRestoreJob.kt index 72d23e48a..cb9e8d37b 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupRestoreJob.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupRestoreJob.kt @@ -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() .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 diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupRestorer.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupRestorer.kt index 34583f9d1..8bda05a56 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupRestorer.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupRestorer.kt @@ -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>() - 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) { + private suspend fun restoreManga(backupManga: BackupManga, backupCategories: List, 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) } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/models/BackupChapter.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/models/BackupChapter.kt index a70121a8c..52db801d6 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/backup/models/BackupChapter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/backup/models/BackupChapter.kt @@ -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, ) } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/models/BackupManga.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/models/BackupManga.kt index a38d45197..f7913a616 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/backup/models/BackupManga.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/backup/models/BackupManga.kt @@ -39,6 +39,7 @@ data class BackupManga( @ProtoNumber(103) var viewer_flags: Int? = null, @ProtoNumber(104) var history: List = 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, ) } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/database/models/Chapter.kt b/app/src/main/java/eu/kanade/tachiyomi/data/database/models/Chapter.kt index 8ca265d6b..1167c0888 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/database/models/Chapter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/database/models/Chapter.kt @@ -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, ) } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/database/models/ChapterImpl.kt b/app/src/main/java/eu/kanade/tachiyomi/data/database/models/ChapterImpl.kt index a1a2d3f55..f5ad4c64b 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/database/models/ChapterImpl.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/database/models/ChapterImpl.kt @@ -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 diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/notification/NotificationReceiver.kt b/app/src/main/java/eu/kanade/tachiyomi/data/notification/NotificationReceiver.kt index 5110aaca6..ba9a6f8dd 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/notification/NotificationReceiver.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/notification/NotificationReceiver.kt @@ -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) + } } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/sync/SyncDataJob.kt b/app/src/main/java/eu/kanade/tachiyomi/data/sync/SyncDataJob.kt new file mode 100644 index 000000000..6f57cbce5 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/sync/SyncDataJob.kt @@ -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() + 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( + 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() + .addTag(TAG_MANUAL) + .build() + context.workManager.enqueueUniqueWork(TAG_MANUAL, ExistingWorkPolicy.KEEP, request) + } + + fun stop(context: Context) { + context.workManager.cancelUniqueWork(TAG_MANUAL) + } + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/sync/SyncHolder.kt b/app/src/main/java/eu/kanade/tachiyomi/data/sync/SyncHolder.kt new file mode 100644 index 000000000..3cd618e5a --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/sync/SyncHolder.kt @@ -0,0 +1,7 @@ +package eu.kanade.tachiyomi.data.sync + +import eu.kanade.tachiyomi.data.backup.models.Backup + +object SyncHolder { + var backup: Backup? = null +} 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 new file mode 100644 index 000000000..627fda56b --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/sync/SyncManager.kt @@ -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 { + 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, remoteChapters: List): 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> { + val databaseMangaFavorites = getFavorites.await() + val localMangaMap = databaseMangaFavorites.associateBy { it.url } + val favorites = mutableListOf() + val nonFavorites = mutableListOf() + + 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) { + 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) + } + } + } + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/sync/SyncNotifier.kt b/app/src/main/java/eu/kanade/tachiyomi/data/sync/SyncNotifier.kt new file mode 100644 index 000000000..6d11a62f6 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/sync/SyncNotifier.kt @@ -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) + } + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/sync/models/Sync.kt b/app/src/main/java/eu/kanade/tachiyomi/data/sync/models/Sync.kt new file mode 100644 index 000000000..d8ecf7f3d --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/sync/models/Sync.kt @@ -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, +) 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 new file mode 100644 index 000000000..8601978e9 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/sync/service/SyncService.kt @@ -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!! + } +} 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 new file mode 100644 index 000000000..873b961e3 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/sync/service/SyncYomiSyncService.kt @@ -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 + } + } + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/more/MoreTab.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/more/MoreTab.kt index 1e2ede273..e8eb6ecd1 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/more/MoreTab.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/more/MoreTab.kt @@ -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()) }, ) diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsScreen.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsScreen.kt index 8a18219ad..539276b0f 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsScreen.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsScreen.kt @@ -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) } } diff --git a/core/src/main/java/tachiyomi/core/preference/PreferenceStore.kt b/core/src/main/java/tachiyomi/core/preference/PreferenceStore.kt index f8cc9f890..86158ad63 100644 --- a/core/src/main/java/tachiyomi/core/preference/PreferenceStore.kt +++ b/core/src/main/java/tachiyomi/core/preference/PreferenceStore.kt @@ -1,5 +1,7 @@ package tachiyomi.core.preference +import java.time.Instant + interface PreferenceStore { fun getString(key: String, defaultValue: String = ""): Preference @@ -14,6 +16,15 @@ interface PreferenceStore { fun getStringSet(key: String, defaultValue: Set = emptySet()): Preference> + fun getInstant(key: String, defaultValue: Instant = Instant.EPOCH): Preference { + return getObject( + key = key, + defaultValue = defaultValue, + serializer = { it.epochSecond.toString() }, + deserializer = { Instant.ofEpochSecond(it.toLong()) }, + ) + } + fun getObject( key: String, defaultValue: T, diff --git a/data/src/main/java/tachiyomi/data/chapter/ChapterMapper.kt b/data/src/main/java/tachiyomi/data/chapter/ChapterMapper.kt index 7b1888205..1f8ccca0c 100644 --- a/data/src/main/java/tachiyomi/data/chapter/ChapterMapper.kt +++ b/data/src/main/java/tachiyomi/data/chapter/ChapterMapper.kt @@ -2,8 +2,8 @@ package tachiyomi.data.chapter import tachiyomi.domain.chapter.model.Chapter -val chapterMapper: (Long, Long, String, String, String?, Boolean, Boolean, Long, Float, Long, Long, Long) -> Chapter = - { id, mangaId, url, name, scanlator, read, bookmark, lastPageRead, chapterNumber, sourceOrder, dateFetch, dateUpload -> +val chapterMapper: (Long, Long, String, String, String?, Boolean, Boolean, Long, Float, Long, Long, Long, Long?) -> Chapter = + { id, mangaId, url, name, scanlator, read, bookmark, lastPageRead, chapterNumber, sourceOrder, dateFetch, dateUpload, lastModifiedAt -> Chapter( id = id, mangaId = mangaId, @@ -17,5 +17,6 @@ val chapterMapper: (Long, Long, String, String, String?, Boolean, Boolean, Long, dateUpload = dateUpload, chapterNumber = chapterNumber, scanlator = scanlator, + lastModifiedAt = lastModifiedAt, ) } diff --git a/data/src/main/java/tachiyomi/data/manga/MangaMapper.kt b/data/src/main/java/tachiyomi/data/manga/MangaMapper.kt index 0c9191bf2..de198bd9f 100644 --- a/data/src/main/java/tachiyomi/data/manga/MangaMapper.kt +++ b/data/src/main/java/tachiyomi/data/manga/MangaMapper.kt @@ -4,8 +4,8 @@ import eu.kanade.tachiyomi.source.model.UpdateStrategy import tachiyomi.domain.library.model.LibraryManga import tachiyomi.domain.manga.model.Manga -val mangaMapper: (Long, Long, String, String?, String?, String?, List?, String, Long, String?, Boolean, Long?, Long?, Boolean, Long, Long, Long, Long, UpdateStrategy, Long) -> Manga = - { id, source, url, artist, author, description, genre, title, status, thumbnailUrl, favorite, lastUpdate, nextUpdate, initialized, viewerFlags, chapterFlags, coverLastModified, dateAdded, updateStrategy, calculateInterval -> +val mangaMapper: (Long, Long, String, String?, String?, String?, List?, String, Long, String?, Boolean, Long?, Long?, Boolean, Long, Long, Long, Long, UpdateStrategy, Long, Long?) -> Manga = + { id, source, url, artist, author, description, genre, title, status, thumbnailUrl, favorite, lastUpdate, nextUpdate, initialized, viewerFlags, chapterFlags, coverLastModified, dateAdded, updateStrategy, calculateInterval, lastModifiedAt -> Manga( id = id, source = source, @@ -27,11 +27,12 @@ val mangaMapper: (Long, Long, String, String?, String?, String?, List?, thumbnailUrl = thumbnailUrl, updateStrategy = updateStrategy, initialized = initialized, + lastModifiedAt = lastModifiedAt, ) } -val libraryManga: (Long, Long, String, String?, String?, String?, List?, String, Long, String?, Boolean, Long?, Long?, Boolean, Long, Long, Long, Long, UpdateStrategy, Long, Long, Long, Long, Long, Long, Long, Long) -> LibraryManga = - { id, source, url, artist, author, description, genre, title, status, thumbnailUrl, favorite, lastUpdate, nextUpdate, initialized, viewerFlags, chapterFlags, coverLastModified, dateAdded, updateStrategy, calculateInterval, totalCount, readCount, latestUpload, chapterFetchedAt, lastRead, bookmarkCount, category -> +val libraryManga: (Long, Long, String, String?, String?, String?, List?, String, Long, String?, Boolean, Long?, Long?, Boolean, Long, Long, Long, Long, UpdateStrategy, Long, Long?, Long, Long, Long, Long, Long, Long, Long) -> LibraryManga = + { id, source, url, artist, author, description, genre, title, status, thumbnailUrl, favorite, lastUpdate, nextUpdate, initialized, viewerFlags, chapterFlags, coverLastModified, dateAdded, updateStrategy, calculateInterval, lastModifiedAt, totalCount, readCount, latestUpload, chapterFetchedAt, lastRead, bookmarkCount, category -> LibraryManga( manga = mangaMapper( id, @@ -54,6 +55,7 @@ val libraryManga: (Long, Long, String, String?, String?, String?, List?, dateAdded, updateStrategy, calculateInterval, + lastModifiedAt, ), category = category, totalChapters = totalCount, diff --git a/data/src/main/sqldelight/tachiyomi/data/chapters.sq b/data/src/main/sqldelight/tachiyomi/data/chapters.sq index 165916241..292b7063f 100644 --- a/data/src/main/sqldelight/tachiyomi/data/chapters.sq +++ b/data/src/main/sqldelight/tachiyomi/data/chapters.sq @@ -11,6 +11,7 @@ CREATE TABLE chapters( source_order INTEGER NOT NULL, date_fetch INTEGER AS Long NOT NULL, date_upload INTEGER AS Long NOT NULL, + last_modified_at INTEGER AS Long, FOREIGN KEY(manga_id) REFERENCES mangas (_id) ON DELETE CASCADE ); @@ -18,6 +19,15 @@ CREATE TABLE chapters( CREATE INDEX chapters_manga_id_index ON chapters(manga_id); CREATE INDEX chapters_unread_by_manga_index ON chapters(manga_id, read) WHERE read = 0; +CREATE TRIGGER update_last_modified_at_chapters +AFTER UPDATE ON chapters +FOR EACH ROW +BEGIN + UPDATE chapters + SET last_modified_at = strftime('%s', 'now') + WHERE _id = new._id; +END; + getChapterById: SELECT * FROM chapters diff --git a/data/src/main/sqldelight/tachiyomi/data/mangas.sq b/data/src/main/sqldelight/tachiyomi/data/mangas.sq index b5661e061..4a5ff2424 100644 --- a/data/src/main/sqldelight/tachiyomi/data/mangas.sq +++ b/data/src/main/sqldelight/tachiyomi/data/mangas.sq @@ -22,12 +22,31 @@ CREATE TABLE mangas( cover_last_modified INTEGER AS Long NOT NULL, date_added INTEGER AS Long NOT NULL, update_strategy INTEGER AS UpdateStrategy NOT NULL DEFAULT 0, - calculate_interval INTEGER DEFAULT 0 NOT NULL + calculate_interval INTEGER DEFAULT 0 NOT NULL, + last_modified_at INTEGER AS Long ); CREATE INDEX library_favorite_index ON mangas(favorite) WHERE favorite = 1; CREATE INDEX mangas_url_index ON mangas(url); +CREATE TRIGGER update_last_modified_at_mangas +AFTER UPDATE ON mangas +FOR EACH ROW +BEGIN + UPDATE mangas + SET last_modified_at = strftime('%s', 'now') + WHERE _id = new._id; +END; + +CREATE TRIGGER insert_last_modified_at_mangas +AFTER INSERT ON mangas +FOR EACH ROW +BEGIN + UPDATE mangas + SET last_modified_at = strftime('%s', 'now') + WHERE _id = new._id; +END; + getMangaById: SELECT * FROM mangas @@ -45,6 +64,10 @@ SELECT * FROM mangas WHERE favorite = 1; +getAllManga: +SELECT * +FROM mangas; + getSourceIdWithFavoriteCount: SELECT source, diff --git a/data/src/main/sqldelight/tachiyomi/data/mangas_categories.sq b/data/src/main/sqldelight/tachiyomi/data/mangas_categories.sq index a97c9d3ca..f328adef1 100644 --- a/data/src/main/sqldelight/tachiyomi/data/mangas_categories.sq +++ b/data/src/main/sqldelight/tachiyomi/data/mangas_categories.sq @@ -2,12 +2,31 @@ CREATE TABLE mangas_categories( _id INTEGER NOT NULL PRIMARY KEY, manga_id INTEGER NOT NULL, category_id INTEGER NOT NULL, + last_modified_at INTEGER AS Long, FOREIGN KEY(category_id) REFERENCES categories (_id) ON DELETE CASCADE, FOREIGN KEY(manga_id) REFERENCES mangas (_id) ON DELETE CASCADE ); +CREATE TRIGGER update_last_modified_at_mangas_categories +AFTER UPDATE ON mangas_categories +FOR EACH ROW +BEGIN + UPDATE mangas_categories + SET last_modified_at = strftime('%s', 'now') + WHERE _id = new._id; +END; + +CREATE TRIGGER insert_last_modified_at_mangas_categories +AFTER INSERT ON mangas_categories +FOR EACH ROW +BEGIN + UPDATE mangas_categories + SET last_modified_at = strftime('%s', 'now') + WHERE _id = new._id; +END; + insert: INSERT INTO mangas_categories(manga_id, category_id) VALUES (:mangaId, :categoryId); diff --git a/data/src/main/sqldelight/tachiyomi/migrations/24.sqm b/data/src/main/sqldelight/tachiyomi/migrations/24.sqm index c34ccfa0f..d80030ba1 100644 --- a/data/src/main/sqldelight/tachiyomi/migrations/24.sqm +++ b/data/src/main/sqldelight/tachiyomi/migrations/24.sqm @@ -1 +1,144 @@ +import kotlin.collections.List; +import eu.kanade.tachiyomi.source.model.UpdateStrategy; + ALTER TABLE mangas ADD COLUMN calculate_interval INTEGER DEFAULT 0 NOT NULL; + +-- Drop indices +DROP INDEX IF EXISTS library_favorite_index; +DROP INDEX IF EXISTS mangas_url_index; +DROP INDEX IF EXISTS chapters_manga_id_index; +DROP INDEX IF EXISTS chapters_unread_by_manga_index; + +-- Rename existing tables to temporary tables +ALTER TABLE mangas RENAME TO mangas_temp; +ALTER TABLE chapters RENAME TO chapters_temp; +ALTER TABLE mangas_categories RENAME TO mangas_categories_temp; + +-- Create new tables with updated schema +CREATE TABLE mangas( + _id INTEGER NOT NULL PRIMARY KEY, + source INTEGER NOT NULL, + url TEXT NOT NULL, + artist TEXT, + author TEXT, + description TEXT, + genre TEXT AS List, + title TEXT NOT NULL, + status INTEGER NOT NULL, + thumbnail_url TEXT, + favorite INTEGER AS Boolean NOT NULL, + last_update INTEGER AS Long, + next_update INTEGER AS Long, + initialized INTEGER AS Boolean NOT NULL, + viewer INTEGER NOT NULL, + chapter_flags INTEGER NOT NULL, + cover_last_modified INTEGER AS Long NOT NULL, + date_added INTEGER AS Long NOT NULL, + update_strategy INTEGER AS UpdateStrategy NOT NULL DEFAULT 0, + last_modified_at INTEGER AS Long +); + +CREATE TABLE mangas_categories( + _id INTEGER NOT NULL PRIMARY KEY, + manga_id INTEGER NOT NULL, + category_id INTEGER NOT NULL, + last_modified_at INTEGER AS Long, + FOREIGN KEY(category_id) REFERENCES categories (_id) + ON DELETE CASCADE, + FOREIGN KEY(manga_id) REFERENCES mangas (_id) + ON DELETE CASCADE +); + +CREATE TABLE chapters( + _id INTEGER NOT NULL PRIMARY KEY, + manga_id INTEGER NOT NULL, + url TEXT NOT NULL, + name TEXT NOT NULL, + scanlator TEXT, + read INTEGER AS Boolean NOT NULL, + bookmark INTEGER AS Boolean NOT NULL, + last_page_read INTEGER NOT NULL, + chapter_number REAL AS Float NOT NULL, + source_order INTEGER NOT NULL, + date_fetch INTEGER AS Long NOT NULL, + date_upload INTEGER AS Long NOT NULL, + last_modified_at INTEGER AS Long, + FOREIGN KEY(manga_id) REFERENCES mangas (_id) + ON DELETE CASCADE +); + +-- Copy data from temporary tables to new tables +INSERT INTO mangas +SELECT _id, source, url, artist, author, description, genre, title, status, thumbnail_url, favorite, last_update, next_update, initialized, viewer, chapter_flags, cover_last_modified, date_added, update_strategy, NULL +FROM mangas_temp; + +INSERT INTO chapters +SELECT _id, manga_id, url, name, scanlator, read, bookmark, last_page_read, chapter_number, source_order, date_fetch, date_upload, NULL +FROM chapters_temp; + +INSERT INTO mangas_categories +SELECT _id, manga_id, category_id, NULL +FROM mangas_categories_temp; + +-- Create indices +CREATE INDEX library_favorite_index ON mangas(favorite) WHERE favorite = 1; +CREATE INDEX mangas_url_index ON mangas(url); +CREATE INDEX chapters_manga_id_index ON chapters(manga_id); +CREATE INDEX chapters_unread_by_manga_index ON chapters(manga_id, read) WHERE read = 0; + +-- Drop temporary tables +DROP TABLE IF EXISTS mangas_temp; +DROP TABLE IF EXISTS chapters_temp; +DROP TABLE IF EXISTS mangas_categories_temp; + + +-- Create triggers +DROP TRIGGER IF EXISTS update_last_modified_at_mangas; +CREATE TRIGGER update_last_modified_at_mangas +AFTER UPDATE ON mangas +FOR EACH ROW +BEGIN + UPDATE mangas + SET last_modified_at = strftime('%s', 'now') + WHERE _id = new._id; +END; + +DROP TRIGGER IF EXISTS insert_last_modified_at_mangas; +CREATE TRIGGER insert_last_modified_at_mangas +AFTER INSERT ON mangas +FOR EACH ROW +BEGIN + UPDATE mangas + SET last_modified_at = strftime('%s', 'now') + WHERE _id = new._id; +END; + +DROP TRIGGER IF EXISTS update_last_modified_at_chapters; +CREATE TRIGGER update_last_modified_at_chapters +AFTER UPDATE ON chapters +FOR EACH ROW +BEGIN + UPDATE chapters + SET last_modified_at = strftime('%s', 'now') + WHERE _id = new._id; +END; + +DROP TRIGGER IF EXISTS update_last_modified_at_mangas_categories; +CREATE TRIGGER update_last_modified_at_mangas_categories +AFTER UPDATE ON mangas_categories +FOR EACH ROW +BEGIN + UPDATE mangas_categories + SET last_modified_at = strftime('%s', 'now') + WHERE _id = new._id; +END; + +DROP TRIGGER IF EXISTS insert_last_modified_at_mangas_categories; +CREATE TRIGGER insert_last_modified_at_mangas_categories +AFTER INSERT ON mangas_categories +FOR EACH ROW +BEGIN + UPDATE mangas_categories + SET last_modified_at = strftime('%s', 'now') + WHERE _id = new._id; +END; diff --git a/domain/src/main/java/tachiyomi/domain/chapter/model/Chapter.kt b/domain/src/main/java/tachiyomi/domain/chapter/model/Chapter.kt index e11ca6564..48a80419f 100644 --- a/domain/src/main/java/tachiyomi/domain/chapter/model/Chapter.kt +++ b/domain/src/main/java/tachiyomi/domain/chapter/model/Chapter.kt @@ -13,6 +13,7 @@ data class Chapter( val dateUpload: Long, val chapterNumber: Float, val scanlator: String?, + val lastModifiedAt: Long?, ) { val isRecognizedNumber: Boolean get() = chapterNumber >= 0f @@ -31,6 +32,7 @@ data class Chapter( dateUpload = -1, chapterNumber = -1f, scanlator = null, + lastModifiedAt = null, ) } } diff --git a/domain/src/main/java/tachiyomi/domain/manga/model/Manga.kt b/domain/src/main/java/tachiyomi/domain/manga/model/Manga.kt index df7885a92..8f2a23b32 100644 --- a/domain/src/main/java/tachiyomi/domain/manga/model/Manga.kt +++ b/domain/src/main/java/tachiyomi/domain/manga/model/Manga.kt @@ -24,6 +24,7 @@ data class Manga( val thumbnailUrl: String?, val updateStrategy: UpdateStrategy, val initialized: Boolean, + val lastModifiedAt: Long?, ) : Serializable { val sorting: Long @@ -109,6 +110,7 @@ data class Manga( thumbnailUrl = null, updateStrategy = UpdateStrategy.ALWAYS_UPDATE, initialized = false, + lastModifiedAt = 0L, ) } } diff --git a/domain/src/main/java/tachiyomi/domain/sync/SyncPreferences.kt b/domain/src/main/java/tachiyomi/domain/sync/SyncPreferences.kt new file mode 100644 index 000000000..fb922ef8f --- /dev/null +++ b/domain/src/main/java/tachiyomi/domain/sync/SyncPreferences.kt @@ -0,0 +1,20 @@ +package tachiyomi.domain.sync + +import tachiyomi.core.preference.PreferenceStore +import java.time.Instant + +class SyncPreferences( + private val preferenceStore: PreferenceStore, +) { + fun syncHost() = preferenceStore.getString("sync_host", "https://sync.tachiyomi.org") + fun syncAPIKey() = preferenceStore.getString("sync_api_key", "") + fun syncLastSync() = preferenceStore.getInstant("sync_last_sync", Instant.EPOCH) + + fun syncInterval() = preferenceStore.getInt("sync_interval", 0) + + fun deviceName() = preferenceStore.getString("device_name", android.os.Build.MANUFACTURER + android.os.Build.PRODUCT) + + fun deviceID() = preferenceStore.getInt("device_id", 0) + + fun syncService() = preferenceStore.getInt("sync_service", 0) +} diff --git a/i18n/src/main/res/values/strings.xml b/i18n/src/main/res/values/strings.xml index 03783484e..3001a5637 100644 --- a/i18n/src/main/res/values/strings.xml +++ b/i18n/src/main/res/values/strings.xml @@ -23,6 +23,7 @@ History Sources Backup and restore + Sync Statistics Migrate Extensions @@ -243,6 +244,9 @@ Global update Automatic updates Off + Every 30 minutes + Every hour + Every 3 hours Every 6 hours Every 12 hours Daily @@ -522,7 +526,35 @@ Canceled restore You should keep copies of backups in other places as well. - + + Syncing data + Syncing data failed + Syncing data complete + Sync is already in progress + Device name + Enter a name for this device + Host + Enter the host address for synchronizing your library + API key + Enter the API key to synchronize your library + Sync your library with a remote server + Sync Actions + Sync now + Sync confirmation + Initiate immediate synchronization of your data + Syncing will overwrite your local library with the remote library. Are you sure you want to continue? + Service + Select the service to sync your library with + Automatic Synchronization + Synchronization frequency + Self-hosted (SyncYomi) + Done in %1$s + Last Synchronization + + + + + Network Clear cookies DNS over HTTPS (DoH)