feat(sync): Allow to choose what to sync.

Various improvement and added the option to choose what they want to sync. Added sync library button to LibraryTab as well.

Signed-off-by: KaiserBh <kaiserbh@proton.me>
This commit is contained in:
KaiserBh 2024-01-14 05:56:09 +11:00
parent 89c577952c
commit ffe6efdd7a
No known key found for this signature in database
GPG Key ID: 14D73B142042BBA9
9 changed files with 229 additions and 100 deletions

View File

@ -37,6 +37,7 @@ fun LibraryToolbar(
onClickRefresh: () -> Unit,
onClickGlobalUpdate: () -> Unit,
onClickOpenRandomManga: () -> Unit,
onClickSyncNow: () -> Unit,
searchQuery: String?,
onSearchQueryChange: (String?) -> Unit,
scrollBehavior: TopAppBarScrollBehavior?,
@ -56,6 +57,7 @@ fun LibraryToolbar(
onClickRefresh = onClickRefresh,
onClickGlobalUpdate = onClickGlobalUpdate,
onClickOpenRandomManga = onClickOpenRandomManga,
onClickSyncNow = onClickSyncNow,
scrollBehavior = scrollBehavior,
)
}
@ -70,6 +72,7 @@ private fun LibraryRegularToolbar(
onClickRefresh: () -> Unit,
onClickGlobalUpdate: () -> Unit,
onClickOpenRandomManga: () -> Unit,
onClickSyncNow: () -> Unit,
scrollBehavior: TopAppBarScrollBehavior?,
) {
val pillAlpha = if (isSystemInDarkTheme()) 0.12f else 0.08f
@ -115,6 +118,10 @@ private fun LibraryRegularToolbar(
title = stringResource(MR.strings.action_open_random_manga),
onClick = onClickOpenRandomManga,
),
AppBar.OverflowAction(
title = stringResource(MR.strings.sync_library),
onClick = onClickSyncNow,
),
),
)
},

View File

@ -39,6 +39,7 @@ import eu.kanade.presentation.more.settings.Preference
import eu.kanade.presentation.more.settings.screen.data.CreateBackupScreen
import eu.kanade.presentation.more.settings.screen.data.RestoreBackupScreen
import eu.kanade.presentation.more.settings.screen.data.StorageInfo
import eu.kanade.presentation.more.settings.screen.data.SyncSettingsSelector
import eu.kanade.presentation.more.settings.widget.BasePreferenceWidget
import eu.kanade.presentation.more.settings.widget.PrefsHorizontalPadding
import eu.kanade.presentation.util.relativeTimeSpanString
@ -462,24 +463,7 @@ object SettingsDataScreen : SearchableSettings {
@Composable
private fun getSyncNowPref(): Preference.PreferenceGroup {
val scope = rememberCoroutineScope()
var showDialog by remember { mutableStateOf(false) }
val context = LocalContext.current
if (showDialog) {
SyncConfirmationDialog(
onConfirm = {
showDialog = false
scope.launch {
if (!SyncDataJob.isAnyJobRunning(context)) {
SyncDataJob.startNow(context)
} else {
context.toast(MR.strings.sync_in_progress)
}
}
},
onDismissRequest = { showDialog = false },
)
}
val navigator = LocalNavigator.currentOrThrow
return Preference.PreferenceGroup(
title = stringResource(MR.strings.pref_sync_now_group_title),
preferenceItems = persistentListOf(
@ -487,7 +471,7 @@ object SettingsDataScreen : SearchableSettings {
title = stringResource(MR.strings.pref_sync_now),
subtitle = stringResource(MR.strings.pref_sync_now_subtitle),
onClick = {
showDialog = true
navigator.push(SyncSettingsSelector())
},
),
),
@ -528,26 +512,4 @@ object SettingsDataScreen : SearchableSettings {
),
)
}
@Composable
private fun SyncConfirmationDialog(
onConfirm: () -> Unit,
onDismissRequest: () -> Unit,
) {
AlertDialog(
onDismissRequest = onDismissRequest,
title = { Text(text = stringResource(MR.strings.pref_sync_confirmation_title)) },
text = { Text(text = stringResource(MR.strings.pref_sync_confirmation_message)) },
dismissButton = {
TextButton(onClick = onDismissRequest) {
Text(text = stringResource(MR.strings.action_cancel))
}
},
confirmButton = {
TextButton(onClick = onConfirm) {
Text(text = stringResource(MR.strings.action_ok))
}
},
)
}
}

View File

@ -0,0 +1,142 @@
package eu.kanade.presentation.more.settings.screen.data
import android.content.Context
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.platform.LocalContext
import cafe.adriel.voyager.core.model.StateScreenModel
import cafe.adriel.voyager.core.model.rememberScreenModel
import cafe.adriel.voyager.navigator.LocalNavigator
import cafe.adriel.voyager.navigator.currentOrThrow
import eu.kanade.presentation.components.AppBar
import eu.kanade.presentation.util.Screen
import eu.kanade.tachiyomi.data.backup.create.BackupOptions
import eu.kanade.tachiyomi.data.sync.SyncDataJob
import eu.kanade.tachiyomi.util.system.toast
import kotlinx.collections.immutable.ImmutableList
import kotlinx.coroutines.flow.update
import tachiyomi.domain.sync.SyncOptions
import tachiyomi.domain.sync.SyncPreferences
import tachiyomi.i18n.MR
import tachiyomi.presentation.core.components.LabeledCheckbox
import tachiyomi.presentation.core.components.LazyColumnWithAction
import tachiyomi.presentation.core.components.SectionCard
import tachiyomi.presentation.core.components.material.Scaffold
import tachiyomi.presentation.core.i18n.stringResource
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
class SyncSettingsSelector : Screen() {
@Composable
override fun Content() {
val context = LocalContext.current
val navigator = LocalNavigator.currentOrThrow
val model = rememberScreenModel { SyncSettingsSelectorModel() }
val state by model.state.collectAsState()
Scaffold(
topBar = {
AppBar(
title = stringResource(MR.strings.pref_choose_what_to_sync),
navigateUp = navigator::pop,
scrollBehavior = it,
)
},
) { contentPadding ->
LazyColumnWithAction(
contentPadding = contentPadding,
actionLabel = stringResource(MR.strings.label_sync),
actionEnabled = state.options.anyEnabled(),
onClickAction = {
if (!SyncDataJob.isAnyJobRunning(context)) {
model.syncNow(context)
navigator.pop()
} else {
context.toast(MR.strings.sync_in_progress)
}
},
) {
item {
SectionCard(MR.strings.label_library) {
Options(BackupOptions.libraryOptions, state, model)
}
}
item {
SectionCard(MR.strings.label_settings) {
Options(BackupOptions.settingsOptions, state, model)
}
}
}
}
}
@Composable
private fun Options(
options: ImmutableList<BackupOptions.Entry>,
state: SyncSettingsSelectorModel.State,
model: SyncSettingsSelectorModel,
) {
options.forEach { option ->
LabeledCheckbox(
label = stringResource(option.label),
checked = option.getter(state.options),
onCheckedChange = {
model.toggle(option.setter, it)
},
enabled = option.enabled(state.options),
)
}
}
}
private class SyncSettingsSelectorModel(
val syncPreferences: SyncPreferences = Injekt.get(),
) : StateScreenModel<SyncSettingsSelectorModel.State>(
State(syncOptionsToBackupOptions(syncPreferences.getSyncOptions())),
) {
fun toggle(setter: (BackupOptions, Boolean) -> BackupOptions, enabled: Boolean) {
mutableState.update {
val updatedOptions = setter(it.options, enabled)
syncPreferences.setSyncOptions(backupOptionsToSyncOptions(updatedOptions))
it.copy(options = updatedOptions)
}
}
fun syncNow(context: Context) {
SyncDataJob.startNow(context)
}
@Immutable
data class State(
val options: BackupOptions = BackupOptions(),
) companion object {
private fun syncOptionsToBackupOptions(syncOptions: SyncOptions): BackupOptions {
return BackupOptions(
libraryEntries = syncOptions.libraryEntries,
categories = syncOptions.categories,
chapters = syncOptions.chapters,
tracking = syncOptions.tracking,
history = syncOptions.history,
appSettings = syncOptions.appSettings,
sourceSettings = syncOptions.sourceSettings,
privateSettings = syncOptions.privateSettings,
)
}
private fun backupOptionsToSyncOptions(backupOptions: BackupOptions): SyncOptions {
return SyncOptions(
libraryEntries = backupOptions.libraryEntries,
categories = backupOptions.categories,
chapters = backupOptions.chapters,
tracking = backupOptions.tracking,
history = backupOptions.history,
appSettings = backupOptions.appSettings,
sourceSettings = backupOptions.sourceSettings,
privateSettings = backupOptions.privateSettings,
)
}
}
}

View File

@ -80,7 +80,6 @@ class SyncDataJob(private val context: Context, workerParams: WorkerParameters)
TimeUnit.MINUTES,
)
.addTag(TAG_AUTO)
// No initial delay is needed, remove the randomDelayMillis
.build()
context.workManager.enqueueUniquePeriodicWork(TAG_AUTO, ExistingPeriodicWorkPolicy.UPDATE, request)

View File

@ -72,16 +72,17 @@ class SyncManager(
* from the database using the BackupManager, then synchronizes the data with a sync service.
*/
suspend fun syncData() {
val syncOptions = syncPreferences.getSyncOptions()
val databaseManga = getAllMangaFromDB()
val backupOptions = BackupOptions(
libraryEntries = true,
categories = true,
chapters = true,
tracking = true,
history = true,
appSettings = true,
sourceSettings = true,
privateSettings = true,
libraryEntries = syncOptions.libraryEntries,
categories = syncOptions.categories,
chapters = syncOptions.chapters,
tracking = syncOptions.tracking,
history = syncOptions.history,
appSettings = syncOptions.appSettings,
sourceSettings = syncOptions.sourceSettings,
privateSettings = syncOptions.privateSettings,
)
val backup = Backup(
backupManga = backupCreator.backupMangas(databaseManga, backupOptions),
@ -119,10 +120,17 @@ class SyncManager(
val remoteBackup = syncService?.doSync(syncData)
// Stop the sync early if the remote backup is null or empty
if (remoteBackup?.backupManga?.size == 0) {
notifier.showSyncError("No data found on remote server.")
return
}
// Check if it's first sync based on lastSyncTimestamp
if (syncPreferences.lastSyncTimestamp().get() == 0L && databaseManga.isNotEmpty()) {
// It's first sync no need to restore data. (just update remote data)
syncPreferences.lastSyncTimestamp().set(Date().time)
notifier.showSyncSuccess("Updated remote data successfully")
return
}
@ -130,10 +138,8 @@ class SyncManager(
val (filteredFavorites, nonFavorites) = filterFavoritesAndNonFavorites(remoteBackup)
updateNonFavorites(nonFavorites)
val mangas = processFavoriteManga(filteredFavorites)
val newSyncData = backup.copy(
backupManga = mangas,
backupManga = filteredFavorites,
backupCategories = remoteBackup.backupCategories,
backupSources = remoteBackup.backupSources,
backupPreferences = remoteBackup.backupPreferences,
@ -141,9 +147,10 @@ class SyncManager(
)
// It's local sync no need to restore data. (just update remote data)
if (mangas.isEmpty()) {
if (filteredFavorites.isEmpty()) {
// update the sync timestamp
syncPreferences.lastSyncTimestamp().set(Date().time)
notifier.showSyncSuccess("Sync completed successfully")
return
}
@ -328,6 +335,7 @@ class SyncManager(
val favorites = mutableListOf<BackupManga>()
val nonFavorites = mutableListOf<BackupManga>()
val logTag = "filterFavoritesAndNonFavorites"
val elapsedTimeMillis = measureTimeMillis {
val databaseMangaFavorites = getFavorites.await()
val localMangaMap = databaseMangaFavorites.associateBy {
@ -368,51 +376,6 @@ class SyncManager(
return Pair(favorites, nonFavorites)
}
private fun processFavoriteManga(backupManga: List<BackupManga>): List<BackupManga> {
val mangas = mutableListOf<BackupManga>()
val lastSyncTimeStamp = syncPreferences.lastSyncTimestamp().get()
val elapsedTimeMillis = measureTimeMillis {
logcat(LogPriority.DEBUG) { "Starting to process BackupMangas." }
backupManga.forEach { manga ->
val mangaLastUpdatedStatus = manga.lastModifiedAt * 1000L > lastSyncTimeStamp
val chaptersUpdatedStatus = chaptersUpdatedAfterSync(manga, lastSyncTimeStamp)
if (mangaLastUpdatedStatus || chaptersUpdatedStatus) {
mangas.add(manga)
logcat(LogPriority.DEBUG) {
"Added ${manga.title} to the process list. Manga Last Updated: $mangaLastUpdatedStatus, " +
"Chapters Updated: $chaptersUpdatedStatus."
}
} else {
logcat(LogPriority.DEBUG) {
"Skipped ${manga.title} as it has not been updated since the last sync " +
"(Last Modified: ${manga.lastModifiedAt * 1000L}, Last Sync: $lastSyncTimeStamp)."
}
}
}
}
val minutes = elapsedTimeMillis / 60000
val seconds = (elapsedTimeMillis % 60000) / 1000
logcat(LogPriority.DEBUG) { "Processing completed in ${minutes}m ${seconds}s. Total Processed: ${mangas.size}" }
return mangas
}
private fun chaptersUpdatedAfterSync(manga: BackupManga, lastSyncTimeStamp: Long): Boolean {
return manga.chapters.any { chapter ->
val updated = chapter.lastModifiedAt * 1000L > lastSyncTimeStamp
if (updated) {
logcat(LogPriority.DEBUG) {
"Chapter ${chapter.name} of ${manga.title} updated after last sync " +
"(Chapter Last Modified: ${chapter.lastModifiedAt * 1000L}, Last Sync: $lastSyncTimeStamp)."
}
}
updated
}
}
/**
* 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.

View File

@ -72,4 +72,15 @@ class SyncNotifier(private val context: Context) {
show(Notifications.ID_RESTORE_COMPLETE)
}
}
fun showSyncSuccess(message: String?) {
context.cancelNotification(Notifications.ID_RESTORE_PROGRESS)
with(completeNotificationBuilder) {
setContentTitle(context.getString(R.string.sync_complete))
setContentText(message)
show(Notifications.ID_RESTORE_COMPLETE)
}
}
}

View File

@ -37,12 +37,14 @@ import eu.kanade.presentation.more.onboarding.GETTING_STARTED_URL
import eu.kanade.presentation.util.Tab
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.library.LibraryUpdateJob
import eu.kanade.tachiyomi.data.sync.SyncDataJob
import eu.kanade.tachiyomi.ui.browse.source.globalsearch.GlobalSearchScreen
import eu.kanade.tachiyomi.ui.category.CategoryScreen
import eu.kanade.tachiyomi.ui.home.HomeScreen
import eu.kanade.tachiyomi.ui.main.MainActivity
import eu.kanade.tachiyomi.ui.manga.MangaScreen
import eu.kanade.tachiyomi.ui.reader.ReaderActivity
import eu.kanade.tachiyomi.util.system.toast
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.collectLatest
@ -135,6 +137,13 @@ object LibraryTab : Tab {
}
}
},
onClickSyncNow = {
if (!SyncDataJob.isAnyJobRunning(context)) {
SyncDataJob.startNow(context)
} else {
context.toast(MR.strings.sync_in_progress)
}
},
searchQuery = state.searchQuery,
onSearchQueryChange = screenModel::search,
scrollBehavior = scrollBehavior.takeIf { !tabVisible }, // For scroll overlay when no tab

View File

@ -36,4 +36,39 @@ class SyncPreferences(
return uniqueID
}
fun getSyncOptions(): SyncOptions {
return SyncOptions(
libraryEntries = preferenceStore.getBoolean("library_entries", true).get(),
categories = preferenceStore.getBoolean("categories", true).get(),
chapters = preferenceStore.getBoolean("chapters", true).get(),
tracking = preferenceStore.getBoolean("tracking", true).get(),
history = preferenceStore.getBoolean("history", true).get(),
appSettings = preferenceStore.getBoolean("appSettings", true).get(),
sourceSettings = preferenceStore.getBoolean("sourceSettings", true).get(),
privateSettings = preferenceStore.getBoolean("privateSettings", true).get(),
)
}
fun setSyncOptions(syncOptions: SyncOptions) {
preferenceStore.getBoolean("library_entries", true).set(syncOptions.libraryEntries)
preferenceStore.getBoolean("categories", true).set(syncOptions.categories)
preferenceStore.getBoolean("chapters", true).set(syncOptions.chapters)
preferenceStore.getBoolean("tracking", true).set(syncOptions.tracking)
preferenceStore.getBoolean("history", true).set(syncOptions.history)
preferenceStore.getBoolean("appSettings", true).set(syncOptions.appSettings)
preferenceStore.getBoolean("sourceSettings", true).set(syncOptions.sourceSettings)
preferenceStore.getBoolean("privateSettings", true).set(syncOptions.privateSettings)
}
}
data class SyncOptions(
val libraryEntries: Boolean = true,
val categories: Boolean = true,
val chapters: Boolean = true,
val tracking: Boolean = true,
val history: Boolean = true,
val appSettings: Boolean = true,
val sourceSettings: Boolean = true,
val privateSettings: Boolean = false,
)

View File

@ -563,6 +563,7 @@
<string name="pref_sync_interval">Synchronization frequency</string>
<string name="pref_reset_sync_timestamp">Reset last sync timestamp</string>
<string name="pref_reset_sync_timestamp_subtitle">Reset the last sync timestamp to force a full sync</string>
<string name="pref_choose_what_to_sync">Choose what to sync</string>
<string name="success_reset_sync_timestamp">Last sync timestamp reset</string>
<string name="syncyomi">SyncYomi</string>
<string name="sync_completed_message">Done in %1$s</string>
@ -582,7 +583,7 @@
<string name="error_deleting_google_drive_lock_file">Error Deleting Google Drive Lock File</string>
<string name="pref_purge_confirmation_title">Purge confirmation</string>
<string name="pref_purge_confirmation_message">Purging sync data will delete all your sync data from Google Drive. Are you sure you want to continue?</string>
<string name="sync_library">Sync library</string>
<!-- Advanced section -->
<string name="label_network">Networking</string>