diff --git a/app/src/main/java/eu/kanade/presentation/library/components/LibraryToolbar.kt b/app/src/main/java/eu/kanade/presentation/library/components/LibraryToolbar.kt index 9845ca816..6853ad595 100644 --- a/app/src/main/java/eu/kanade/presentation/library/components/LibraryToolbar.kt +++ b/app/src/main/java/eu/kanade/presentation/library/components/LibraryToolbar.kt @@ -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, + ), ), ) }, diff --git a/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsDataScreen.kt b/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsDataScreen.kt index c0ed0d40c..cc2b51ed5 100644 --- a/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsDataScreen.kt +++ b/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsDataScreen.kt @@ -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)) - } - }, - ) - } } diff --git a/app/src/main/java/eu/kanade/presentation/more/settings/screen/data/SyncSettingsSelector.kt b/app/src/main/java/eu/kanade/presentation/more/settings/screen/data/SyncSettingsSelector.kt new file mode 100644 index 000000000..a13aa5c28 --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/more/settings/screen/data/SyncSettingsSelector.kt @@ -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, + 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( + 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, + ) + } + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/sync/SyncDataJob.kt b/app/src/main/java/eu/kanade/tachiyomi/data/sync/SyncDataJob.kt index 3ea2551a0..ba5b9b952 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/sync/SyncDataJob.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/sync/SyncDataJob.kt @@ -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) diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/sync/SyncManager.kt b/app/src/main/java/eu/kanade/tachiyomi/data/sync/SyncManager.kt index 420141030..dcf0d2809 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/sync/SyncManager.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/sync/SyncManager.kt @@ -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() val nonFavorites = mutableListOf() 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): List { - val mangas = mutableListOf() - 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. diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/sync/SyncNotifier.kt b/app/src/main/java/eu/kanade/tachiyomi/data/sync/SyncNotifier.kt index 39596d7b4..2321240a6 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/sync/SyncNotifier.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/sync/SyncNotifier.kt @@ -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) + } + } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryTab.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryTab.kt index e54dd97d6..daffd610e 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryTab.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryTab.kt @@ -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 diff --git a/domain/src/main/java/tachiyomi/domain/sync/SyncPreferences.kt b/domain/src/main/java/tachiyomi/domain/sync/SyncPreferences.kt index 72b1feaee..83d791075 100644 --- a/domain/src/main/java/tachiyomi/domain/sync/SyncPreferences.kt +++ b/domain/src/main/java/tachiyomi/domain/sync/SyncPreferences.kt @@ -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, +) diff --git a/i18n/src/commonMain/resources/MR/base/strings.xml b/i18n/src/commonMain/resources/MR/base/strings.xml index 1b6df365e..786c4892c 100644 --- a/i18n/src/commonMain/resources/MR/base/strings.xml +++ b/i18n/src/commonMain/resources/MR/base/strings.xml @@ -563,6 +563,7 @@ Synchronization frequency Reset last sync timestamp Reset the last sync timestamp to force a full sync + Choose what to sync Last sync timestamp reset SyncYomi Done in %1$s @@ -582,7 +583,7 @@ Error Deleting Google Drive Lock File Purge confirmation Purging sync data will delete all your sync data from Google Drive. Are you sure you want to continue? - + Sync library Networking