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, onClickRefresh: () -> Unit,
onClickGlobalUpdate: () -> Unit, onClickGlobalUpdate: () -> Unit,
onClickOpenRandomManga: () -> Unit, onClickOpenRandomManga: () -> Unit,
onClickSyncNow: () -> Unit,
searchQuery: String?, searchQuery: String?,
onSearchQueryChange: (String?) -> Unit, onSearchQueryChange: (String?) -> Unit,
scrollBehavior: TopAppBarScrollBehavior?, scrollBehavior: TopAppBarScrollBehavior?,
@ -56,6 +57,7 @@ fun LibraryToolbar(
onClickRefresh = onClickRefresh, onClickRefresh = onClickRefresh,
onClickGlobalUpdate = onClickGlobalUpdate, onClickGlobalUpdate = onClickGlobalUpdate,
onClickOpenRandomManga = onClickOpenRandomManga, onClickOpenRandomManga = onClickOpenRandomManga,
onClickSyncNow = onClickSyncNow,
scrollBehavior = scrollBehavior, scrollBehavior = scrollBehavior,
) )
} }
@ -70,6 +72,7 @@ private fun LibraryRegularToolbar(
onClickRefresh: () -> Unit, onClickRefresh: () -> Unit,
onClickGlobalUpdate: () -> Unit, onClickGlobalUpdate: () -> Unit,
onClickOpenRandomManga: () -> Unit, onClickOpenRandomManga: () -> Unit,
onClickSyncNow: () -> Unit,
scrollBehavior: TopAppBarScrollBehavior?, scrollBehavior: TopAppBarScrollBehavior?,
) { ) {
val pillAlpha = if (isSystemInDarkTheme()) 0.12f else 0.08f val pillAlpha = if (isSystemInDarkTheme()) 0.12f else 0.08f
@ -115,6 +118,10 @@ private fun LibraryRegularToolbar(
title = stringResource(MR.strings.action_open_random_manga), title = stringResource(MR.strings.action_open_random_manga),
onClick = onClickOpenRandomManga, 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.CreateBackupScreen
import eu.kanade.presentation.more.settings.screen.data.RestoreBackupScreen 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.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.BasePreferenceWidget
import eu.kanade.presentation.more.settings.widget.PrefsHorizontalPadding import eu.kanade.presentation.more.settings.widget.PrefsHorizontalPadding
import eu.kanade.presentation.util.relativeTimeSpanString import eu.kanade.presentation.util.relativeTimeSpanString
@ -462,24 +463,7 @@ object SettingsDataScreen : SearchableSettings {
@Composable @Composable
private fun getSyncNowPref(): Preference.PreferenceGroup { private fun getSyncNowPref(): Preference.PreferenceGroup {
val scope = rememberCoroutineScope() val navigator = LocalNavigator.currentOrThrow
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 },
)
}
return Preference.PreferenceGroup( return Preference.PreferenceGroup(
title = stringResource(MR.strings.pref_sync_now_group_title), title = stringResource(MR.strings.pref_sync_now_group_title),
preferenceItems = persistentListOf( preferenceItems = persistentListOf(
@ -487,7 +471,7 @@ object SettingsDataScreen : SearchableSettings {
title = stringResource(MR.strings.pref_sync_now), title = stringResource(MR.strings.pref_sync_now),
subtitle = stringResource(MR.strings.pref_sync_now_subtitle), subtitle = stringResource(MR.strings.pref_sync_now_subtitle),
onClick = { 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, TimeUnit.MINUTES,
) )
.addTag(TAG_AUTO) .addTag(TAG_AUTO)
// No initial delay is needed, remove the randomDelayMillis
.build() .build()
context.workManager.enqueueUniquePeriodicWork(TAG_AUTO, ExistingPeriodicWorkPolicy.UPDATE, request) 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. * from the database using the BackupManager, then synchronizes the data with a sync service.
*/ */
suspend fun syncData() { suspend fun syncData() {
val syncOptions = syncPreferences.getSyncOptions()
val databaseManga = getAllMangaFromDB() val databaseManga = getAllMangaFromDB()
val backupOptions = BackupOptions( val backupOptions = BackupOptions(
libraryEntries = true, libraryEntries = syncOptions.libraryEntries,
categories = true, categories = syncOptions.categories,
chapters = true, chapters = syncOptions.chapters,
tracking = true, tracking = syncOptions.tracking,
history = true, history = syncOptions.history,
appSettings = true, appSettings = syncOptions.appSettings,
sourceSettings = true, sourceSettings = syncOptions.sourceSettings,
privateSettings = true, privateSettings = syncOptions.privateSettings,
) )
val backup = Backup( val backup = Backup(
backupManga = backupCreator.backupMangas(databaseManga, backupOptions), backupManga = backupCreator.backupMangas(databaseManga, backupOptions),
@ -119,10 +120,17 @@ class SyncManager(
val remoteBackup = syncService?.doSync(syncData) 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 // Check if it's first sync based on lastSyncTimestamp
if (syncPreferences.lastSyncTimestamp().get() == 0L && databaseManga.isNotEmpty()) { if (syncPreferences.lastSyncTimestamp().get() == 0L && databaseManga.isNotEmpty()) {
// It's first sync no need to restore data. (just update remote data) // It's first sync no need to restore data. (just update remote data)
syncPreferences.lastSyncTimestamp().set(Date().time) syncPreferences.lastSyncTimestamp().set(Date().time)
notifier.showSyncSuccess("Updated remote data successfully")
return return
} }
@ -130,10 +138,8 @@ class SyncManager(
val (filteredFavorites, nonFavorites) = filterFavoritesAndNonFavorites(remoteBackup) val (filteredFavorites, nonFavorites) = filterFavoritesAndNonFavorites(remoteBackup)
updateNonFavorites(nonFavorites) updateNonFavorites(nonFavorites)
val mangas = processFavoriteManga(filteredFavorites)
val newSyncData = backup.copy( val newSyncData = backup.copy(
backupManga = mangas, backupManga = filteredFavorites,
backupCategories = remoteBackup.backupCategories, backupCategories = remoteBackup.backupCategories,
backupSources = remoteBackup.backupSources, backupSources = remoteBackup.backupSources,
backupPreferences = remoteBackup.backupPreferences, backupPreferences = remoteBackup.backupPreferences,
@ -141,9 +147,10 @@ class SyncManager(
) )
// It's local sync no need to restore data. (just update remote data) // It's local sync no need to restore data. (just update remote data)
if (mangas.isEmpty()) { if (filteredFavorites.isEmpty()) {
// update the sync timestamp // update the sync timestamp
syncPreferences.lastSyncTimestamp().set(Date().time) syncPreferences.lastSyncTimestamp().set(Date().time)
notifier.showSyncSuccess("Sync completed successfully")
return return
} }
@ -328,6 +335,7 @@ class SyncManager(
val favorites = mutableListOf<BackupManga>() val favorites = mutableListOf<BackupManga>()
val nonFavorites = mutableListOf<BackupManga>() val nonFavorites = mutableListOf<BackupManga>()
val logTag = "filterFavoritesAndNonFavorites" val logTag = "filterFavoritesAndNonFavorites"
val elapsedTimeMillis = measureTimeMillis { val elapsedTimeMillis = measureTimeMillis {
val databaseMangaFavorites = getFavorites.await() val databaseMangaFavorites = getFavorites.await()
val localMangaMap = databaseMangaFavorites.associateBy { val localMangaMap = databaseMangaFavorites.associateBy {
@ -368,51 +376,6 @@ class SyncManager(
return Pair(favorites, nonFavorites) 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. * 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. * @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) 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.presentation.util.Tab
import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.R
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.ui.browse.source.globalsearch.GlobalSearchScreen import eu.kanade.tachiyomi.ui.browse.source.globalsearch.GlobalSearchScreen
import eu.kanade.tachiyomi.ui.category.CategoryScreen import eu.kanade.tachiyomi.ui.category.CategoryScreen
import eu.kanade.tachiyomi.ui.home.HomeScreen import eu.kanade.tachiyomi.ui.home.HomeScreen
import eu.kanade.tachiyomi.ui.main.MainActivity import eu.kanade.tachiyomi.ui.main.MainActivity
import eu.kanade.tachiyomi.ui.manga.MangaScreen import eu.kanade.tachiyomi.ui.manga.MangaScreen
import eu.kanade.tachiyomi.ui.reader.ReaderActivity import eu.kanade.tachiyomi.ui.reader.ReaderActivity
import eu.kanade.tachiyomi.util.system.toast
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.collectLatest 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, searchQuery = state.searchQuery,
onSearchQueryChange = screenModel::search, onSearchQueryChange = screenModel::search,
scrollBehavior = scrollBehavior.takeIf { !tabVisible }, // For scroll overlay when no tab scrollBehavior = scrollBehavior.takeIf { !tabVisible }, // For scroll overlay when no tab

View File

@ -36,4 +36,39 @@ class SyncPreferences(
return uniqueID 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_sync_interval">Synchronization frequency</string>
<string name="pref_reset_sync_timestamp">Reset last sync timestamp</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_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="success_reset_sync_timestamp">Last sync timestamp reset</string>
<string name="syncyomi">SyncYomi</string> <string name="syncyomi">SyncYomi</string>
<string name="sync_completed_message">Done in %1$s</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="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_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="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 --> <!-- Advanced section -->
<string name="label_network">Networking</string> <string name="label_network">Networking</string>