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,
|
dateUpload = sChapter.date_upload,
|
||||||
chapterNumber = sChapter.chapter_number,
|
chapterNumber = sChapter.chapter_number,
|
||||||
scanlator = sChapter.scanlator?.ifBlank { null },
|
scanlator = sChapter.scanlator?.ifBlank { null },
|
||||||
|
lastModifiedAt = null,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -34,6 +35,7 @@ fun Chapter.copyFrom(other: Chapters): Chapter {
|
|||||||
dateUpload = other.date_upload,
|
dateUpload = other.date_upload,
|
||||||
chapterNumber = other.chapter_number,
|
chapterNumber = other.chapter_number,
|
||||||
scanlator = other.scanlator?.ifBlank { null },
|
scanlator = other.scanlator?.ifBlank { null },
|
||||||
|
lastModifiedAt = other.last_modified_at,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -50,4 +52,5 @@ fun Chapter.toDbChapter(): DbChapter = ChapterImpl().also {
|
|||||||
it.date_upload = dateUpload
|
it.date_upload = dateUpload
|
||||||
it.chapter_number = chapterNumber
|
it.chapter_number = chapterNumber
|
||||||
it.source_order = sourceOrder.toInt()
|
it.source_order = sourceOrder.toInt()
|
||||||
|
it.last_modified = lastModifiedAt
|
||||||
}
|
}
|
||||||
|
@ -17,6 +17,7 @@ import androidx.compose.material.icons.outlined.Label
|
|||||||
import androidx.compose.material.icons.outlined.QueryStats
|
import androidx.compose.material.icons.outlined.QueryStats
|
||||||
import androidx.compose.material.icons.outlined.Settings
|
import androidx.compose.material.icons.outlined.Settings
|
||||||
import androidx.compose.material.icons.outlined.SettingsBackupRestore
|
import androidx.compose.material.icons.outlined.SettingsBackupRestore
|
||||||
|
import androidx.compose.material.icons.outlined.Sync
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
@ -46,6 +47,7 @@ fun MoreScreen(
|
|||||||
onClickCategories: () -> Unit,
|
onClickCategories: () -> Unit,
|
||||||
onClickStats: () -> Unit,
|
onClickStats: () -> Unit,
|
||||||
onClickBackupAndRestore: () -> Unit,
|
onClickBackupAndRestore: () -> Unit,
|
||||||
|
onClickSync: () -> Unit,
|
||||||
onClickSettings: () -> Unit,
|
onClickSettings: () -> Unit,
|
||||||
onClickAbout: () -> Unit,
|
onClickAbout: () -> Unit,
|
||||||
) {
|
) {
|
||||||
@ -146,6 +148,13 @@ fun MoreScreen(
|
|||||||
onPreferenceClick = onClickBackupAndRestore,
|
onPreferenceClick = onClickBackupAndRestore,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
item {
|
||||||
|
TextPreferenceWidget(
|
||||||
|
title = stringResource(R.string.label_sync),
|
||||||
|
icon = Icons.Outlined.Sync,
|
||||||
|
onPreferenceClick = onClickSync,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
item { Divider() }
|
item { Divider() }
|
||||||
|
|
||||||
|
@ -228,6 +228,12 @@ object SettingsMainScreen : Screen() {
|
|||||||
icon = Icons.Outlined.SettingsBackupRestore,
|
icon = Icons.Outlined.SettingsBackupRestore,
|
||||||
screen = SettingsBackupScreen,
|
screen = SettingsBackupScreen,
|
||||||
),
|
),
|
||||||
|
Item(
|
||||||
|
titleRes = R.string.label_sync,
|
||||||
|
subtitleRes = R.string.pref_sync_summary,
|
||||||
|
icon = Icons.Outlined.Sync,
|
||||||
|
screen = SettingsSyncScreen,
|
||||||
|
),
|
||||||
Item(
|
Item(
|
||||||
titleRes = R.string.pref_category_security,
|
titleRes = R.string.pref_category_security,
|
||||||
subtitleRes = R.string.pref_security_summary,
|
subtitleRes = R.string.pref_security_summary,
|
||||||
|
@ -291,6 +291,7 @@ private val settingScreens = listOf(
|
|||||||
SettingsTrackingScreen,
|
SettingsTrackingScreen,
|
||||||
SettingsBrowseScreen,
|
SettingsBrowseScreen,
|
||||||
SettingsBackupScreen,
|
SettingsBackupScreen,
|
||||||
|
SettingsSyncScreen,
|
||||||
SettingsSecurityScreen,
|
SettingsSecurityScreen,
|
||||||
SettingsAdvancedScreen,
|
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.download.service.DownloadPreferences
|
||||||
import tachiyomi.domain.library.service.LibraryPreferences
|
import tachiyomi.domain.library.service.LibraryPreferences
|
||||||
import tachiyomi.domain.source.service.SourceManager
|
import tachiyomi.domain.source.service.SourceManager
|
||||||
|
import tachiyomi.domain.sync.SyncPreferences
|
||||||
import tachiyomi.source.local.image.LocalCoverManager
|
import tachiyomi.source.local.image.LocalCoverManager
|
||||||
import tachiyomi.source.local.io.LocalSourceFileSystem
|
import tachiyomi.source.local.io.LocalSourceFileSystem
|
||||||
import uy.kohesive.injekt.api.InjektModule
|
import uy.kohesive.injekt.api.InjektModule
|
||||||
@ -197,6 +198,9 @@ class PreferenceModule(val application: Application) : InjektModule {
|
|||||||
preferenceStore = get(),
|
preferenceStore = get(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
addSingletonFactory {
|
||||||
|
SyncPreferences(get())
|
||||||
|
}
|
||||||
addSingletonFactory {
|
addSingletonFactory {
|
||||||
UiPreferences(get())
|
UiPreferences(get())
|
||||||
}
|
}
|
||||||
|
@ -5,7 +5,6 @@ import android.content.Context
|
|||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import com.hippo.unifile.UniFile
|
import com.hippo.unifile.UniFile
|
||||||
import eu.kanade.domain.chapter.model.copyFrom
|
import eu.kanade.domain.chapter.model.copyFrom
|
||||||
import eu.kanade.domain.manga.model.copyFrom
|
|
||||||
import eu.kanade.tachiyomi.R
|
import eu.kanade.tachiyomi.R
|
||||||
import eu.kanade.tachiyomi.data.backup.BackupConst.BACKUP_CATEGORY
|
import eu.kanade.tachiyomi.data.backup.BackupConst.BACKUP_CATEGORY
|
||||||
import eu.kanade.tachiyomi.data.backup.BackupConst.BACKUP_CATEGORY_MASK
|
import eu.kanade.tachiyomi.data.backup.BackupConst.BACKUP_CATEGORY_MASK
|
||||||
@ -134,7 +133,7 @@ class BackupManager(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun backupExtensionInfo(mangas: List<Manga>): List<BackupSource> {
|
fun backupExtensionInfo(mangas: List<Manga>): List<BackupSource> {
|
||||||
return mangas
|
return mangas
|
||||||
.asSequence()
|
.asSequence()
|
||||||
.map(Manga::source)
|
.map(Manga::source)
|
||||||
@ -149,7 +148,7 @@ class BackupManager(
|
|||||||
*
|
*
|
||||||
* @return list of [BackupCategory] to be backed up
|
* @return list of [BackupCategory] to be backed up
|
||||||
*/
|
*/
|
||||||
private suspend fun backupCategories(options: Int): List<BackupCategory> {
|
suspend fun backupCategories(options: Int): List<BackupCategory> {
|
||||||
// Check if user wants category information in backup
|
// Check if user wants category information in backup
|
||||||
return if (options and BACKUP_CATEGORY_MASK == BACKUP_CATEGORY) {
|
return if (options and BACKUP_CATEGORY_MASK == BACKUP_CATEGORY) {
|
||||||
getCategories.await()
|
getCategories.await()
|
||||||
@ -160,7 +159,7 @@ class BackupManager(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun backupMangas(mangas: List<Manga>, flags: Int): List<BackupManga> {
|
suspend fun backupMangas(mangas: List<Manga>, flags: Int): List<BackupManga> {
|
||||||
return mangas.map {
|
return mangas.map {
|
||||||
backupManga(it, flags)
|
backupManga(it, flags)
|
||||||
}
|
}
|
||||||
@ -455,7 +454,7 @@ class BackupManager(
|
|||||||
updatedChapter = updatedChapter.copy(id = dbChapter._id)
|
updatedChapter = updatedChapter.copy(id = dbChapter._id)
|
||||||
updatedChapter = updatedChapter.copyFrom(dbChapter)
|
updatedChapter = updatedChapter.copyFrom(dbChapter)
|
||||||
if (dbChapter.read && !updatedChapter.read) {
|
if (dbChapter.read && !updatedChapter.read) {
|
||||||
updatedChapter = updatedChapter.copy(read = true, lastPageRead = dbChapter.last_page_read)
|
updatedChapter = updatedChapter.copy(read = chapter.read, lastPageRead = dbChapter.last_page_read)
|
||||||
} else if (updatedChapter.lastPageRead == 0L && dbChapter.last_page_read != 0L) {
|
} else if (updatedChapter.lastPageRead == 0L && dbChapter.last_page_read != 0L) {
|
||||||
updatedChapter = updatedChapter.copy(lastPageRead = dbChapter.last_page_read)
|
updatedChapter = updatedChapter.copy(lastPageRead = dbChapter.last_page_read)
|
||||||
}
|
}
|
||||||
@ -513,7 +512,7 @@ class BackupManager(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun updateManga(manga: Manga): Long {
|
suspend fun updateManga(manga: Manga): Long {
|
||||||
handler.await(true) {
|
handler.await(true) {
|
||||||
mangasQueries.update(
|
mangasQueries.update(
|
||||||
source = manga.source,
|
source = manga.source,
|
||||||
|
@ -79,9 +79,9 @@ class BackupNotifier(private val context: Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun showRestoreProgress(content: String = "", progress: Int = 0, maxAmount: Int = 100): NotificationCompat.Builder {
|
fun showRestoreProgress(content: String = "", contentTitle: String = context.getString(R.string.restoring_backup), progress: Int = 0, maxAmount: Int = 100): NotificationCompat.Builder {
|
||||||
val builder = with(progressNotificationBuilder) {
|
val builder = with(progressNotificationBuilder) {
|
||||||
setContentTitle(context.getString(R.string.restoring_backup))
|
setContentTitle(contentTitle)
|
||||||
|
|
||||||
if (!preferences.hideNotificationContent().get()) {
|
if (!preferences.hideNotificationContent().get()) {
|
||||||
setContentText(content)
|
setContentText(content)
|
||||||
@ -114,7 +114,7 @@ class BackupNotifier(private val context: Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun showRestoreComplete(time: Long, errorCount: Int, path: String?, file: String?) {
|
fun showRestoreComplete(time: Long, errorCount: Int, path: String?, file: String?, contentTitle: String = context.getString(R.string.restore_completed)) {
|
||||||
context.cancelNotification(Notifications.ID_RESTORE_PROGRESS)
|
context.cancelNotification(Notifications.ID_RESTORE_PROGRESS)
|
||||||
|
|
||||||
val timeString = context.getString(
|
val timeString = context.getString(
|
||||||
@ -126,7 +126,7 @@ class BackupNotifier(private val context: Context) {
|
|||||||
)
|
)
|
||||||
|
|
||||||
with(completeNotificationBuilder) {
|
with(completeNotificationBuilder) {
|
||||||
setContentTitle(context.getString(R.string.restore_completed))
|
setContentTitle(contentTitle)
|
||||||
setContentText(context.resources.getQuantityString(R.plurals.restore_completed_message, errorCount, timeString, errorCount))
|
setContentText(context.resources.getQuantityString(R.plurals.restore_completed_message, errorCount, timeString, errorCount))
|
||||||
|
|
||||||
clearActions()
|
clearActions()
|
||||||
|
@ -11,6 +11,7 @@ import androidx.work.WorkerParameters
|
|||||||
import androidx.work.workDataOf
|
import androidx.work.workDataOf
|
||||||
import eu.kanade.tachiyomi.R
|
import eu.kanade.tachiyomi.R
|
||||||
import eu.kanade.tachiyomi.data.notification.Notifications
|
import eu.kanade.tachiyomi.data.notification.Notifications
|
||||||
|
import eu.kanade.tachiyomi.data.sync.SyncHolder
|
||||||
import eu.kanade.tachiyomi.util.system.cancelNotification
|
import eu.kanade.tachiyomi.util.system.cancelNotification
|
||||||
import eu.kanade.tachiyomi.util.system.isRunning
|
import eu.kanade.tachiyomi.util.system.isRunning
|
||||||
import eu.kanade.tachiyomi.util.system.workManager
|
import eu.kanade.tachiyomi.util.system.workManager
|
||||||
@ -26,6 +27,8 @@ class BackupRestoreJob(private val context: Context, workerParams: WorkerParamet
|
|||||||
override suspend fun doWork(): Result {
|
override suspend fun doWork(): Result {
|
||||||
val uri = inputData.getString(LOCATION_URI_KEY)?.toUri()
|
val uri = inputData.getString(LOCATION_URI_KEY)?.toUri()
|
||||||
?: return Result.failure()
|
?: return Result.failure()
|
||||||
|
val sync = inputData.getBoolean(SYNC, false)
|
||||||
|
val useBackupHolder = inputData.getBoolean(USE_BACKUP_HOLDER, false)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setForeground(getForegroundInfo())
|
setForeground(getForegroundInfo())
|
||||||
@ -35,7 +38,12 @@ class BackupRestoreJob(private val context: Context, workerParams: WorkerParamet
|
|||||||
|
|
||||||
return try {
|
return try {
|
||||||
val restorer = BackupRestorer(context, notifier)
|
val restorer = BackupRestorer(context, notifier)
|
||||||
restorer.restoreBackup(uri)
|
if (useBackupHolder) {
|
||||||
|
restorer.restoreBackup(uri, sync)
|
||||||
|
SyncHolder.backup = null
|
||||||
|
} else {
|
||||||
|
restorer.restoreBackup(uri, sync)
|
||||||
|
}
|
||||||
Result.success()
|
Result.success()
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
if (e is CancellationException) {
|
if (e is CancellationException) {
|
||||||
@ -63,9 +71,11 @@ class BackupRestoreJob(private val context: Context, workerParams: WorkerParamet
|
|||||||
return context.workManager.isRunning(TAG)
|
return context.workManager.isRunning(TAG)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun start(context: Context, uri: Uri) {
|
fun start(context: Context, uri: Uri, sync: Boolean = false, useBackupHolder: Boolean = false) {
|
||||||
val inputData = workDataOf(
|
val inputData = workDataOf(
|
||||||
LOCATION_URI_KEY to uri.toString(),
|
LOCATION_URI_KEY to uri.toString(),
|
||||||
|
SYNC to sync,
|
||||||
|
USE_BACKUP_HOLDER to useBackupHolder,
|
||||||
)
|
)
|
||||||
val request = OneTimeWorkRequestBuilder<BackupRestoreJob>()
|
val request = OneTimeWorkRequestBuilder<BackupRestoreJob>()
|
||||||
.addTag(TAG)
|
.addTag(TAG)
|
||||||
@ -83,3 +93,7 @@ class BackupRestoreJob(private val context: Context, workerParams: WorkerParamet
|
|||||||
private const val TAG = "BackupRestore"
|
private const val TAG = "BackupRestore"
|
||||||
|
|
||||||
private const val LOCATION_URI_KEY = "location_uri" // String
|
private const val LOCATION_URI_KEY = "location_uri" // String
|
||||||
|
|
||||||
|
private const val SYNC = "sync" // Boolean
|
||||||
|
|
||||||
|
private const val USE_BACKUP_HOLDER = "use_backup_holder" // Boolean
|
||||||
|
@ -7,6 +7,7 @@ import eu.kanade.tachiyomi.data.backup.models.BackupCategory
|
|||||||
import eu.kanade.tachiyomi.data.backup.models.BackupHistory
|
import eu.kanade.tachiyomi.data.backup.models.BackupHistory
|
||||||
import eu.kanade.tachiyomi.data.backup.models.BackupManga
|
import eu.kanade.tachiyomi.data.backup.models.BackupManga
|
||||||
import eu.kanade.tachiyomi.data.backup.models.BackupSource
|
import eu.kanade.tachiyomi.data.backup.models.BackupSource
|
||||||
|
import eu.kanade.tachiyomi.data.sync.SyncHolder
|
||||||
import eu.kanade.tachiyomi.util.BackupUtil
|
import eu.kanade.tachiyomi.util.BackupUtil
|
||||||
import eu.kanade.tachiyomi.util.system.createFileInCacheDir
|
import eu.kanade.tachiyomi.util.system.createFileInCacheDir
|
||||||
import kotlinx.coroutines.coroutineScope
|
import kotlinx.coroutines.coroutineScope
|
||||||
@ -36,12 +37,12 @@ class BackupRestorer(
|
|||||||
|
|
||||||
private val errors = mutableListOf<Pair<Date, String>>()
|
private val errors = mutableListOf<Pair<Date, String>>()
|
||||||
|
|
||||||
suspend fun restoreBackup(uri: Uri): Boolean {
|
suspend fun restoreBackup(uri: Uri, sync: Boolean): Boolean {
|
||||||
val startTime = System.currentTimeMillis()
|
val startTime = System.currentTimeMillis()
|
||||||
restoreProgress = 0
|
restoreProgress = 0
|
||||||
errors.clear()
|
errors.clear()
|
||||||
|
|
||||||
if (!performRestore(uri)) {
|
if (!performRestore(uri, sync)) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -50,7 +51,11 @@ class BackupRestorer(
|
|||||||
|
|
||||||
val logFile = writeErrorLog()
|
val logFile = writeErrorLog()
|
||||||
|
|
||||||
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
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -73,8 +78,12 @@ class BackupRestorer(
|
|||||||
return File("")
|
return File("")
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun performRestore(uri: Uri): Boolean {
|
private suspend fun performRestore(uri: Uri, sync: Boolean): Boolean {
|
||||||
val backup = BackupUtil.decodeBackup(context, uri)
|
val backup = if (sync) {
|
||||||
|
SyncHolder.backup ?: throw IllegalStateException("syncBackup cannot be null when sync is true")
|
||||||
|
} else {
|
||||||
|
BackupUtil.decodeBackup(context, uri)
|
||||||
|
}
|
||||||
|
|
||||||
restoreAmount = backup.backupManga.size + 1 // +1 for categories
|
restoreAmount = backup.backupManga.size + 1 // +1 for categories
|
||||||
|
|
||||||
@ -94,7 +103,7 @@ class BackupRestorer(
|
|||||||
return@coroutineScope false
|
return@coroutineScope false
|
||||||
}
|
}
|
||||||
|
|
||||||
restoreManga(it, backup.backupCategories)
|
restoreManga(it, backup.backupCategories, sync)
|
||||||
}
|
}
|
||||||
// TODO: optionally trigger online library + tracker update
|
// TODO: optionally trigger online library + tracker update
|
||||||
true
|
true
|
||||||
@ -105,10 +114,10 @@ class BackupRestorer(
|
|||||||
backupManager.restoreCategories(backupCategories)
|
backupManager.restoreCategories(backupCategories)
|
||||||
|
|
||||||
restoreProgress += 1
|
restoreProgress += 1
|
||||||
showRestoreProgress(restoreProgress, restoreAmount, context.getString(R.string.categories))
|
showRestoreProgress(restoreProgress, restoreAmount, context.getString(R.string.categories), context.getString(R.string.restoring_backup))
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun restoreManga(backupManga: BackupManga, backupCategories: List<BackupCategory>) {
|
private suspend fun restoreManga(backupManga: BackupManga, backupCategories: List<BackupCategory>, sync: Boolean) {
|
||||||
val manga = backupManga.getMangaImpl()
|
val manga = backupManga.getMangaImpl()
|
||||||
val chapters = backupManga.getChaptersImpl()
|
val chapters = backupManga.getChaptersImpl()
|
||||||
val categories = backupManga.categories.map { it.toInt() }
|
val categories = backupManga.categories.map { it.toInt() }
|
||||||
@ -134,7 +143,11 @@ class BackupRestorer(
|
|||||||
}
|
}
|
||||||
|
|
||||||
restoreProgress += 1
|
restoreProgress += 1
|
||||||
showRestoreProgress(restoreProgress, restoreAmount, manga.title)
|
if (sync) {
|
||||||
|
showRestoreProgress(restoreProgress, restoreAmount, manga.title, context.getString(R.string.syncing_data))
|
||||||
|
} else {
|
||||||
|
showRestoreProgress(restoreProgress, restoreAmount, manga.title, context.getString(R.string.restoring_backup))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -182,7 +195,7 @@ class BackupRestorer(
|
|||||||
* @param amount total restoreAmount of manga
|
* @param amount total restoreAmount of manga
|
||||||
* @param title title of restored manga
|
* @param title title of restored manga
|
||||||
*/
|
*/
|
||||||
private fun showRestoreProgress(progress: Int, amount: Int, title: String) {
|
private fun showRestoreProgress(progress: Int, amount: Int, title: String, contentTitle: String) {
|
||||||
notifier.showRestoreProgress(title, progress, amount)
|
notifier.showRestoreProgress(title, contentTitle, progress, amount)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -20,6 +20,7 @@ data class BackupChapter(
|
|||||||
// chapterNumber is called number is 1.x
|
// chapterNumber is called number is 1.x
|
||||||
@ProtoNumber(9) var chapterNumber: Float = 0F,
|
@ProtoNumber(9) var chapterNumber: Float = 0F,
|
||||||
@ProtoNumber(10) var sourceOrder: Long = 0,
|
@ProtoNumber(10) var sourceOrder: Long = 0,
|
||||||
|
@ProtoNumber(11) var lastModifiedAt: Long? = null,
|
||||||
) {
|
) {
|
||||||
fun toChapterImpl(): Chapter {
|
fun toChapterImpl(): Chapter {
|
||||||
return Chapter.create().copy(
|
return Chapter.create().copy(
|
||||||
@ -33,11 +34,12 @@ data class BackupChapter(
|
|||||||
dateFetch = this@BackupChapter.dateFetch,
|
dateFetch = this@BackupChapter.dateFetch,
|
||||||
dateUpload = this@BackupChapter.dateUpload,
|
dateUpload = this@BackupChapter.dateUpload,
|
||||||
sourceOrder = this@BackupChapter.sourceOrder,
|
sourceOrder = this@BackupChapter.sourceOrder,
|
||||||
|
lastModifiedAt = this@BackupChapter.lastModifiedAt,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val backupChapterMapper = { _: Long, _: Long, url: String, name: String, scanlator: String?, read: Boolean, bookmark: Boolean, lastPageRead: Long, chapterNumber: Float, source_order: Long, dateFetch: Long, dateUpload: Long ->
|
val backupChapterMapper = { _: Long, _: Long, url: String, name: String, scanlator: String?, read: Boolean, bookmark: Boolean, lastPageRead: Long, chapterNumber: Float, source_order: Long, dateFetch: Long, dateUpload: Long, lastModifiedAt: Long? ->
|
||||||
BackupChapter(
|
BackupChapter(
|
||||||
url = url,
|
url = url,
|
||||||
name = name,
|
name = name,
|
||||||
@ -49,5 +51,6 @@ val backupChapterMapper = { _: Long, _: Long, url: String, name: String, scanlat
|
|||||||
dateFetch = dateFetch,
|
dateFetch = dateFetch,
|
||||||
dateUpload = dateUpload,
|
dateUpload = dateUpload,
|
||||||
sourceOrder = source_order,
|
sourceOrder = source_order,
|
||||||
|
lastModifiedAt = lastModifiedAt,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -39,6 +39,7 @@ data class BackupManga(
|
|||||||
@ProtoNumber(103) var viewer_flags: Int? = null,
|
@ProtoNumber(103) var viewer_flags: Int? = null,
|
||||||
@ProtoNumber(104) var history: List<BackupHistory> = emptyList(),
|
@ProtoNumber(104) var history: List<BackupHistory> = emptyList(),
|
||||||
@ProtoNumber(105) var updateStrategy: UpdateStrategy = UpdateStrategy.ALWAYS_UPDATE,
|
@ProtoNumber(105) var updateStrategy: UpdateStrategy = UpdateStrategy.ALWAYS_UPDATE,
|
||||||
|
@ProtoNumber(106) var lastModifiedAt: Long? = 0,
|
||||||
) {
|
) {
|
||||||
fun getMangaImpl(): Manga {
|
fun getMangaImpl(): Manga {
|
||||||
return Manga.create().copy(
|
return Manga.create().copy(
|
||||||
@ -56,6 +57,7 @@ data class BackupManga(
|
|||||||
viewerFlags = (this@BackupManga.viewer_flags ?: this@BackupManga.viewer).toLong(),
|
viewerFlags = (this@BackupManga.viewer_flags ?: this@BackupManga.viewer).toLong(),
|
||||||
chapterFlags = this@BackupManga.chapterFlags.toLong(),
|
chapterFlags = this@BackupManga.chapterFlags.toLong(),
|
||||||
updateStrategy = this@BackupManga.updateStrategy,
|
updateStrategy = this@BackupManga.updateStrategy,
|
||||||
|
lastModifiedAt = this@BackupManga.lastModifiedAt,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -89,6 +91,7 @@ data class BackupManga(
|
|||||||
viewer_flags = manga.viewerFlags.toInt(),
|
viewer_flags = manga.viewerFlags.toInt(),
|
||||||
chapterFlags = manga.chapterFlags.toInt(),
|
chapterFlags = manga.chapterFlags.toInt(),
|
||||||
updateStrategy = manga.updateStrategy,
|
updateStrategy = manga.updateStrategy,
|
||||||
|
lastModifiedAt = manga.lastModifiedAt,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -19,6 +19,8 @@ interface Chapter : SChapter, Serializable {
|
|||||||
var date_fetch: Long
|
var date_fetch: Long
|
||||||
|
|
||||||
var source_order: Int
|
var source_order: Int
|
||||||
|
|
||||||
|
var last_modified: Long?
|
||||||
}
|
}
|
||||||
|
|
||||||
fun Chapter.toDomainChapter(): DomainChapter? {
|
fun Chapter.toDomainChapter(): DomainChapter? {
|
||||||
@ -36,5 +38,6 @@ fun Chapter.toDomainChapter(): DomainChapter? {
|
|||||||
dateUpload = date_upload,
|
dateUpload = date_upload,
|
||||||
chapterNumber = chapter_number,
|
chapterNumber = chapter_number,
|
||||||
scanlator = scanlator,
|
scanlator = scanlator,
|
||||||
|
lastModifiedAt = last_modified,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -26,6 +26,8 @@ class ChapterImpl : Chapter {
|
|||||||
|
|
||||||
override var source_order: Int = 0
|
override var source_order: Int = 0
|
||||||
|
|
||||||
|
override var last_modified: Long? = null
|
||||||
|
|
||||||
override fun equals(other: Any?): Boolean {
|
override fun equals(other: Any?): Boolean {
|
||||||
if (this === other) return true
|
if (this === other) return true
|
||||||
if (other == null || javaClass != other.javaClass) return false
|
if (other == null || javaClass != other.javaClass) return false
|
||||||
|
@ -11,6 +11,7 @@ import eu.kanade.tachiyomi.R
|
|||||||
import eu.kanade.tachiyomi.data.backup.BackupRestoreJob
|
import eu.kanade.tachiyomi.data.backup.BackupRestoreJob
|
||||||
import eu.kanade.tachiyomi.data.download.DownloadManager
|
import eu.kanade.tachiyomi.data.download.DownloadManager
|
||||||
import eu.kanade.tachiyomi.data.library.LibraryUpdateJob
|
import eu.kanade.tachiyomi.data.library.LibraryUpdateJob
|
||||||
|
import eu.kanade.tachiyomi.data.sync.SyncDataJob
|
||||||
import eu.kanade.tachiyomi.data.updater.AppUpdateService
|
import eu.kanade.tachiyomi.data.updater.AppUpdateService
|
||||||
import eu.kanade.tachiyomi.ui.main.MainActivity
|
import eu.kanade.tachiyomi.ui.main.MainActivity
|
||||||
import eu.kanade.tachiyomi.ui.reader.ReaderActivity
|
import eu.kanade.tachiyomi.ui.reader.ReaderActivity
|
||||||
@ -83,6 +84,8 @@ class NotificationReceiver : BroadcastReceiver() {
|
|||||||
intent.getIntExtra(EXTRA_NOTIFICATION_ID, -1),
|
intent.getIntExtra(EXTRA_NOTIFICATION_ID, -1),
|
||||||
)
|
)
|
||||||
ACTION_CANCEL_RESTORE -> cancelRestore(context)
|
ACTION_CANCEL_RESTORE -> cancelRestore(context)
|
||||||
|
|
||||||
|
ACTION_CANCEL_SYNC -> cancelSync(context)
|
||||||
// Cancel library update and dismiss notification
|
// Cancel library update and dismiss notification
|
||||||
ACTION_CANCEL_LIBRARY_UPDATE -> cancelLibraryUpdate(context)
|
ACTION_CANCEL_LIBRARY_UPDATE -> cancelLibraryUpdate(context)
|
||||||
// Cancel downloading app update
|
// Cancel downloading app update
|
||||||
@ -213,6 +216,15 @@ class NotificationReceiver : BroadcastReceiver() {
|
|||||||
AppUpdateService.stop(context)
|
AppUpdateService.stop(context)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Method called when user wants to stop a backup restore job.
|
||||||
|
*
|
||||||
|
* @param context context of application
|
||||||
|
*/
|
||||||
|
private fun cancelSync(context: Context) {
|
||||||
|
SyncDataJob.stop(context)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Method called when user wants to mark manga chapters as read
|
* Method called when user wants to mark manga chapters as read
|
||||||
*
|
*
|
||||||
@ -266,6 +278,8 @@ class NotificationReceiver : BroadcastReceiver() {
|
|||||||
|
|
||||||
private const val ACTION_CANCEL_RESTORE = "$ID.$NAME.CANCEL_RESTORE"
|
private const val ACTION_CANCEL_RESTORE = "$ID.$NAME.CANCEL_RESTORE"
|
||||||
|
|
||||||
|
private const val ACTION_CANCEL_SYNC = "$ID.$NAME.CANCEL_SYNC"
|
||||||
|
|
||||||
private const val ACTION_CANCEL_LIBRARY_UPDATE = "$ID.$NAME.CANCEL_LIBRARY_UPDATE"
|
private const val ACTION_CANCEL_LIBRARY_UPDATE = "$ID.$NAME.CANCEL_LIBRARY_UPDATE"
|
||||||
|
|
||||||
private const val ACTION_CANCEL_APP_UPDATE_DOWNLOAD = "$ID.$NAME.CANCEL_APP_UPDATE_DOWNLOAD"
|
private const val ACTION_CANCEL_APP_UPDATE_DOWNLOAD = "$ID.$NAME.CANCEL_APP_UPDATE_DOWNLOAD"
|
||||||
@ -570,5 +584,20 @@ class NotificationReceiver : BroadcastReceiver() {
|
|||||||
}
|
}
|
||||||
return PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
|
return PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns [PendingIntent] that cancels a sync restore job.
|
||||||
|
*
|
||||||
|
* @param context context of application
|
||||||
|
* @param notificationId id of notification
|
||||||
|
* @return [PendingIntent]
|
||||||
|
*/
|
||||||
|
internal fun cancelSyncPendingBroadcast(context: Context, notificationId: Int): PendingIntent {
|
||||||
|
val intent = Intent(context, NotificationReceiver::class.java).apply {
|
||||||
|
action = ACTION_CANCEL_SYNC
|
||||||
|
putExtra(EXTRA_NOTIFICATION_ID, notificationId)
|
||||||
|
}
|
||||||
|
return PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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()) },
|
onClickCategories = { navigator.push(CategoryScreen()) },
|
||||||
onClickStats = { navigator.push(StatsScreen()) },
|
onClickStats = { navigator.push(StatsScreen()) },
|
||||||
onClickBackupAndRestore = { navigator.push(SettingsScreen.toBackupScreen()) },
|
onClickBackupAndRestore = { navigator.push(SettingsScreen.toBackupScreen()) },
|
||||||
|
onClickSync = { navigator.push(SettingsScreen.toSyncScreen()) },
|
||||||
onClickSettings = { navigator.push(SettingsScreen.toMainScreen()) },
|
onClickSettings = { navigator.push(SettingsScreen.toMainScreen()) },
|
||||||
onClickAbout = { navigator.push(SettingsScreen.toAboutScreen()) },
|
onClickAbout = { navigator.push(SettingsScreen.toAboutScreen()) },
|
||||||
)
|
)
|
||||||
|
@ -16,6 +16,7 @@ import eu.kanade.presentation.more.settings.screen.AboutScreen
|
|||||||
import eu.kanade.presentation.more.settings.screen.SettingsAppearanceScreen
|
import eu.kanade.presentation.more.settings.screen.SettingsAppearanceScreen
|
||||||
import eu.kanade.presentation.more.settings.screen.SettingsBackupScreen
|
import eu.kanade.presentation.more.settings.screen.SettingsBackupScreen
|
||||||
import eu.kanade.presentation.more.settings.screen.SettingsMainScreen
|
import eu.kanade.presentation.more.settings.screen.SettingsMainScreen
|
||||||
|
import eu.kanade.presentation.more.settings.screen.SettingsSyncScreen
|
||||||
import eu.kanade.presentation.util.DefaultNavigatorScreenTransition
|
import eu.kanade.presentation.util.DefaultNavigatorScreenTransition
|
||||||
import eu.kanade.presentation.util.LocalBackPress
|
import eu.kanade.presentation.util.LocalBackPress
|
||||||
import eu.kanade.presentation.util.Screen
|
import eu.kanade.presentation.util.Screen
|
||||||
@ -24,6 +25,7 @@ import tachiyomi.presentation.core.components.TwoPanelBox
|
|||||||
|
|
||||||
class SettingsScreen private constructor(
|
class SettingsScreen private constructor(
|
||||||
val toBackup: Boolean,
|
val toBackup: Boolean,
|
||||||
|
val toSync: Boolean,
|
||||||
val toAbout: Boolean,
|
val toAbout: Boolean,
|
||||||
) : Screen() {
|
) : Screen() {
|
||||||
|
|
||||||
@ -34,6 +36,8 @@ class SettingsScreen private constructor(
|
|||||||
Navigator(
|
Navigator(
|
||||||
screen = if (toBackup) {
|
screen = if (toBackup) {
|
||||||
SettingsBackupScreen
|
SettingsBackupScreen
|
||||||
|
} else if (toSync) {
|
||||||
|
SettingsSyncScreen
|
||||||
} else if (toAbout) {
|
} else if (toAbout) {
|
||||||
AboutScreen
|
AboutScreen
|
||||||
} else {
|
} else {
|
||||||
@ -56,6 +60,8 @@ class SettingsScreen private constructor(
|
|||||||
Navigator(
|
Navigator(
|
||||||
screen = if (toBackup) {
|
screen = if (toBackup) {
|
||||||
SettingsBackupScreen
|
SettingsBackupScreen
|
||||||
|
} else if (toSync) {
|
||||||
|
SettingsSyncScreen
|
||||||
} else if (toAbout) {
|
} else if (toAbout) {
|
||||||
AboutScreen
|
AboutScreen
|
||||||
} else {
|
} else {
|
||||||
@ -79,10 +85,12 @@ class SettingsScreen private constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
fun toMainScreen() = SettingsScreen(toBackup = false, toAbout = false)
|
fun toMainScreen() = SettingsScreen(toBackup = false, toSync = false, toAbout = false)
|
||||||
|
|
||||||
fun toBackupScreen() = SettingsScreen(toBackup = true, toAbout = false)
|
fun toBackupScreen() = SettingsScreen(toBackup = true, toSync = false, toAbout = false)
|
||||||
|
|
||||||
fun toAboutScreen() = SettingsScreen(toBackup = false, toAbout = true)
|
fun toSyncScreen() = SettingsScreen(toBackup = false, toSync = true, toAbout = false)
|
||||||
|
|
||||||
|
fun toAboutScreen() = SettingsScreen(toBackup = false, toSync = false, toAbout = true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,5 +1,7 @@
|
|||||||
package tachiyomi.core.preference
|
package tachiyomi.core.preference
|
||||||
|
|
||||||
|
import java.time.Instant
|
||||||
|
|
||||||
interface PreferenceStore {
|
interface PreferenceStore {
|
||||||
|
|
||||||
fun getString(key: String, defaultValue: String = ""): Preference<String>
|
fun getString(key: String, defaultValue: String = ""): Preference<String>
|
||||||
@ -14,6 +16,15 @@ interface PreferenceStore {
|
|||||||
|
|
||||||
fun getStringSet(key: String, defaultValue: Set<String> = emptySet()): Preference<Set<String>>
|
fun getStringSet(key: String, defaultValue: Set<String> = emptySet()): Preference<Set<String>>
|
||||||
|
|
||||||
|
fun getInstant(key: String, defaultValue: Instant = Instant.EPOCH): Preference<Instant> {
|
||||||
|
return getObject(
|
||||||
|
key = key,
|
||||||
|
defaultValue = defaultValue,
|
||||||
|
serializer = { it.epochSecond.toString() },
|
||||||
|
deserializer = { Instant.ofEpochSecond(it.toLong()) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
fun <T> getObject(
|
fun <T> getObject(
|
||||||
key: String,
|
key: String,
|
||||||
defaultValue: T,
|
defaultValue: T,
|
||||||
|
@ -2,8 +2,8 @@ package tachiyomi.data.chapter
|
|||||||
|
|
||||||
import tachiyomi.domain.chapter.model.Chapter
|
import tachiyomi.domain.chapter.model.Chapter
|
||||||
|
|
||||||
val chapterMapper: (Long, Long, String, String, String?, Boolean, Boolean, Long, Float, Long, Long, Long) -> Chapter =
|
val chapterMapper: (Long, Long, String, String, String?, Boolean, Boolean, Long, Float, Long, Long, Long, Long?) -> Chapter =
|
||||||
{ id, mangaId, url, name, scanlator, read, bookmark, lastPageRead, chapterNumber, sourceOrder, dateFetch, dateUpload ->
|
{ id, mangaId, url, name, scanlator, read, bookmark, lastPageRead, chapterNumber, sourceOrder, dateFetch, dateUpload, lastModifiedAt ->
|
||||||
Chapter(
|
Chapter(
|
||||||
id = id,
|
id = id,
|
||||||
mangaId = mangaId,
|
mangaId = mangaId,
|
||||||
@ -17,5 +17,6 @@ val chapterMapper: (Long, Long, String, String, String?, Boolean, Boolean, Long,
|
|||||||
dateUpload = dateUpload,
|
dateUpload = dateUpload,
|
||||||
chapterNumber = chapterNumber,
|
chapterNumber = chapterNumber,
|
||||||
scanlator = scanlator,
|
scanlator = scanlator,
|
||||||
|
lastModifiedAt = lastModifiedAt,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -4,8 +4,8 @@ import eu.kanade.tachiyomi.source.model.UpdateStrategy
|
|||||||
import tachiyomi.domain.library.model.LibraryManga
|
import tachiyomi.domain.library.model.LibraryManga
|
||||||
import tachiyomi.domain.manga.model.Manga
|
import tachiyomi.domain.manga.model.Manga
|
||||||
|
|
||||||
val mangaMapper: (Long, Long, String, String?, String?, String?, List<String>?, String, Long, String?, Boolean, Long?, Long?, Boolean, Long, Long, Long, Long, UpdateStrategy, Long) -> Manga =
|
val mangaMapper: (Long, Long, String, String?, String?, String?, List<String>?, String, Long, String?, Boolean, Long?, Long?, Boolean, Long, Long, Long, Long, UpdateStrategy, Long, Long?) -> Manga =
|
||||||
{ id, source, url, artist, author, description, genre, title, status, thumbnailUrl, favorite, lastUpdate, nextUpdate, initialized, viewerFlags, chapterFlags, coverLastModified, dateAdded, updateStrategy, calculateInterval ->
|
{ id, source, url, artist, author, description, genre, title, status, thumbnailUrl, favorite, lastUpdate, nextUpdate, initialized, viewerFlags, chapterFlags, coverLastModified, dateAdded, updateStrategy, calculateInterval, lastModifiedAt ->
|
||||||
Manga(
|
Manga(
|
||||||
id = id,
|
id = id,
|
||||||
source = source,
|
source = source,
|
||||||
@ -27,11 +27,12 @@ val mangaMapper: (Long, Long, String, String?, String?, String?, List<String>?,
|
|||||||
thumbnailUrl = thumbnailUrl,
|
thumbnailUrl = thumbnailUrl,
|
||||||
updateStrategy = updateStrategy,
|
updateStrategy = updateStrategy,
|
||||||
initialized = initialized,
|
initialized = initialized,
|
||||||
|
lastModifiedAt = lastModifiedAt,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
val libraryManga: (Long, Long, String, String?, String?, String?, List<String>?, String, Long, String?, Boolean, Long?, Long?, Boolean, Long, Long, Long, Long, UpdateStrategy, Long, Long, Long, Long, Long, Long, Long, Long) -> LibraryManga =
|
val libraryManga: (Long, Long, String, String?, String?, String?, List<String>?, String, Long, String?, Boolean, Long?, Long?, Boolean, Long, Long, Long, Long, UpdateStrategy, Long, Long?, Long, Long, Long, Long, Long, Long, Long) -> LibraryManga =
|
||||||
{ id, source, url, artist, author, description, genre, title, status, thumbnailUrl, favorite, lastUpdate, nextUpdate, initialized, viewerFlags, chapterFlags, coverLastModified, dateAdded, updateStrategy, calculateInterval, totalCount, readCount, latestUpload, chapterFetchedAt, lastRead, bookmarkCount, category ->
|
{ id, source, url, artist, author, description, genre, title, status, thumbnailUrl, favorite, lastUpdate, nextUpdate, initialized, viewerFlags, chapterFlags, coverLastModified, dateAdded, updateStrategy, calculateInterval, lastModifiedAt, totalCount, readCount, latestUpload, chapterFetchedAt, lastRead, bookmarkCount, category ->
|
||||||
LibraryManga(
|
LibraryManga(
|
||||||
manga = mangaMapper(
|
manga = mangaMapper(
|
||||||
id,
|
id,
|
||||||
@ -54,6 +55,7 @@ val libraryManga: (Long, Long, String, String?, String?, String?, List<String>?,
|
|||||||
dateAdded,
|
dateAdded,
|
||||||
updateStrategy,
|
updateStrategy,
|
||||||
calculateInterval,
|
calculateInterval,
|
||||||
|
lastModifiedAt,
|
||||||
),
|
),
|
||||||
category = category,
|
category = category,
|
||||||
totalChapters = totalCount,
|
totalChapters = totalCount,
|
||||||
|
@ -11,6 +11,7 @@ CREATE TABLE chapters(
|
|||||||
source_order INTEGER NOT NULL,
|
source_order INTEGER NOT NULL,
|
||||||
date_fetch INTEGER AS Long NOT NULL,
|
date_fetch INTEGER AS Long NOT NULL,
|
||||||
date_upload INTEGER AS Long NOT NULL,
|
date_upload INTEGER AS Long NOT NULL,
|
||||||
|
last_modified_at INTEGER AS Long,
|
||||||
FOREIGN KEY(manga_id) REFERENCES mangas (_id)
|
FOREIGN KEY(manga_id) REFERENCES mangas (_id)
|
||||||
ON DELETE CASCADE
|
ON DELETE CASCADE
|
||||||
);
|
);
|
||||||
@ -18,6 +19,15 @@ CREATE TABLE chapters(
|
|||||||
CREATE INDEX chapters_manga_id_index ON chapters(manga_id);
|
CREATE INDEX chapters_manga_id_index ON chapters(manga_id);
|
||||||
CREATE INDEX chapters_unread_by_manga_index ON chapters(manga_id, read) WHERE read = 0;
|
CREATE INDEX chapters_unread_by_manga_index ON chapters(manga_id, read) WHERE read = 0;
|
||||||
|
|
||||||
|
CREATE TRIGGER update_last_modified_at_chapters
|
||||||
|
AFTER UPDATE ON chapters
|
||||||
|
FOR EACH ROW
|
||||||
|
BEGIN
|
||||||
|
UPDATE chapters
|
||||||
|
SET last_modified_at = strftime('%s', 'now')
|
||||||
|
WHERE _id = new._id;
|
||||||
|
END;
|
||||||
|
|
||||||
getChapterById:
|
getChapterById:
|
||||||
SELECT *
|
SELECT *
|
||||||
FROM chapters
|
FROM chapters
|
||||||
|
@ -22,12 +22,31 @@ CREATE TABLE mangas(
|
|||||||
cover_last_modified INTEGER AS Long NOT NULL,
|
cover_last_modified INTEGER AS Long NOT NULL,
|
||||||
date_added INTEGER AS Long NOT NULL,
|
date_added INTEGER AS Long NOT NULL,
|
||||||
update_strategy INTEGER AS UpdateStrategy NOT NULL DEFAULT 0,
|
update_strategy INTEGER AS UpdateStrategy NOT NULL DEFAULT 0,
|
||||||
calculate_interval INTEGER DEFAULT 0 NOT NULL
|
calculate_interval INTEGER DEFAULT 0 NOT NULL,
|
||||||
|
last_modified_at INTEGER AS Long
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE INDEX library_favorite_index ON mangas(favorite) WHERE favorite = 1;
|
CREATE INDEX library_favorite_index ON mangas(favorite) WHERE favorite = 1;
|
||||||
CREATE INDEX mangas_url_index ON mangas(url);
|
CREATE INDEX mangas_url_index ON mangas(url);
|
||||||
|
|
||||||
|
CREATE TRIGGER update_last_modified_at_mangas
|
||||||
|
AFTER UPDATE ON mangas
|
||||||
|
FOR EACH ROW
|
||||||
|
BEGIN
|
||||||
|
UPDATE mangas
|
||||||
|
SET last_modified_at = strftime('%s', 'now')
|
||||||
|
WHERE _id = new._id;
|
||||||
|
END;
|
||||||
|
|
||||||
|
CREATE TRIGGER insert_last_modified_at_mangas
|
||||||
|
AFTER INSERT ON mangas
|
||||||
|
FOR EACH ROW
|
||||||
|
BEGIN
|
||||||
|
UPDATE mangas
|
||||||
|
SET last_modified_at = strftime('%s', 'now')
|
||||||
|
WHERE _id = new._id;
|
||||||
|
END;
|
||||||
|
|
||||||
getMangaById:
|
getMangaById:
|
||||||
SELECT *
|
SELECT *
|
||||||
FROM mangas
|
FROM mangas
|
||||||
@ -45,6 +64,10 @@ SELECT *
|
|||||||
FROM mangas
|
FROM mangas
|
||||||
WHERE favorite = 1;
|
WHERE favorite = 1;
|
||||||
|
|
||||||
|
getAllManga:
|
||||||
|
SELECT *
|
||||||
|
FROM mangas;
|
||||||
|
|
||||||
getSourceIdWithFavoriteCount:
|
getSourceIdWithFavoriteCount:
|
||||||
SELECT
|
SELECT
|
||||||
source,
|
source,
|
||||||
|
@ -2,12 +2,31 @@ CREATE TABLE mangas_categories(
|
|||||||
_id INTEGER NOT NULL PRIMARY KEY,
|
_id INTEGER NOT NULL PRIMARY KEY,
|
||||||
manga_id INTEGER NOT NULL,
|
manga_id INTEGER NOT NULL,
|
||||||
category_id INTEGER NOT NULL,
|
category_id INTEGER NOT NULL,
|
||||||
|
last_modified_at INTEGER AS Long,
|
||||||
FOREIGN KEY(category_id) REFERENCES categories (_id)
|
FOREIGN KEY(category_id) REFERENCES categories (_id)
|
||||||
ON DELETE CASCADE,
|
ON DELETE CASCADE,
|
||||||
FOREIGN KEY(manga_id) REFERENCES mangas (_id)
|
FOREIGN KEY(manga_id) REFERENCES mangas (_id)
|
||||||
ON DELETE CASCADE
|
ON DELETE CASCADE
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TRIGGER update_last_modified_at_mangas_categories
|
||||||
|
AFTER UPDATE ON mangas_categories
|
||||||
|
FOR EACH ROW
|
||||||
|
BEGIN
|
||||||
|
UPDATE mangas_categories
|
||||||
|
SET last_modified_at = strftime('%s', 'now')
|
||||||
|
WHERE _id = new._id;
|
||||||
|
END;
|
||||||
|
|
||||||
|
CREATE TRIGGER insert_last_modified_at_mangas_categories
|
||||||
|
AFTER INSERT ON mangas_categories
|
||||||
|
FOR EACH ROW
|
||||||
|
BEGIN
|
||||||
|
UPDATE mangas_categories
|
||||||
|
SET last_modified_at = strftime('%s', 'now')
|
||||||
|
WHERE _id = new._id;
|
||||||
|
END;
|
||||||
|
|
||||||
insert:
|
insert:
|
||||||
INSERT INTO mangas_categories(manga_id, category_id)
|
INSERT INTO mangas_categories(manga_id, category_id)
|
||||||
VALUES (:mangaId, :categoryId);
|
VALUES (:mangaId, :categoryId);
|
||||||
|
@ -1 +1,144 @@
|
|||||||
|
import kotlin.collections.List;
|
||||||
|
import eu.kanade.tachiyomi.source.model.UpdateStrategy;
|
||||||
|
|
||||||
ALTER TABLE mangas ADD COLUMN calculate_interval INTEGER DEFAULT 0 NOT NULL;
|
ALTER TABLE mangas ADD COLUMN calculate_interval INTEGER DEFAULT 0 NOT NULL;
|
||||||
|
|
||||||
|
-- Drop indices
|
||||||
|
DROP INDEX IF EXISTS library_favorite_index;
|
||||||
|
DROP INDEX IF EXISTS mangas_url_index;
|
||||||
|
DROP INDEX IF EXISTS chapters_manga_id_index;
|
||||||
|
DROP INDEX IF EXISTS chapters_unread_by_manga_index;
|
||||||
|
|
||||||
|
-- Rename existing tables to temporary tables
|
||||||
|
ALTER TABLE mangas RENAME TO mangas_temp;
|
||||||
|
ALTER TABLE chapters RENAME TO chapters_temp;
|
||||||
|
ALTER TABLE mangas_categories RENAME TO mangas_categories_temp;
|
||||||
|
|
||||||
|
-- Create new tables with updated schema
|
||||||
|
CREATE TABLE mangas(
|
||||||
|
_id INTEGER NOT NULL PRIMARY KEY,
|
||||||
|
source INTEGER NOT NULL,
|
||||||
|
url TEXT NOT NULL,
|
||||||
|
artist TEXT,
|
||||||
|
author TEXT,
|
||||||
|
description TEXT,
|
||||||
|
genre TEXT AS List<String>,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
status INTEGER NOT NULL,
|
||||||
|
thumbnail_url TEXT,
|
||||||
|
favorite INTEGER AS Boolean NOT NULL,
|
||||||
|
last_update INTEGER AS Long,
|
||||||
|
next_update INTEGER AS Long,
|
||||||
|
initialized INTEGER AS Boolean NOT NULL,
|
||||||
|
viewer INTEGER NOT NULL,
|
||||||
|
chapter_flags INTEGER NOT NULL,
|
||||||
|
cover_last_modified INTEGER AS Long NOT NULL,
|
||||||
|
date_added INTEGER AS Long NOT NULL,
|
||||||
|
update_strategy INTEGER AS UpdateStrategy NOT NULL DEFAULT 0,
|
||||||
|
last_modified_at INTEGER AS Long
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE mangas_categories(
|
||||||
|
_id INTEGER NOT NULL PRIMARY KEY,
|
||||||
|
manga_id INTEGER NOT NULL,
|
||||||
|
category_id INTEGER NOT NULL,
|
||||||
|
last_modified_at INTEGER AS Long,
|
||||||
|
FOREIGN KEY(category_id) REFERENCES categories (_id)
|
||||||
|
ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY(manga_id) REFERENCES mangas (_id)
|
||||||
|
ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE chapters(
|
||||||
|
_id INTEGER NOT NULL PRIMARY KEY,
|
||||||
|
manga_id INTEGER NOT NULL,
|
||||||
|
url TEXT NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
scanlator TEXT,
|
||||||
|
read INTEGER AS Boolean NOT NULL,
|
||||||
|
bookmark INTEGER AS Boolean NOT NULL,
|
||||||
|
last_page_read INTEGER NOT NULL,
|
||||||
|
chapter_number REAL AS Float NOT NULL,
|
||||||
|
source_order INTEGER NOT NULL,
|
||||||
|
date_fetch INTEGER AS Long NOT NULL,
|
||||||
|
date_upload INTEGER AS Long NOT NULL,
|
||||||
|
last_modified_at INTEGER AS Long,
|
||||||
|
FOREIGN KEY(manga_id) REFERENCES mangas (_id)
|
||||||
|
ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Copy data from temporary tables to new tables
|
||||||
|
INSERT INTO mangas
|
||||||
|
SELECT _id, source, url, artist, author, description, genre, title, status, thumbnail_url, favorite, last_update, next_update, initialized, viewer, chapter_flags, cover_last_modified, date_added, update_strategy, NULL
|
||||||
|
FROM mangas_temp;
|
||||||
|
|
||||||
|
INSERT INTO chapters
|
||||||
|
SELECT _id, manga_id, url, name, scanlator, read, bookmark, last_page_read, chapter_number, source_order, date_fetch, date_upload, NULL
|
||||||
|
FROM chapters_temp;
|
||||||
|
|
||||||
|
INSERT INTO mangas_categories
|
||||||
|
SELECT _id, manga_id, category_id, NULL
|
||||||
|
FROM mangas_categories_temp;
|
||||||
|
|
||||||
|
-- Create indices
|
||||||
|
CREATE INDEX library_favorite_index ON mangas(favorite) WHERE favorite = 1;
|
||||||
|
CREATE INDEX mangas_url_index ON mangas(url);
|
||||||
|
CREATE INDEX chapters_manga_id_index ON chapters(manga_id);
|
||||||
|
CREATE INDEX chapters_unread_by_manga_index ON chapters(manga_id, read) WHERE read = 0;
|
||||||
|
|
||||||
|
-- Drop temporary tables
|
||||||
|
DROP TABLE IF EXISTS mangas_temp;
|
||||||
|
DROP TABLE IF EXISTS chapters_temp;
|
||||||
|
DROP TABLE IF EXISTS mangas_categories_temp;
|
||||||
|
|
||||||
|
|
||||||
|
-- Create triggers
|
||||||
|
DROP TRIGGER IF EXISTS update_last_modified_at_mangas;
|
||||||
|
CREATE TRIGGER update_last_modified_at_mangas
|
||||||
|
AFTER UPDATE ON mangas
|
||||||
|
FOR EACH ROW
|
||||||
|
BEGIN
|
||||||
|
UPDATE mangas
|
||||||
|
SET last_modified_at = strftime('%s', 'now')
|
||||||
|
WHERE _id = new._id;
|
||||||
|
END;
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS insert_last_modified_at_mangas;
|
||||||
|
CREATE TRIGGER insert_last_modified_at_mangas
|
||||||
|
AFTER INSERT ON mangas
|
||||||
|
FOR EACH ROW
|
||||||
|
BEGIN
|
||||||
|
UPDATE mangas
|
||||||
|
SET last_modified_at = strftime('%s', 'now')
|
||||||
|
WHERE _id = new._id;
|
||||||
|
END;
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS update_last_modified_at_chapters;
|
||||||
|
CREATE TRIGGER update_last_modified_at_chapters
|
||||||
|
AFTER UPDATE ON chapters
|
||||||
|
FOR EACH ROW
|
||||||
|
BEGIN
|
||||||
|
UPDATE chapters
|
||||||
|
SET last_modified_at = strftime('%s', 'now')
|
||||||
|
WHERE _id = new._id;
|
||||||
|
END;
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS update_last_modified_at_mangas_categories;
|
||||||
|
CREATE TRIGGER update_last_modified_at_mangas_categories
|
||||||
|
AFTER UPDATE ON mangas_categories
|
||||||
|
FOR EACH ROW
|
||||||
|
BEGIN
|
||||||
|
UPDATE mangas_categories
|
||||||
|
SET last_modified_at = strftime('%s', 'now')
|
||||||
|
WHERE _id = new._id;
|
||||||
|
END;
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS insert_last_modified_at_mangas_categories;
|
||||||
|
CREATE TRIGGER insert_last_modified_at_mangas_categories
|
||||||
|
AFTER INSERT ON mangas_categories
|
||||||
|
FOR EACH ROW
|
||||||
|
BEGIN
|
||||||
|
UPDATE mangas_categories
|
||||||
|
SET last_modified_at = strftime('%s', 'now')
|
||||||
|
WHERE _id = new._id;
|
||||||
|
END;
|
||||||
|
@ -13,6 +13,7 @@ data class Chapter(
|
|||||||
val dateUpload: Long,
|
val dateUpload: Long,
|
||||||
val chapterNumber: Float,
|
val chapterNumber: Float,
|
||||||
val scanlator: String?,
|
val scanlator: String?,
|
||||||
|
val lastModifiedAt: Long?,
|
||||||
) {
|
) {
|
||||||
val isRecognizedNumber: Boolean
|
val isRecognizedNumber: Boolean
|
||||||
get() = chapterNumber >= 0f
|
get() = chapterNumber >= 0f
|
||||||
@ -31,6 +32,7 @@ data class Chapter(
|
|||||||
dateUpload = -1,
|
dateUpload = -1,
|
||||||
chapterNumber = -1f,
|
chapterNumber = -1f,
|
||||||
scanlator = null,
|
scanlator = null,
|
||||||
|
lastModifiedAt = null,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -24,6 +24,7 @@ data class Manga(
|
|||||||
val thumbnailUrl: String?,
|
val thumbnailUrl: String?,
|
||||||
val updateStrategy: UpdateStrategy,
|
val updateStrategy: UpdateStrategy,
|
||||||
val initialized: Boolean,
|
val initialized: Boolean,
|
||||||
|
val lastModifiedAt: Long?,
|
||||||
) : Serializable {
|
) : Serializable {
|
||||||
|
|
||||||
val sorting: Long
|
val sorting: Long
|
||||||
@ -109,6 +110,7 @@ data class Manga(
|
|||||||
thumbnailUrl = null,
|
thumbnailUrl = null,
|
||||||
updateStrategy = UpdateStrategy.ALWAYS_UPDATE,
|
updateStrategy = UpdateStrategy.ALWAYS_UPDATE,
|
||||||
initialized = false,
|
initialized = false,
|
||||||
|
lastModifiedAt = 0L,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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_recent_manga">History</string>
|
||||||
<string name="label_sources">Sources</string>
|
<string name="label_sources">Sources</string>
|
||||||
<string name="label_backup">Backup and restore</string>
|
<string name="label_backup">Backup and restore</string>
|
||||||
|
<string name="label_sync">Sync</string>
|
||||||
<string name="label_stats">Statistics</string>
|
<string name="label_stats">Statistics</string>
|
||||||
<string name="label_migration">Migrate</string>
|
<string name="label_migration">Migrate</string>
|
||||||
<string name="label_extensions">Extensions</string>
|
<string name="label_extensions">Extensions</string>
|
||||||
@ -243,6 +244,9 @@
|
|||||||
<string name="pref_category_library_update">Global update</string>
|
<string name="pref_category_library_update">Global update</string>
|
||||||
<string name="pref_library_update_interval">Automatic updates</string>
|
<string name="pref_library_update_interval">Automatic updates</string>
|
||||||
<string name="update_never">Off</string>
|
<string name="update_never">Off</string>
|
||||||
|
<string name="update_30min">Every 30 minutes</string>
|
||||||
|
<string name="update_1hour">Every hour</string>
|
||||||
|
<string name="update_3hour">Every 3 hours</string>
|
||||||
<string name="update_6hour">Every 6 hours</string>
|
<string name="update_6hour">Every 6 hours</string>
|
||||||
<string name="update_12hour">Every 12 hours</string>
|
<string name="update_12hour">Every 12 hours</string>
|
||||||
<string name="update_24hour">Daily</string>
|
<string name="update_24hour">Daily</string>
|
||||||
@ -522,7 +526,35 @@
|
|||||||
<string name="restoring_backup_canceled">Canceled restore</string>
|
<string name="restoring_backup_canceled">Canceled restore</string>
|
||||||
<string name="backup_info">You should keep copies of backups in other places as well.</string>
|
<string name="backup_info">You should keep copies of backups in other places as well.</string>
|
||||||
|
|
||||||
<!-- 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="label_network">Network</string>
|
||||||
<string name="pref_clear_cookies">Clear cookies</string>
|
<string name="pref_clear_cookies">Clear cookies</string>
|
||||||
<string name="pref_dns_over_https">DNS over HTTPS (DoH)</string>
|
<string name="pref_dns_over_https">DNS over HTTPS (DoH)</string>
|
||||||
|
Loading…
Reference in New Issue
Block a user