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