mirror of
https://github.com/mihonapp/mihon.git
synced 2024-11-15 06:52:49 +01:00
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:
parent
89c577952c
commit
ffe6efdd7a
@ -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,
|
||||
),
|
||||
),
|
||||
)
|
||||
},
|
||||
|
@ -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))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
@ -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)
|
||||
|
@ -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.
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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,
|
||||
)
|
||||
|
@ -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>
|
||||
|
Loading…
Reference in New Issue
Block a user