Split sync feature part 1

Co-authored-by: KaiserBh <kaiserbh@proton.me>
This commit is contained in:
Aria Moradi 2023-07-06 14:06:23 +03:30
parent 4d67066de3
commit 84eb68e1ba
35 changed files with 1090 additions and 35 deletions

View File

@ -24,6 +24,7 @@ fun Chapter.copyFromSChapter(sChapter: SChapter): Chapter {
dateUpload = sChapter.date_upload, dateUpload = sChapter.date_upload,
chapterNumber = sChapter.chapter_number, chapterNumber = sChapter.chapter_number,
scanlator = sChapter.scanlator?.ifBlank { null }, scanlator = sChapter.scanlator?.ifBlank { null },
lastModifiedAt = null,
) )
} }
@ -34,6 +35,7 @@ fun Chapter.copyFrom(other: Chapters): Chapter {
dateUpload = other.date_upload, dateUpload = other.date_upload,
chapterNumber = other.chapter_number, chapterNumber = other.chapter_number,
scanlator = other.scanlator?.ifBlank { null }, 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.date_upload = dateUpload
it.chapter_number = chapterNumber it.chapter_number = chapterNumber
it.source_order = sourceOrder.toInt() it.source_order = sourceOrder.toInt()
it.last_modified = lastModifiedAt
} }

View File

@ -17,6 +17,7 @@ import androidx.compose.material.icons.outlined.Label
import androidx.compose.material.icons.outlined.QueryStats import androidx.compose.material.icons.outlined.QueryStats
import androidx.compose.material.icons.outlined.Settings import androidx.compose.material.icons.outlined.Settings
import androidx.compose.material.icons.outlined.SettingsBackupRestore import androidx.compose.material.icons.outlined.SettingsBackupRestore
import androidx.compose.material.icons.outlined.Sync
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
@ -46,6 +47,7 @@ fun MoreScreen(
onClickCategories: () -> Unit, onClickCategories: () -> Unit,
onClickStats: () -> Unit, onClickStats: () -> Unit,
onClickBackupAndRestore: () -> Unit, onClickBackupAndRestore: () -> Unit,
onClickSync: () -> Unit,
onClickSettings: () -> Unit, onClickSettings: () -> Unit,
onClickAbout: () -> Unit, onClickAbout: () -> Unit,
) { ) {
@ -146,6 +148,13 @@ fun MoreScreen(
onPreferenceClick = onClickBackupAndRestore, onPreferenceClick = onClickBackupAndRestore,
) )
} }
item {
TextPreferenceWidget(
title = stringResource(R.string.label_sync),
icon = Icons.Outlined.Sync,
onPreferenceClick = onClickSync,
)
}
item { Divider() } item { Divider() }

View File

@ -228,6 +228,12 @@ object SettingsMainScreen : Screen() {
icon = Icons.Outlined.SettingsBackupRestore, icon = Icons.Outlined.SettingsBackupRestore,
screen = SettingsBackupScreen, screen = SettingsBackupScreen,
), ),
Item(
titleRes = R.string.label_sync,
subtitleRes = R.string.pref_sync_summary,
icon = Icons.Outlined.Sync,
screen = SettingsSyncScreen,
),
Item( Item(
titleRes = R.string.pref_category_security, titleRes = R.string.pref_category_security,
subtitleRes = R.string.pref_security_summary, subtitleRes = R.string.pref_security_summary,

View File

@ -291,6 +291,7 @@ private val settingScreens = listOf(
SettingsTrackingScreen, SettingsTrackingScreen,
SettingsBrowseScreen, SettingsBrowseScreen,
SettingsBackupScreen, SettingsBackupScreen,
SettingsSyncScreen,
SettingsSecurityScreen, SettingsSecurityScreen,
SettingsAdvancedScreen, SettingsAdvancedScreen,
) )

View File

@ -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))
}
},
)
}
}

View File

@ -48,6 +48,7 @@ import tachiyomi.domain.backup.service.BackupPreferences
import tachiyomi.domain.download.service.DownloadPreferences import tachiyomi.domain.download.service.DownloadPreferences
import tachiyomi.domain.library.service.LibraryPreferences import tachiyomi.domain.library.service.LibraryPreferences
import tachiyomi.domain.source.service.SourceManager import tachiyomi.domain.source.service.SourceManager
import tachiyomi.domain.sync.SyncPreferences
import tachiyomi.source.local.image.LocalCoverManager import tachiyomi.source.local.image.LocalCoverManager
import tachiyomi.source.local.io.LocalSourceFileSystem import tachiyomi.source.local.io.LocalSourceFileSystem
import uy.kohesive.injekt.api.InjektModule import uy.kohesive.injekt.api.InjektModule
@ -197,6 +198,9 @@ class PreferenceModule(val application: Application) : InjektModule {
preferenceStore = get(), preferenceStore = get(),
) )
} }
addSingletonFactory {
SyncPreferences(get())
}
addSingletonFactory { addSingletonFactory {
UiPreferences(get()) UiPreferences(get())
} }

View File

@ -5,7 +5,6 @@ import android.content.Context
import android.net.Uri import android.net.Uri
import com.hippo.unifile.UniFile import com.hippo.unifile.UniFile
import eu.kanade.domain.chapter.model.copyFrom import eu.kanade.domain.chapter.model.copyFrom
import eu.kanade.domain.manga.model.copyFrom
import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.backup.BackupConst.BACKUP_CATEGORY import eu.kanade.tachiyomi.data.backup.BackupConst.BACKUP_CATEGORY
import eu.kanade.tachiyomi.data.backup.BackupConst.BACKUP_CATEGORY_MASK 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 return mangas
.asSequence() .asSequence()
.map(Manga::source) .map(Manga::source)
@ -149,7 +148,7 @@ class BackupManager(
* *
* @return list of [BackupCategory] to be backed up * @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 // Check if user wants category information in backup
return if (options and BACKUP_CATEGORY_MASK == BACKUP_CATEGORY) { return if (options and BACKUP_CATEGORY_MASK == BACKUP_CATEGORY) {
getCategories.await() 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 { return mangas.map {
backupManga(it, flags) backupManga(it, flags)
} }
@ -455,7 +454,7 @@ class BackupManager(
updatedChapter = updatedChapter.copy(id = dbChapter._id) updatedChapter = updatedChapter.copy(id = dbChapter._id)
updatedChapter = updatedChapter.copyFrom(dbChapter) updatedChapter = updatedChapter.copyFrom(dbChapter)
if (dbChapter.read && !updatedChapter.read) { 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) { } else if (updatedChapter.lastPageRead == 0L && dbChapter.last_page_read != 0L) {
updatedChapter = updatedChapter.copy(lastPageRead = dbChapter.last_page_read) 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) { handler.await(true) {
mangasQueries.update( mangasQueries.update(
source = manga.source, source = manga.source,

View File

@ -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) { val builder = with(progressNotificationBuilder) {
setContentTitle(context.getString(R.string.restoring_backup)) setContentTitle(contentTitle)
if (!preferences.hideNotificationContent().get()) { if (!preferences.hideNotificationContent().get()) {
setContentText(content) 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) context.cancelNotification(Notifications.ID_RESTORE_PROGRESS)
val timeString = context.getString( val timeString = context.getString(
@ -126,7 +126,7 @@ class BackupNotifier(private val context: Context) {
) )
with(completeNotificationBuilder) { with(completeNotificationBuilder) {
setContentTitle(context.getString(R.string.restore_completed)) setContentTitle(contentTitle)
setContentText(context.resources.getQuantityString(R.plurals.restore_completed_message, errorCount, timeString, errorCount)) setContentText(context.resources.getQuantityString(R.plurals.restore_completed_message, errorCount, timeString, errorCount))
clearActions() clearActions()

View File

@ -11,6 +11,7 @@ import androidx.work.WorkerParameters
import androidx.work.workDataOf import androidx.work.workDataOf
import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.notification.Notifications 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.cancelNotification
import eu.kanade.tachiyomi.util.system.isRunning import eu.kanade.tachiyomi.util.system.isRunning
import eu.kanade.tachiyomi.util.system.workManager import eu.kanade.tachiyomi.util.system.workManager
@ -26,6 +27,8 @@ class BackupRestoreJob(private val context: Context, workerParams: WorkerParamet
override suspend fun doWork(): Result { override suspend fun doWork(): Result {
val uri = inputData.getString(LOCATION_URI_KEY)?.toUri() val uri = inputData.getString(LOCATION_URI_KEY)?.toUri()
?: return Result.failure() ?: return Result.failure()
val sync = inputData.getBoolean(SYNC, false)
val useBackupHolder = inputData.getBoolean(USE_BACKUP_HOLDER, false)
try { try {
setForeground(getForegroundInfo()) setForeground(getForegroundInfo())
@ -35,7 +38,12 @@ class BackupRestoreJob(private val context: Context, workerParams: WorkerParamet
return try { return try {
val restorer = BackupRestorer(context, notifier) val restorer = BackupRestorer(context, notifier)
restorer.restoreBackup(uri) if (useBackupHolder) {
restorer.restoreBackup(uri, sync)
SyncHolder.backup = null
} else {
restorer.restoreBackup(uri, sync)
}
Result.success() Result.success()
} catch (e: Exception) { } catch (e: Exception) {
if (e is CancellationException) { if (e is CancellationException) {
@ -63,9 +71,11 @@ class BackupRestoreJob(private val context: Context, workerParams: WorkerParamet
return context.workManager.isRunning(TAG) 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( val inputData = workDataOf(
LOCATION_URI_KEY to uri.toString(), LOCATION_URI_KEY to uri.toString(),
SYNC to sync,
USE_BACKUP_HOLDER to useBackupHolder,
) )
val request = OneTimeWorkRequestBuilder<BackupRestoreJob>() val request = OneTimeWorkRequestBuilder<BackupRestoreJob>()
.addTag(TAG) .addTag(TAG)
@ -83,3 +93,7 @@ class BackupRestoreJob(private val context: Context, workerParams: WorkerParamet
private const val TAG = "BackupRestore" private const val TAG = "BackupRestore"
private const val LOCATION_URI_KEY = "location_uri" // String 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

View File

@ -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.BackupHistory
import eu.kanade.tachiyomi.data.backup.models.BackupManga import eu.kanade.tachiyomi.data.backup.models.BackupManga
import eu.kanade.tachiyomi.data.backup.models.BackupSource 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.BackupUtil
import eu.kanade.tachiyomi.util.system.createFileInCacheDir import eu.kanade.tachiyomi.util.system.createFileInCacheDir
import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.coroutineScope
@ -36,12 +37,12 @@ class BackupRestorer(
private val errors = mutableListOf<Pair<Date, String>>() 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() val startTime = System.currentTimeMillis()
restoreProgress = 0 restoreProgress = 0
errors.clear() errors.clear()
if (!performRestore(uri)) { if (!performRestore(uri, sync)) {
return false return false
} }
@ -50,7 +51,11 @@ class BackupRestorer(
val logFile = writeErrorLog() val logFile = writeErrorLog()
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) notifier.showRestoreComplete(time, errors.size, logFile.parent, logFile.name)
}
return true return true
} }
@ -73,8 +78,12 @@ class BackupRestorer(
return File("") return File("")
} }
private suspend fun performRestore(uri: Uri): Boolean { private suspend fun performRestore(uri: Uri, sync: Boolean): Boolean {
val backup = BackupUtil.decodeBackup(context, uri) 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 restoreAmount = backup.backupManga.size + 1 // +1 for categories
@ -94,7 +103,7 @@ class BackupRestorer(
return@coroutineScope false return@coroutineScope false
} }
restoreManga(it, backup.backupCategories) restoreManga(it, backup.backupCategories, sync)
} }
// TODO: optionally trigger online library + tracker update // TODO: optionally trigger online library + tracker update
true true
@ -105,10 +114,10 @@ class BackupRestorer(
backupManager.restoreCategories(backupCategories) backupManager.restoreCategories(backupCategories)
restoreProgress += 1 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 manga = backupManga.getMangaImpl()
val chapters = backupManga.getChaptersImpl() val chapters = backupManga.getChaptersImpl()
val categories = backupManga.categories.map { it.toInt() } val categories = backupManga.categories.map { it.toInt() }
@ -134,7 +143,11 @@ class BackupRestorer(
} }
restoreProgress += 1 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 amount total restoreAmount of manga
* @param title title of restored manga * @param title title of restored manga
*/ */
private fun showRestoreProgress(progress: Int, amount: Int, title: String) { private fun showRestoreProgress(progress: Int, amount: Int, title: String, contentTitle: String) {
notifier.showRestoreProgress(title, progress, amount) notifier.showRestoreProgress(title, contentTitle, progress, amount)
} }
} }

View File

@ -20,6 +20,7 @@ data class BackupChapter(
// chapterNumber is called number is 1.x // chapterNumber is called number is 1.x
@ProtoNumber(9) var chapterNumber: Float = 0F, @ProtoNumber(9) var chapterNumber: Float = 0F,
@ProtoNumber(10) var sourceOrder: Long = 0, @ProtoNumber(10) var sourceOrder: Long = 0,
@ProtoNumber(11) var lastModifiedAt: Long? = null,
) { ) {
fun toChapterImpl(): Chapter { fun toChapterImpl(): Chapter {
return Chapter.create().copy( return Chapter.create().copy(
@ -33,11 +34,12 @@ data class BackupChapter(
dateFetch = this@BackupChapter.dateFetch, dateFetch = this@BackupChapter.dateFetch,
dateUpload = this@BackupChapter.dateUpload, dateUpload = this@BackupChapter.dateUpload,
sourceOrder = this@BackupChapter.sourceOrder, 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( BackupChapter(
url = url, url = url,
name = name, name = name,
@ -49,5 +51,6 @@ val backupChapterMapper = { _: Long, _: Long, url: String, name: String, scanlat
dateFetch = dateFetch, dateFetch = dateFetch,
dateUpload = dateUpload, dateUpload = dateUpload,
sourceOrder = source_order, sourceOrder = source_order,
lastModifiedAt = lastModifiedAt,
) )
} }

View File

@ -39,6 +39,7 @@ data class BackupManga(
@ProtoNumber(103) var viewer_flags: Int? = null, @ProtoNumber(103) var viewer_flags: Int? = null,
@ProtoNumber(104) var history: List<BackupHistory> = emptyList(), @ProtoNumber(104) var history: List<BackupHistory> = emptyList(),
@ProtoNumber(105) var updateStrategy: UpdateStrategy = UpdateStrategy.ALWAYS_UPDATE, @ProtoNumber(105) var updateStrategy: UpdateStrategy = UpdateStrategy.ALWAYS_UPDATE,
@ProtoNumber(106) var lastModifiedAt: Long? = 0,
) { ) {
fun getMangaImpl(): Manga { fun getMangaImpl(): Manga {
return Manga.create().copy( return Manga.create().copy(
@ -56,6 +57,7 @@ data class BackupManga(
viewerFlags = (this@BackupManga.viewer_flags ?: this@BackupManga.viewer).toLong(), viewerFlags = (this@BackupManga.viewer_flags ?: this@BackupManga.viewer).toLong(),
chapterFlags = this@BackupManga.chapterFlags.toLong(), chapterFlags = this@BackupManga.chapterFlags.toLong(),
updateStrategy = this@BackupManga.updateStrategy, updateStrategy = this@BackupManga.updateStrategy,
lastModifiedAt = this@BackupManga.lastModifiedAt,
) )
} }
@ -89,6 +91,7 @@ data class BackupManga(
viewer_flags = manga.viewerFlags.toInt(), viewer_flags = manga.viewerFlags.toInt(),
chapterFlags = manga.chapterFlags.toInt(), chapterFlags = manga.chapterFlags.toInt(),
updateStrategy = manga.updateStrategy, updateStrategy = manga.updateStrategy,
lastModifiedAt = manga.lastModifiedAt,
) )
} }
} }

View File

@ -19,6 +19,8 @@ interface Chapter : SChapter, Serializable {
var date_fetch: Long var date_fetch: Long
var source_order: Int var source_order: Int
var last_modified: Long?
} }
fun Chapter.toDomainChapter(): DomainChapter? { fun Chapter.toDomainChapter(): DomainChapter? {
@ -36,5 +38,6 @@ fun Chapter.toDomainChapter(): DomainChapter? {
dateUpload = date_upload, dateUpload = date_upload,
chapterNumber = chapter_number, chapterNumber = chapter_number,
scanlator = scanlator, scanlator = scanlator,
lastModifiedAt = last_modified,
) )
} }

View File

@ -26,6 +26,8 @@ class ChapterImpl : Chapter {
override var source_order: Int = 0 override var source_order: Int = 0
override var last_modified: Long? = null
override fun equals(other: Any?): Boolean { override fun equals(other: Any?): Boolean {
if (this === other) return true if (this === other) return true
if (other == null || javaClass != other.javaClass) return false if (other == null || javaClass != other.javaClass) return false

View File

@ -11,6 +11,7 @@ import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.backup.BackupRestoreJob import eu.kanade.tachiyomi.data.backup.BackupRestoreJob
import eu.kanade.tachiyomi.data.download.DownloadManager import eu.kanade.tachiyomi.data.download.DownloadManager
import eu.kanade.tachiyomi.data.library.LibraryUpdateJob 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.data.updater.AppUpdateService
import eu.kanade.tachiyomi.ui.main.MainActivity import eu.kanade.tachiyomi.ui.main.MainActivity
import eu.kanade.tachiyomi.ui.reader.ReaderActivity import eu.kanade.tachiyomi.ui.reader.ReaderActivity
@ -83,6 +84,8 @@ class NotificationReceiver : BroadcastReceiver() {
intent.getIntExtra(EXTRA_NOTIFICATION_ID, -1), intent.getIntExtra(EXTRA_NOTIFICATION_ID, -1),
) )
ACTION_CANCEL_RESTORE -> cancelRestore(context) ACTION_CANCEL_RESTORE -> cancelRestore(context)
ACTION_CANCEL_SYNC -> cancelSync(context)
// Cancel library update and dismiss notification // Cancel library update and dismiss notification
ACTION_CANCEL_LIBRARY_UPDATE -> cancelLibraryUpdate(context) ACTION_CANCEL_LIBRARY_UPDATE -> cancelLibraryUpdate(context)
// Cancel downloading app update // Cancel downloading app update
@ -213,6 +216,15 @@ class NotificationReceiver : BroadcastReceiver() {
AppUpdateService.stop(context) 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 * 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_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_LIBRARY_UPDATE = "$ID.$NAME.CANCEL_LIBRARY_UPDATE"
private const val ACTION_CANCEL_APP_UPDATE_DOWNLOAD = "$ID.$NAME.CANCEL_APP_UPDATE_DOWNLOAD" 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) 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)
}
} }
} }

View File

@ -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)
}
}
}

View File

@ -0,0 +1,7 @@
package eu.kanade.tachiyomi.data.sync
import eu.kanade.tachiyomi.data.backup.models.Backup
object SyncHolder {
var backup: Backup? = null
}

View 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)
}
}
}
}
}

View File

@ -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)
}
}
}

View File

@ -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,
)

View File

@ -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!!
}
}

View File

@ -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
}
}
}
}

View File

@ -72,6 +72,7 @@ object MoreTab : Tab {
onClickCategories = { navigator.push(CategoryScreen()) }, onClickCategories = { navigator.push(CategoryScreen()) },
onClickStats = { navigator.push(StatsScreen()) }, onClickStats = { navigator.push(StatsScreen()) },
onClickBackupAndRestore = { navigator.push(SettingsScreen.toBackupScreen()) }, onClickBackupAndRestore = { navigator.push(SettingsScreen.toBackupScreen()) },
onClickSync = { navigator.push(SettingsScreen.toSyncScreen()) },
onClickSettings = { navigator.push(SettingsScreen.toMainScreen()) }, onClickSettings = { navigator.push(SettingsScreen.toMainScreen()) },
onClickAbout = { navigator.push(SettingsScreen.toAboutScreen()) }, onClickAbout = { navigator.push(SettingsScreen.toAboutScreen()) },
) )

View File

@ -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.SettingsAppearanceScreen
import eu.kanade.presentation.more.settings.screen.SettingsBackupScreen import eu.kanade.presentation.more.settings.screen.SettingsBackupScreen
import eu.kanade.presentation.more.settings.screen.SettingsMainScreen 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.DefaultNavigatorScreenTransition
import eu.kanade.presentation.util.LocalBackPress import eu.kanade.presentation.util.LocalBackPress
import eu.kanade.presentation.util.Screen import eu.kanade.presentation.util.Screen
@ -24,6 +25,7 @@ import tachiyomi.presentation.core.components.TwoPanelBox
class SettingsScreen private constructor( class SettingsScreen private constructor(
val toBackup: Boolean, val toBackup: Boolean,
val toSync: Boolean,
val toAbout: Boolean, val toAbout: Boolean,
) : Screen() { ) : Screen() {
@ -34,6 +36,8 @@ class SettingsScreen private constructor(
Navigator( Navigator(
screen = if (toBackup) { screen = if (toBackup) {
SettingsBackupScreen SettingsBackupScreen
} else if (toSync) {
SettingsSyncScreen
} else if (toAbout) { } else if (toAbout) {
AboutScreen AboutScreen
} else { } else {
@ -56,6 +60,8 @@ class SettingsScreen private constructor(
Navigator( Navigator(
screen = if (toBackup) { screen = if (toBackup) {
SettingsBackupScreen SettingsBackupScreen
} else if (toSync) {
SettingsSyncScreen
} else if (toAbout) { } else if (toAbout) {
AboutScreen AboutScreen
} else { } else {
@ -79,10 +85,12 @@ class SettingsScreen private constructor(
} }
companion object { 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)
} }
} }

View File

@ -1,5 +1,7 @@
package tachiyomi.core.preference package tachiyomi.core.preference
import java.time.Instant
interface PreferenceStore { interface PreferenceStore {
fun getString(key: String, defaultValue: String = ""): Preference<String> fun getString(key: String, defaultValue: String = ""): Preference<String>
@ -14,6 +16,15 @@ interface PreferenceStore {
fun getStringSet(key: String, defaultValue: Set<String> = emptySet()): Preference<Set<String>> fun getStringSet(key: String, defaultValue: Set<String> = emptySet()): Preference<Set<String>>
fun getInstant(key: String, defaultValue: Instant = Instant.EPOCH): Preference<Instant> {
return getObject(
key = key,
defaultValue = defaultValue,
serializer = { it.epochSecond.toString() },
deserializer = { Instant.ofEpochSecond(it.toLong()) },
)
}
fun <T> getObject( fun <T> getObject(
key: String, key: String,
defaultValue: T, defaultValue: T,

View File

@ -2,8 +2,8 @@ package tachiyomi.data.chapter
import tachiyomi.domain.chapter.model.Chapter import tachiyomi.domain.chapter.model.Chapter
val chapterMapper: (Long, Long, String, String, String?, Boolean, Boolean, Long, Float, Long, Long, Long) -> Chapter = 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 -> { id, mangaId, url, name, scanlator, read, bookmark, lastPageRead, chapterNumber, sourceOrder, dateFetch, dateUpload, lastModifiedAt ->
Chapter( Chapter(
id = id, id = id,
mangaId = mangaId, mangaId = mangaId,
@ -17,5 +17,6 @@ val chapterMapper: (Long, Long, String, String, String?, Boolean, Boolean, Long,
dateUpload = dateUpload, dateUpload = dateUpload,
chapterNumber = chapterNumber, chapterNumber = chapterNumber,
scanlator = scanlator, scanlator = scanlator,
lastModifiedAt = lastModifiedAt,
) )
} }

View File

@ -4,8 +4,8 @@ import eu.kanade.tachiyomi.source.model.UpdateStrategy
import tachiyomi.domain.library.model.LibraryManga import tachiyomi.domain.library.model.LibraryManga
import tachiyomi.domain.manga.model.Manga import tachiyomi.domain.manga.model.Manga
val mangaMapper: (Long, Long, String, String?, String?, String?, List<String>?, String, Long, String?, Boolean, Long?, Long?, Boolean, Long, Long, Long, Long, UpdateStrategy, Long) -> Manga = val mangaMapper: (Long, Long, String, String?, String?, String?, List<String>?, 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 -> { id, source, url, artist, author, description, genre, title, status, thumbnailUrl, favorite, lastUpdate, nextUpdate, initialized, viewerFlags, chapterFlags, coverLastModified, dateAdded, updateStrategy, calculateInterval, lastModifiedAt ->
Manga( Manga(
id = id, id = id,
source = source, source = source,
@ -27,11 +27,12 @@ val mangaMapper: (Long, Long, String, String?, String?, String?, List<String>?,
thumbnailUrl = thumbnailUrl, thumbnailUrl = thumbnailUrl,
updateStrategy = updateStrategy, updateStrategy = updateStrategy,
initialized = initialized, initialized = initialized,
lastModifiedAt = lastModifiedAt,
) )
} }
val libraryManga: (Long, Long, String, String?, String?, String?, List<String>?, String, Long, String?, Boolean, Long?, Long?, Boolean, Long, Long, Long, Long, UpdateStrategy, Long, Long, Long, Long, Long, Long, Long, Long) -> LibraryManga = val libraryManga: (Long, Long, String, String?, String?, String?, List<String>?, 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, totalCount, readCount, latestUpload, chapterFetchedAt, lastRead, bookmarkCount, category -> { 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( LibraryManga(
manga = mangaMapper( manga = mangaMapper(
id, id,
@ -54,6 +55,7 @@ val libraryManga: (Long, Long, String, String?, String?, String?, List<String>?,
dateAdded, dateAdded,
updateStrategy, updateStrategy,
calculateInterval, calculateInterval,
lastModifiedAt,
), ),
category = category, category = category,
totalChapters = totalCount, totalChapters = totalCount,

View File

@ -11,6 +11,7 @@ CREATE TABLE chapters(
source_order INTEGER NOT NULL, source_order INTEGER NOT NULL,
date_fetch INTEGER AS Long NOT NULL, date_fetch INTEGER AS Long NOT NULL,
date_upload 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) FOREIGN KEY(manga_id) REFERENCES mangas (_id)
ON DELETE CASCADE ON DELETE CASCADE
); );
@ -18,6 +19,15 @@ CREATE TABLE chapters(
CREATE INDEX chapters_manga_id_index ON chapters(manga_id); 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 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: getChapterById:
SELECT * SELECT *
FROM chapters FROM chapters

View File

@ -22,12 +22,31 @@ CREATE TABLE mangas(
cover_last_modified INTEGER AS Long NOT NULL, cover_last_modified INTEGER AS Long NOT NULL,
date_added INTEGER AS Long NOT NULL, date_added INTEGER AS Long NOT NULL,
update_strategy INTEGER AS UpdateStrategy NOT NULL DEFAULT 0, 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 library_favorite_index ON mangas(favorite) WHERE favorite = 1;
CREATE INDEX mangas_url_index ON mangas(url); 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: getMangaById:
SELECT * SELECT *
FROM mangas FROM mangas
@ -45,6 +64,10 @@ SELECT *
FROM mangas FROM mangas
WHERE favorite = 1; WHERE favorite = 1;
getAllManga:
SELECT *
FROM mangas;
getSourceIdWithFavoriteCount: getSourceIdWithFavoriteCount:
SELECT SELECT
source, source,

View File

@ -2,12 +2,31 @@ CREATE TABLE mangas_categories(
_id INTEGER NOT NULL PRIMARY KEY, _id INTEGER NOT NULL PRIMARY KEY,
manga_id INTEGER NOT NULL, manga_id INTEGER NOT NULL,
category_id INTEGER NOT NULL, category_id INTEGER NOT NULL,
last_modified_at INTEGER AS Long,
FOREIGN KEY(category_id) REFERENCES categories (_id) FOREIGN KEY(category_id) REFERENCES categories (_id)
ON DELETE CASCADE, ON DELETE CASCADE,
FOREIGN KEY(manga_id) REFERENCES mangas (_id) FOREIGN KEY(manga_id) REFERENCES mangas (_id)
ON DELETE CASCADE 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:
INSERT INTO mangas_categories(manga_id, category_id) INSERT INTO mangas_categories(manga_id, category_id)
VALUES (:mangaId, :categoryId); VALUES (:mangaId, :categoryId);

View File

@ -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; 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<String>,
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;

View File

@ -13,6 +13,7 @@ data class Chapter(
val dateUpload: Long, val dateUpload: Long,
val chapterNumber: Float, val chapterNumber: Float,
val scanlator: String?, val scanlator: String?,
val lastModifiedAt: Long?,
) { ) {
val isRecognizedNumber: Boolean val isRecognizedNumber: Boolean
get() = chapterNumber >= 0f get() = chapterNumber >= 0f
@ -31,6 +32,7 @@ data class Chapter(
dateUpload = -1, dateUpload = -1,
chapterNumber = -1f, chapterNumber = -1f,
scanlator = null, scanlator = null,
lastModifiedAt = null,
) )
} }
} }

View File

@ -24,6 +24,7 @@ data class Manga(
val thumbnailUrl: String?, val thumbnailUrl: String?,
val updateStrategy: UpdateStrategy, val updateStrategy: UpdateStrategy,
val initialized: Boolean, val initialized: Boolean,
val lastModifiedAt: Long?,
) : Serializable { ) : Serializable {
val sorting: Long val sorting: Long
@ -109,6 +110,7 @@ data class Manga(
thumbnailUrl = null, thumbnailUrl = null,
updateStrategy = UpdateStrategy.ALWAYS_UPDATE, updateStrategy = UpdateStrategy.ALWAYS_UPDATE,
initialized = false, initialized = false,
lastModifiedAt = 0L,
) )
} }
} }

View File

@ -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)
}

View File

@ -23,6 +23,7 @@
<string name="label_recent_manga">History</string> <string name="label_recent_manga">History</string>
<string name="label_sources">Sources</string> <string name="label_sources">Sources</string>
<string name="label_backup">Backup and restore</string> <string name="label_backup">Backup and restore</string>
<string name="label_sync">Sync</string>
<string name="label_stats">Statistics</string> <string name="label_stats">Statistics</string>
<string name="label_migration">Migrate</string> <string name="label_migration">Migrate</string>
<string name="label_extensions">Extensions</string> <string name="label_extensions">Extensions</string>
@ -243,6 +244,9 @@
<string name="pref_category_library_update">Global update</string> <string name="pref_category_library_update">Global update</string>
<string name="pref_library_update_interval">Automatic updates</string> <string name="pref_library_update_interval">Automatic updates</string>
<string name="update_never">Off</string> <string name="update_never">Off</string>
<string name="update_30min">Every 30 minutes</string>
<string name="update_1hour">Every hour</string>
<string name="update_3hour">Every 3 hours</string>
<string name="update_6hour">Every 6 hours</string> <string name="update_6hour">Every 6 hours</string>
<string name="update_12hour">Every 12 hours</string> <string name="update_12hour">Every 12 hours</string>
<string name="update_24hour">Daily</string> <string name="update_24hour">Daily</string>
@ -522,6 +526,34 @@
<string name="restoring_backup_canceled">Canceled restore</string> <string name="restoring_backup_canceled">Canceled restore</string>
<string name="backup_info">You should keep copies of backups in other places as well.</string> <string name="backup_info">You should keep copies of backups in other places as well.</string>
<!-- Sync section -->
<string name="syncing_data">Syncing data</string>
<string name="sync_error">Syncing data failed</string>
<string name="sync_complete">Syncing data complete</string>
<string name="sync_in_progress">Sync is already in progress</string>
<string name="pref_sync_device_name">Device name</string>
<string name="pref_sync_device_name_summ">Enter a name for this device</string>
<string name="pref_sync_host">Host</string>
<string name="pref_sync_host_summ">Enter the host address for synchronizing your library</string>
<string name="pref_sync_api_key">API key</string>
<string name="pref_sync_api_key_summ">Enter the API key to synchronize your library</string>
<string name="pref_sync_summary">Sync your library with a remote server</string>
<string name="pref_sync_now_group_title">Sync Actions</string>
<string name="pref_sync_now">Sync now</string>
<string name="pref_sync_confirmation_title">Sync confirmation</string>
<string name="pref_sync_now_subtitle">Initiate immediate synchronization of your data</string>
<string name="pref_sync_confirmation_message">Syncing will overwrite your local library with the remote library. Are you sure you want to continue?</string>
<string name="pref_sync_service">Service</string>
<string name="pref_sync_service_summ">Select the service to sync your library with</string>
<string name="pref_sync_service_category">Automatic Synchronization</string>
<string name="pref_sync_interval">Synchronization frequency</string>
<string name="self_host">Self-hosted (SyncYomi)</string>
<string name="sync_completed_message">Done in %1$s</string>
<string name="last_synchronization">Last Synchronization</string>
<!-- Advanced section --> <!-- Advanced section -->
<string name="label_network">Network</string> <string name="label_network">Network</string>
<string name="pref_clear_cookies">Clear cookies</string> <string name="pref_clear_cookies">Clear cookies</string>