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

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.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() }

View File

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

View File

@@ -291,6 +291,7 @@ private val settingScreens = listOf(
SettingsTrackingScreen,
SettingsBrowseScreen,
SettingsBackupScreen,
SettingsSyncScreen,
SettingsSecurityScreen,
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.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())
}

View File

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

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) {
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()

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

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()) },
onClickStats = { navigator.push(StatsScreen()) },
onClickBackupAndRestore = { navigator.push(SettingsScreen.toBackupScreen()) },
onClickSync = { navigator.push(SettingsScreen.toSyncScreen()) },
onClickSettings = { navigator.push(SettingsScreen.toMainScreen()) },
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.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)
}
}