From 334e9fb680aa51094842d9a2af4db04e82d70ef1 Mon Sep 17 00:00:00 2001 From: KaiserBh <41852205+kaiserbh@users.noreply.github.com> Date: Sun, 17 Mar 2024 02:53:20 +1100 Subject: [PATCH] feat: add cross device sync (#1005) * feat: add cross device sync. * chore: add google api. * chore: add SY specifics. * feat: add backupSource, backupPref, and "SY" backupSavedSearches. I forgot to add the data into the merging logic, So remote always have empty value :(. Better late than never. * 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 * oops. Signed-off-by: KaiserBh * refactor: fix up the sync triggers, and update imports. * refactor * chore: review pointers. * refactor: update imports * refactor: add more error guard for gdrive. Also changed it to be app specific, we don't want them to use sync data from SY or other forks as some of the model and backup is different. So if people using other forks they should use the same data and not mismatch. * fix: conflict and refactor. * refactor: update imports. * chore: fix some of detekt error. * refactor: add breaks and max retries. I think we were reaching deadlock or infinite loop causing the sync to go forever. * feat: db changes to accommodate new syncing logic. Using timestamp to sync is a bit skewed due to system clock etc and therefore there was a lot of issues with it such as removing a manga that shouldn't have been removed. Marking chapters as unread even though it was marked as a read. Hopefully by using versioning system it should eliminate those issues. * chore: add migrations * chore: version and is_syncing fields. * chore: add SY only stuff. * fix: oops wrong index. Signed-off-by: KaiserBh * chore: review pointers. Signed-off-by: KaiserBh * chore: remove the option to reset timestamp We don't need this anymore since we are utilizing versioning system. Signed-off-by: KaiserBh * refactor: Forgot to use the new versioning system. I forgot to cherry pick this from mihon branch. * chore: remove isSyncing from Chapter/Manga model. Signed-off-by: KaiserBh * chore: remove unused import. Signed-off-by: KaiserBh * chore: remove isSyncing leftover. Signed-off-by: KaiserBh * chore: remove isSyncing. Signed-off-by: KaiserBh * refactor: make sure the manga version is bumped. When there is changes in the chapters table such as reading or updating last read page we should bump the manga version. This way the manga is synced properly as in the History and last_read history is synced properly. This should fix the sorting issue. Signed-off-by: KaiserBh --------- Signed-off-by: KaiserBh --- app/build.gradle.kts | 3 + app/proguard-rules.pro | 23 +- app/src/main/AndroidManifest.xml | 14 + app/src/main/assets/client_secrets.json | 1 + .../eu/kanade/domain/chapter/model/Chapter.kt | 1 + .../eu/kanade/domain/sync/SyncPreferences.kt | 92 +++ .../kanade/domain/sync/models/SyncSettings.kt | 12 + .../library/components/LibraryToolbar.kt | 8 +- .../settings/screen/SettingsAdvancedScreen.kt | 1 - .../settings/screen/SettingsDataScreen.kt | 237 +++++++- .../screen/data/SyncSettingsSelector.kt | 142 +++++ .../screen/data/SyncTriggerOptionsScreen.kt | 101 ++++ app/src/main/java/eu/kanade/tachiyomi/App.kt | 22 + .../data/backup/create/BackupCreator.kt | 12 +- .../data/backup/restore/BackupRestorer.kt | 2 +- .../backup/restore/restorers/MangaRestorer.kt | 66 ++- .../data/library/LibraryUpdateJob.kt | 42 ++ .../data/notification/NotificationReceiver.kt | 34 ++ .../kanade/tachiyomi/data/sync/SyncDataJob.kt | 102 ++++ .../kanade/tachiyomi/data/sync/SyncManager.kt | 327 +++++++++++ .../tachiyomi/data/sync/SyncNotifier.kt | 86 +++ .../data/sync/models/SyncTriggerOptions.kt | 72 +++ .../sync/service/GoogleDriveSyncService.kt | 522 ++++++++++++++++++ .../data/sync/service/SyncService.kt | 511 +++++++++++++++++ .../data/sync/service/SyncYomiSyncService.kt | 198 +++++++ .../java/eu/kanade/tachiyomi/di/AppModule.kt | 3 + .../kanade/tachiyomi/di/PreferenceModule.kt | 5 + .../kanade/tachiyomi/ui/library/LibraryTab.kt | 9 + .../tachiyomi/ui/reader/ReaderViewModel.kt | 15 + .../setting/track/GoogleDriveLoginActivity.kt | 54 ++ .../eu/kanade/tachiyomi/network/Requests.kt | 13 + .../common/util/system/LogcatExtensions.kt | 20 +- gradle/libs.versions.toml | 3 + .../commonMain/resources/MR/base/strings.xml | 55 +- .../resources/MR/nb-rNO/plurals.xml | 4 + .../commonMain/resources/MR/th/plurals.xml | 3 + .../commonMain/resources/MR/tr/plurals.xml | 4 + 37 files changed, 2772 insertions(+), 47 deletions(-) create mode 100644 app/src/main/assets/client_secrets.json create mode 100644 app/src/main/java/eu/kanade/domain/sync/SyncPreferences.kt create mode 100644 app/src/main/java/eu/kanade/domain/sync/models/SyncSettings.kt create mode 100644 app/src/main/java/eu/kanade/presentation/more/settings/screen/data/SyncSettingsSelector.kt create mode 100644 app/src/main/java/eu/kanade/presentation/more/settings/screen/data/SyncTriggerOptionsScreen.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/data/sync/SyncDataJob.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/data/sync/SyncManager.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/data/sync/SyncNotifier.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/data/sync/models/SyncTriggerOptions.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/data/sync/service/GoogleDriveSyncService.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/data/sync/service/SyncService.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/data/sync/service/SyncYomiSyncService.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/setting/track/GoogleDriveLoginActivity.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 200855fea..b0742a3fc 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -248,6 +248,9 @@ dependencies { implementation(libs.compose.materialmotion) implementation(libs.swipe) + implementation(libs.google.api.services.drive) + implementation(libs.google.api.client.oauth) + // Logging implementation(libs.logcat) diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index 072585f59..f4445d017 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -126,6 +126,12 @@ -keep class com.google.firebase.installations.** { *; } -keep interface com.google.firebase.installations.** { *; } +# Google Drive +-keep class com.google.api.services.** { *; } + +# Google OAuth +-keep class com.google.api.client.** { *; } + # SY --> # SqlCipher -keepclassmembers class net.zetetic.database.sqlcipher.SQLiteCustomFunction { *; } @@ -260,6 +266,9 @@ -keep,allowoptimization class * extends uy.kohesive.injekt.api.TypeReference -keep,allowoptimization public class io.requery.android.database.sqlite.SQLiteConnection { *; } + # Keep apache http client + -keep class org.apache.http.** { *; } + # Suggested rules -dontwarn com.oracle.svm.core.annotate.AutomaticFeature -dontwarn com.oracle.svm.core.annotate.Delete @@ -272,4 +281,16 @@ -dontwarn org.slf4j.impl.StaticLoggerBinder -dontwarn java.lang.Module -dontwarn org.graalvm.nativeimage.hosted.RuntimeResourceAccess --dontwarn org.jspecify.annotations.NullMarked \ No newline at end of file +-dontwarn org.jspecify.annotations.NullMarked +-dontwarn javax.naming.InvalidNameException +-dontwarn javax.naming.NamingException +-dontwarn javax.naming.directory.Attribute +-dontwarn javax.naming.directory.Attributes +-dontwarn javax.naming.ldap.LdapName +-dontwarn javax.naming.ldap.Rdn +-dontwarn org.ietf.jgss.GSSContext +-dontwarn org.ietf.jgss.GSSCredential +-dontwarn org.ietf.jgss.GSSException +-dontwarn org.ietf.jgss.GSSManager +-dontwarn org.ietf.jgss.GSSName +-dontwarn org.ietf.jgss.Oid \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index c6a13b2b0..fbc658c12 100755 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -188,6 +188,20 @@ + + + + + + + + + + Unit, onClickGlobalUpdate: () -> Unit, onClickOpenRandomManga: () -> Unit, + onClickSyncNow: () -> Unit, // SY --> onClickSyncExh: (() -> Unit)?, // SY <-- @@ -60,6 +61,7 @@ fun LibraryToolbar( onClickRefresh = onClickRefresh, onClickGlobalUpdate = onClickGlobalUpdate, onClickOpenRandomManga = onClickOpenRandomManga, + onClickSyncNow = onClickSyncNow, // SY --> onClickSyncExh = onClickSyncExh, // SY <-- @@ -77,6 +79,7 @@ private fun LibraryRegularToolbar( onClickRefresh: () -> Unit, onClickGlobalUpdate: () -> Unit, onClickOpenRandomManga: () -> Unit, + onClickSyncNow: () -> Unit, // SY --> onClickSyncExh: (() -> Unit)?, // SY <-- @@ -125,7 +128,10 @@ private fun LibraryRegularToolbar( title = stringResource(MR.strings.action_open_random_manga), onClick = onClickOpenRandomManga, ), - + AppBar.OverflowAction( + title = stringResource(MR.strings.sync_library), + onClick = onClickSyncNow, + ), ).builder().apply { // SY --> if (onClickSyncExh != null) { diff --git a/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsAdvancedScreen.kt b/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsAdvancedScreen.kt index b6b0e9fc2..e17f06a55 100644 --- a/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsAdvancedScreen.kt +++ b/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsAdvancedScreen.kt @@ -60,7 +60,6 @@ import eu.kanade.tachiyomi.util.CrashLogUtil import eu.kanade.tachiyomi.util.storage.DiskUtil import eu.kanade.tachiyomi.util.system.isDevFlavor import eu.kanade.tachiyomi.util.system.isPreviewBuildType -import eu.kanade.tachiyomi.util.system.isReleaseBuildType import eu.kanade.tachiyomi.util.system.isShizukuInstalled import eu.kanade.tachiyomi.util.system.powerManager import eu.kanade.tachiyomi.util.system.setDefaultSettings 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 a6f9955a0..9e51db038 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 @@ -15,16 +15,19 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.outlined.HelpOutline +import androidx.compose.material3.AlertDialog import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MultiChoiceSegmentedButtonRow import androidx.compose.material3.SegmentedButton import androidx.compose.material3.SegmentedButtonDefaults 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.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue @@ -35,10 +38,13 @@ import androidx.core.net.toUri import cafe.adriel.voyager.navigator.LocalNavigator import cafe.adriel.voyager.navigator.currentOrThrow import com.hippo.unifile.UniFile +import eu.kanade.domain.sync.SyncPreferences 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.screen.data.SyncTriggerOptionsScreen import eu.kanade.presentation.more.settings.widget.BasePreferenceWidget import eu.kanade.presentation.more.settings.widget.PrefsHorizontalPadding import eu.kanade.presentation.util.relativeTimeSpanString @@ -46,10 +52,15 @@ import eu.kanade.tachiyomi.data.backup.create.BackupCreateJob import eu.kanade.tachiyomi.data.backup.restore.BackupRestoreJob import eu.kanade.tachiyomi.data.cache.ChapterCache import eu.kanade.tachiyomi.data.cache.PagePreviewCache +import eu.kanade.tachiyomi.data.sync.SyncDataJob +import eu.kanade.tachiyomi.data.sync.SyncManager +import eu.kanade.tachiyomi.data.sync.service.GoogleDriveService +import eu.kanade.tachiyomi.data.sync.service.GoogleDriveSyncService import eu.kanade.tachiyomi.util.system.DeviceUtil import eu.kanade.tachiyomi.util.system.toast import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentMapOf +import kotlinx.coroutines.launch import logcat.LogPriority import tachiyomi.core.common.i18n.stringResource import tachiyomi.core.common.storage.displayablePath @@ -91,13 +102,16 @@ object SettingsDataScreen : SearchableSettings { val backupPreferences = Injekt.get() val storagePreferences = Injekt.get() + val syncPreferences = remember { Injekt.get() } + val syncService by syncPreferences.syncService().collectAsState() + return persistentListOf( getStorageLocationPref(storagePreferences = storagePreferences), Preference.PreferenceItem.InfoPreference(stringResource(MR.strings.pref_storage_location_info)), getBackupAndRestoreGroup(backupPreferences = backupPreferences), getDataGroup(), - ) + ) + getSyncPreferences(syncPreferences = syncPreferences, syncService = syncService) } @Composable @@ -330,4 +344,225 @@ object SettingsDataScreen : SearchableSettings { ), ) } + + @Composable + private fun getSyncPreferences(syncPreferences: SyncPreferences, syncService: Int): List { + return listOf( + Preference.PreferenceGroup( + title = stringResource(MR.strings.pref_sync_service_category), + preferenceItems = persistentListOf( + Preference.PreferenceItem.ListPreference( + pref = syncPreferences.syncService(), + title = stringResource(MR.strings.pref_sync_service), + entries = persistentMapOf( + SyncManager.SyncService.NONE.value to stringResource(MR.strings.off), + SyncManager.SyncService.SYNCYOMI.value to stringResource(MR.strings.syncyomi), + SyncManager.SyncService.GOOGLE_DRIVE.value to stringResource(MR.strings.google_drive), + ), + onValueChanged = { true }, + ), + ), + ), + ) + getSyncServicePreferences(syncPreferences, syncService) + } + + @Composable + private fun getSyncServicePreferences(syncPreferences: SyncPreferences, syncService: Int): List { + val syncServiceType = SyncManager.SyncService.fromInt(syncService) + + val basePreferences = getBasePreferences(syncServiceType, syncPreferences) + + return if (syncServiceType != SyncManager.SyncService.NONE) { + basePreferences + getAdditionalPreferences(syncPreferences) + } else { + basePreferences + } + } + + @Composable + private fun getBasePreferences( + syncServiceType: SyncManager.SyncService, + syncPreferences: SyncPreferences, + ): List { + return when (syncServiceType) { + SyncManager.SyncService.NONE -> emptyList() + SyncManager.SyncService.SYNCYOMI -> getSelfHostPreferences(syncPreferences) + SyncManager.SyncService.GOOGLE_DRIVE -> getGoogleDrivePreferences() + } + } + + @Composable + private fun getAdditionalPreferences(syncPreferences: SyncPreferences): List { + return listOf(getSyncNowPref(), getAutomaticSyncGroup(syncPreferences)) + } + + @Composable + private fun getGoogleDrivePreferences(): List { + val context = LocalContext.current + val googleDriveSync = Injekt.get() + return listOf( + Preference.PreferenceItem.TextPreference( + title = stringResource(MR.strings.pref_google_drive_sign_in), + onClick = { + val intent = googleDriveSync.getSignInIntent() + context.startActivity(intent) + }, + ), + getGoogleDrivePurge(), + ) + } + + @Composable + private fun getGoogleDrivePurge(): Preference.PreferenceItem.TextPreference { + val scope = rememberCoroutineScope() + val context = LocalContext.current + val googleDriveSync = remember { GoogleDriveSyncService(context) } + var showPurgeDialog by remember { mutableStateOf(false) } + + if (showPurgeDialog) { + PurgeConfirmationDialog( + onConfirm = { + showPurgeDialog = false + scope.launch { + val result = googleDriveSync.deleteSyncDataFromGoogleDrive() + when (result) { + GoogleDriveSyncService.DeleteSyncDataStatus.NOT_INITIALIZED -> context.toast( + MR.strings.google_drive_not_signed_in, + duration = 5000, + ) + GoogleDriveSyncService.DeleteSyncDataStatus.NO_FILES -> context.toast( + MR.strings.google_drive_sync_data_not_found, + duration = 5000, + ) + GoogleDriveSyncService.DeleteSyncDataStatus.SUCCESS -> context.toast( + MR.strings.google_drive_sync_data_purged, + duration = 5000, + ) + GoogleDriveSyncService.DeleteSyncDataStatus.ERROR -> context.toast( + MR.strings.google_drive_sync_data_purge_error, + duration = 10000, + ) + } + } + }, + onDismissRequest = { showPurgeDialog = false }, + ) + } + + return Preference.PreferenceItem.TextPreference( + title = stringResource(MR.strings.pref_google_drive_purge_sync_data), + onClick = { showPurgeDialog = true }, + ) + } + + @Composable + private fun PurgeConfirmationDialog( + onConfirm: () -> Unit, + onDismissRequest: () -> Unit, + ) { + AlertDialog( + onDismissRequest = onDismissRequest, + title = { Text(text = stringResource(MR.strings.pref_purge_confirmation_title)) }, + text = { Text(text = stringResource(MR.strings.pref_purge_confirmation_message)) }, + dismissButton = { + TextButton(onClick = onDismissRequest) { + Text(text = stringResource(MR.strings.action_cancel)) + } + }, + confirmButton = { + TextButton(onClick = onConfirm) { + Text(text = stringResource(MR.strings.action_ok)) + } + }, + ) + } + + @Composable + private fun getSelfHostPreferences(syncPreferences: SyncPreferences): List { + val scope = rememberCoroutineScope() + return listOf( + Preference.PreferenceItem.EditTextPreference( + title = stringResource(MR.strings.pref_sync_host), + subtitle = stringResource(MR.strings.pref_sync_host_summ), + pref = syncPreferences.clientHost(), + onValueChanged = { newValue -> + scope.launch { + // Trim spaces at the beginning and end, then remove trailing slash if present + val trimmedValue = newValue.trim() + val modifiedValue = trimmedValue.trimEnd { it == '/' } + syncPreferences.clientHost().set(modifiedValue) + } + true + }, + ), + Preference.PreferenceItem.EditTextPreference( + title = stringResource(MR.strings.pref_sync_api_key), + subtitle = stringResource(MR.strings.pref_sync_api_key_summ), + pref = syncPreferences.clientAPIKey(), + ), + ) + } + + @Composable + private fun getSyncNowPref(): Preference.PreferenceGroup { + val navigator = LocalNavigator.currentOrThrow + return Preference.PreferenceGroup( + title = stringResource(MR.strings.pref_sync_now_group_title), + preferenceItems = persistentListOf( + getSyncOptionsPref(), + Preference.PreferenceItem.TextPreference( + title = stringResource(MR.strings.pref_sync_now), + subtitle = stringResource(MR.strings.pref_sync_now_subtitle), + onClick = { + navigator.push(SyncSettingsSelector()) + }, + ), + ), + ) + } + + @Composable + private fun getSyncOptionsPref(): Preference.PreferenceItem.TextPreference { + val navigator = LocalNavigator.currentOrThrow + return Preference.PreferenceItem.TextPreference( + title = stringResource(MR.strings.pref_sync_options), + subtitle = stringResource(MR.strings.pref_sync_options_summ), + onClick = { navigator.push(SyncTriggerOptionsScreen()) }, + ) + } + + @Composable + private fun getAutomaticSyncGroup(syncPreferences: SyncPreferences): Preference.PreferenceGroup { + val context = LocalContext.current + val syncIntervalPref = syncPreferences.syncInterval() + val lastSync by syncPreferences.lastSyncTimestamp().collectAsState() + + return Preference.PreferenceGroup( + title = stringResource(MR.strings.pref_sync_automatic_category), + preferenceItems = persistentListOf( + Preference.PreferenceItem.ListPreference( + pref = syncIntervalPref, + title = stringResource(MR.strings.pref_sync_interval), + entries = persistentMapOf( + 0 to stringResource(MR.strings.off), + 30 to stringResource(MR.strings.update_30min), + 60 to stringResource(MR.strings.update_1hour), + 180 to stringResource(MR.strings.update_3hour), + 360 to stringResource(MR.strings.update_6hour), + 720 to stringResource(MR.strings.update_12hour), + 1440 to stringResource(MR.strings.update_24hour), + 2880 to stringResource(MR.strings.update_48hour), + 10080 to stringResource(MR.strings.update_weekly), + ), + onValueChanged = { + SyncDataJob.setupTask(context, it) + true + }, + ), + Preference.PreferenceItem.InfoPreference( + stringResource(MR.strings.last_synchronization, relativeTimeSpanString(lastSync)), + ), + ), + ) + } } 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..86865113f --- /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.domain.sync.SyncPreferences +import eu.kanade.domain.sync.models.SyncSettings +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.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.getSyncSettings())), +) { + fun toggle(setter: (BackupOptions, Boolean) -> BackupOptions, enabled: Boolean) { + mutableState.update { + val updatedOptions = setter(it.options, enabled) + syncPreferences.setSyncSettings(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(syncSettings: SyncSettings): BackupOptions { + return BackupOptions( + libraryEntries = syncSettings.libraryEntries, + categories = syncSettings.categories, + chapters = syncSettings.chapters, + tracking = syncSettings.tracking, + history = syncSettings.history, + appSettings = syncSettings.appSettings, + sourceSettings = syncSettings.sourceSettings, + privateSettings = syncSettings.privateSettings, + ) + } + + private fun backupOptionsToSyncOptions(backupOptions: BackupOptions): SyncSettings { + return SyncSettings( + 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/presentation/more/settings/screen/data/SyncTriggerOptionsScreen.kt b/app/src/main/java/eu/kanade/presentation/more/settings/screen/data/SyncTriggerOptionsScreen.kt new file mode 100644 index 000000000..27136eceb --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/more/settings/screen/data/SyncTriggerOptionsScreen.kt @@ -0,0 +1,101 @@ +package eu.kanade.presentation.more.settings.screen.data + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +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.domain.sync.SyncPreferences +import eu.kanade.presentation.components.AppBar +import eu.kanade.presentation.util.Screen +import eu.kanade.tachiyomi.data.sync.models.SyncTriggerOptions +import kotlinx.collections.immutable.ImmutableList +import kotlinx.coroutines.flow.update +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 SyncTriggerOptionsScreen : Screen() { + + @Composable + override fun Content() { + val navigator = LocalNavigator.currentOrThrow + val model = rememberScreenModel { SyncOptionsScreenModel() } + val state by model.state.collectAsState() + + Scaffold( + topBar = { + AppBar( + title = stringResource(MR.strings.pref_sync_options), + navigateUp = navigator::pop, + scrollBehavior = it, + ) + }, + ) { contentPadding -> + LazyColumnWithAction( + contentPadding = contentPadding, + actionLabel = stringResource(MR.strings.action_save), + actionEnabled = state.options.anyEnabled(), + onClickAction = { + navigator.pop() + }, + ) { + item { + SectionCard(MR.strings.label_triggers) { + Options(SyncTriggerOptions.mainOptions, state, model) + } + } + } + } + } + + @Composable + private fun Options( + options: ImmutableList, + state: SyncOptionsScreenModel.State, + model: SyncOptionsScreenModel, + ) { + 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 SyncOptionsScreenModel( + val syncPreferences: SyncPreferences = Injekt.get(), +) : StateScreenModel( + State( + syncPreferences.getSyncTriggerOptions(), + ), +) { + + fun toggle(setter: (SyncTriggerOptions, Boolean) -> SyncTriggerOptions, enabled: Boolean) { + mutableState.update { + val updatedTriggerOptions = setter(it.options, enabled) + syncPreferences.setSyncTriggerOptions(updatedTriggerOptions) + it.copy( + options = updatedTriggerOptions, + ) + } + } + + @Immutable + data class State( + val options: SyncTriggerOptions = SyncTriggerOptions(), + ) +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/App.kt b/app/src/main/java/eu/kanade/tachiyomi/App.kt index 0e70af832..e54f28457 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/App.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/App.kt @@ -35,6 +35,7 @@ import com.google.firebase.ktx.Firebase import eu.kanade.domain.DomainModule import eu.kanade.domain.SYDomainModule import eu.kanade.domain.base.BasePreferences +import eu.kanade.domain.sync.SyncPreferences import eu.kanade.domain.ui.UiPreferences import eu.kanade.domain.ui.model.setAppCompatDelegateThemeMode import eu.kanade.tachiyomi.crash.CrashActivity @@ -46,6 +47,7 @@ import eu.kanade.tachiyomi.data.coil.PagePreviewFetcher import eu.kanade.tachiyomi.data.coil.PagePreviewKeyer import eu.kanade.tachiyomi.data.coil.TachiyomiImageDecoder import eu.kanade.tachiyomi.data.notification.Notifications +import eu.kanade.tachiyomi.data.sync.SyncDataJob import eu.kanade.tachiyomi.di.AppModule import eu.kanade.tachiyomi.di.PreferenceModule import eu.kanade.tachiyomi.di.SYPreferenceModule @@ -166,6 +168,13 @@ class App : Application(), DefaultLifecycleObserver, SingletonImageLoader.Factor /*if (!LogcatLogger.isInstalled && networkPreferences.verboseLogging().get()) { LogcatLogger.install(AndroidLogcatLogger(LogPriority.VERBOSE)) }*/ + + val syncPreferences: SyncPreferences = Injekt.get() + val syncTriggerOpt = syncPreferences.getSyncTriggerOptions() + if (syncPreferences.isSyncEnabled() && syncTriggerOpt.syncOnAppStart + ) { + SyncDataJob.startNow(this@App) + } } override fun newImageLoader(context: Context): ImageLoader { @@ -197,6 +206,13 @@ class App : Application(), DefaultLifecycleObserver, SingletonImageLoader.Factor override fun onStart(owner: LifecycleOwner) { SecureActivityDelegate.onApplicationStart() + + val syncPreferences: SyncPreferences = Injekt.get() + val syncTriggerOpt = syncPreferences.getSyncTriggerOptions() + if (syncPreferences.isSyncEnabled() && syncTriggerOpt.syncOnAppResume + ) { + SyncDataJob.startNow(this@App) + } } override fun onStop(owner: LifecycleOwner) { @@ -230,6 +246,12 @@ class App : Application(), DefaultLifecycleObserver, SingletonImageLoader.Factor } catch (e: Exception) { logcat(LogPriority.ERROR, e) { "Failed to modify notification channels" } } + + val syncPreferences: SyncPreferences = Injekt.get() + val syncTriggerOpt = syncPreferences.getSyncTriggerOptions() + if (syncPreferences.isSyncEnabled() && syncTriggerOpt.syncOnAppStart) { + SyncDataJob.startNow(this@App) + } } // EXH diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/create/BackupCreator.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/create/BackupCreator.kt index 73ccbd3dc..182f840fb 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/backup/create/BackupCreator.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/backup/create/BackupCreator.kt @@ -132,34 +132,34 @@ class BackupCreator( } } - private suspend fun backupCategories(options: BackupOptions): List { + suspend fun backupCategories(options: BackupOptions): List { if (!options.categories) return emptyList() return categoriesBackupCreator.backupCategories() } - private suspend fun backupMangas(mangas: List, options: BackupOptions): List { + suspend fun backupMangas(mangas: List, options: BackupOptions): List { return mangaBackupCreator.backupMangas(mangas, options) } - private fun backupSources(mangas: List): List { + fun backupSources(mangas: List): List { return sourcesBackupCreator.backupSources(mangas) } - private fun backupAppPreferences(options: BackupOptions): List { + fun backupAppPreferences(options: BackupOptions): List { if (!options.appSettings) return emptyList() return preferenceBackupCreator.backupAppPreferences(includePrivatePreferences = options.privateSettings) } - private fun backupSourcePreferences(options: BackupOptions): List { + fun backupSourcePreferences(options: BackupOptions): List { if (!options.sourceSettings) return emptyList() return preferenceBackupCreator.backupSourcePreferences(includePrivatePreferences = options.privateSettings) } // SY --> - private suspend fun backupSavedSearches(): List { + suspend fun backupSavedSearches(): List { return savedSearchBackupCreator.backupSavedSearches() } // SY <-- diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/restore/BackupRestorer.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/restore/BackupRestorer.kt index a84e6af90..d58213aac 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/backup/restore/BackupRestorer.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/backup/restore/BackupRestorer.kt @@ -34,7 +34,7 @@ class BackupRestorer( private val categoriesRestorer: CategoriesRestorer = CategoriesRestorer(), private val preferenceRestorer: PreferenceRestorer = PreferenceRestorer(context), - private val mangaRestorer: MangaRestorer = MangaRestorer(), + private val mangaRestorer: MangaRestorer = MangaRestorer(isSync), // SY --> private val savedSearchRestorer: SavedSearchRestorer = SavedSearchRestorer(), // SY <-- diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/restore/restorers/MangaRestorer.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/restore/restorers/MangaRestorer.kt index 855a59a4e..0c9ba8524 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/backup/restore/restorers/MangaRestorer.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/backup/restore/restorers/MangaRestorer.kt @@ -33,6 +33,8 @@ import java.util.Date import kotlin.math.max class MangaRestorer( + private var isSync: Boolean = false, + private val handler: DatabaseHandler = Injekt.get(), private val getCategories: GetCategories = Injekt.get(), private val getMangaByUrlAndSourceId: GetMangaByUrlAndSourceId = Injekt.get(), @@ -47,7 +49,6 @@ class MangaRestorer( private val getFlatMetadataById: GetFlatMetadataById = Injekt.get(), // SY <-- ) { - private var now = ZonedDateTime.now() private var currentFetchWindow = fetchInterval.getWindow(now) @@ -97,6 +98,11 @@ class MangaRestorer( customManga = backupManga.getCustomMangaInfo(), // SY <-- ) + + if (isSync) { + mangasQueries.resetIsSyncing() + chaptersQueries.resetIsSyncing() + } } } @@ -128,7 +134,7 @@ class MangaRestorer( ) } - private suspend fun updateManga(manga: Manga): Manga { + suspend fun updateManga(manga: Manga): Manga { handler.await(true) { mangasQueries.update( source = manga.source, @@ -173,36 +179,15 @@ class MangaRestorer( .associateBy { it.url } val (existingChapters, newChapters) = backupChapters - .mapNotNull { - val chapter = it.toChapterImpl().copy(mangaId = manga.id) - + .mapNotNull { backupChapter -> + val chapter = backupChapter.toChapterImpl().copy(mangaId = manga.id) val dbChapter = dbChaptersByUrl[chapter.url] - ?: // New chapter - return@mapNotNull chapter - if (chapter.forComparison() == dbChapter.forComparison()) { - // Same state; skip - return@mapNotNull null + when { + dbChapter == null -> chapter // New chapter + chapter.forComparison() == dbChapter.forComparison() -> null // Same state; skip + else -> updateChapterBasedOnSyncState(chapter, dbChapter) } - - // Update to an existing chapter - var updatedChapter = chapter - .copyFrom(dbChapter) - .copy( - id = dbChapter.id, - bookmark = chapter.bookmark || dbChapter.bookmark, - ) - if (dbChapter.read && !updatedChapter.read) { - updatedChapter = updatedChapter.copy( - read = true, - lastPageRead = dbChapter.lastPageRead, - ) - } else if (updatedChapter.lastPageRead == 0L && dbChapter.lastPageRead != 0L) { - updatedChapter = updatedChapter.copy( - lastPageRead = dbChapter.lastPageRead, - ) - } - updatedChapter } .partition { it.id > 0 } @@ -210,6 +195,27 @@ class MangaRestorer( updateExistingChapters(existingChapters) } + private fun updateChapterBasedOnSyncState(chapter: Chapter, dbChapter: Chapter): Chapter { + return if (isSync) { + chapter.copy( + id = dbChapter.id, + bookmark = chapter.bookmark || dbChapter.bookmark, + read = chapter.read, + lastPageRead = chapter.lastPageRead, + ) + } else { + chapter.copyFrom(dbChapter).let { + when { + dbChapter.read && !it.read -> it.copy(read = true, lastPageRead = dbChapter.lastPageRead) + it.lastPageRead == 0L && dbChapter.lastPageRead != 0L -> it.copy( + lastPageRead = dbChapter.lastPageRead, + ) + else -> it + } + } + } + } + private fun Chapter.forComparison() = this.copy(id = 0L, mangaId = 0L, dateFetch = 0L, dateUpload = 0L, lastModifiedAt = 0L, version = 0L) @@ -251,7 +257,7 @@ class MangaRestorer( dateUpload = null, chapterId = chapter.id, version = chapter.version, - isSyncing = 0, + isSyncing = 1, ) } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/library/LibraryUpdateJob.kt b/app/src/main/java/eu/kanade/tachiyomi/data/library/LibraryUpdateJob.kt index 57625bc22..52dd8c3a0 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/data/library/LibraryUpdateJob.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/library/LibraryUpdateJob.kt @@ -21,11 +21,13 @@ import eu.kanade.domain.chapter.interactor.SyncChaptersWithSource import eu.kanade.domain.manga.interactor.UpdateManga import eu.kanade.domain.manga.model.copyFrom import eu.kanade.domain.manga.model.toSManga +import eu.kanade.domain.sync.SyncPreferences import eu.kanade.domain.track.model.toDbTrack import eu.kanade.domain.track.model.toDomainTrack import eu.kanade.tachiyomi.data.cache.CoverCache import eu.kanade.tachiyomi.data.download.DownloadManager import eu.kanade.tachiyomi.data.notification.Notifications +import eu.kanade.tachiyomi.data.sync.SyncDataJob import eu.kanade.tachiyomi.data.track.TrackStatus import eu.kanade.tachiyomi.data.track.TrackerManager import eu.kanade.tachiyomi.source.model.SManga @@ -801,6 +803,7 @@ class LibraryUpdateJob(private val context: Context, workerParams: WorkerParamet // SY <-- ): Boolean { val wm = context.workManager + // Check if the LibraryUpdateJob is already running if (wm.isRunning(TAG)) { // Already running either as a scheduled or manual job return false @@ -821,6 +824,45 @@ class LibraryUpdateJob(private val context: Context, workerParams: WorkerParamet .build() wm.enqueueUniqueWork(WORK_NAME_MANUAL, ExistingWorkPolicy.KEEP, request) + val syncPreferences: SyncPreferences = Injekt.get() + + // Only proceed with SyncDataJob if sync is enabled and the specific sync on library update flag is set + val syncTriggerOpt = syncPreferences.getSyncTriggerOptions() + if (syncPreferences.isSyncEnabled() && syncTriggerOpt.syncOnLibraryUpdate + ) { + // Check if SyncDataJob is already running + if (wm.isRunning(SyncDataJob.TAG_MANUAL)) { + // SyncDataJob is already running + return false + } + + // Define the SyncDataJob + val syncDataJob = OneTimeWorkRequestBuilder() + .addTag(SyncDataJob.TAG_MANUAL) + .build() + + // Chain SyncDataJob to run before LibraryUpdateJob + val inputData = workDataOf(KEY_CATEGORY to category?.id) + val libraryUpdateJob = OneTimeWorkRequestBuilder() + .addTag(TAG) + .addTag(WORK_NAME_MANUAL) + .setInputData(inputData) + .build() + + wm.beginUniqueWork(WORK_NAME_MANUAL, ExistingWorkPolicy.KEEP, syncDataJob) + .then(libraryUpdateJob) + .enqueue() + } else { + val inputData = workDataOf(KEY_CATEGORY to category?.id) + val request = OneTimeWorkRequestBuilder() + .addTag(TAG) + .addTag(WORK_NAME_MANUAL) + .setInputData(inputData) + .build() + + wm.enqueueUniqueWork(WORK_NAME_MANUAL, ExistingWorkPolicy.KEEP, request) + } + return true } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/notification/NotificationReceiver.kt b/app/src/main/java/eu/kanade/tachiyomi/data/notification/NotificationReceiver.kt index df4ca6c97..cd4908141 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/data/notification/NotificationReceiver.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/notification/NotificationReceiver.kt @@ -10,6 +10,7 @@ import androidx.core.net.toUri import eu.kanade.tachiyomi.data.backup.restore.BackupRestoreJob import eu.kanade.tachiyomi.data.download.DownloadManager import eu.kanade.tachiyomi.data.library.LibraryUpdateJob +import eu.kanade.tachiyomi.data.sync.SyncDataJob import eu.kanade.tachiyomi.data.updater.AppUpdateDownloadJob import eu.kanade.tachiyomi.ui.main.MainActivity import eu.kanade.tachiyomi.ui.reader.ReaderActivity @@ -71,6 +72,8 @@ class NotificationReceiver : BroadcastReceiver() { "application/x-protobuf+gzip", ) ACTION_CANCEL_RESTORE -> cancelRestore(context) + + ACTION_CANCEL_SYNC -> cancelSync(context) // Cancel library update and dismiss notification ACTION_CANCEL_LIBRARY_UPDATE -> cancelLibraryUpdate(context) // Start downloading app update @@ -188,6 +191,15 @@ class NotificationReceiver : BroadcastReceiver() { AppUpdateDownloadJob.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 * @@ -240,6 +252,8 @@ class NotificationReceiver : BroadcastReceiver() { 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_START_APP_UPDATE = "$ID.$NAME.ACTION_START_APP_UPDATE" @@ -618,5 +632,25 @@ class NotificationReceiver : BroadcastReceiver() { 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, + ) + } } } 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 new file mode 100644 index 000000000..e1f949029 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/sync/SyncDataJob.kt @@ -0,0 +1,102 @@ +package eu.kanade.tachiyomi.data.sync + +import android.content.Context +import android.content.pm.ServiceInfo +import android.os.Build +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.domain.sync.SyncPreferences +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.common.util.system.logcat +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get +import java.util.concurrent.TimeUnit + +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(), + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC + } else { + 0 + }, + ) + } + + companion object { + private const val TAG_JOB = "SyncDataJob" + private const val TAG_AUTO = "$TAG_JOB:auto" + const val TAG_MANUAL = "$TAG_JOB:manual" + + private val jobTagList = listOf(TAG_AUTO, TAG_MANUAL) + + fun isAnyJobRunning(context: Context): Boolean { + return jobTagList.any { context.workManager.isRunning(it) } + } + + fun setupTask(context: Context, prefInterval: Int? = null) { + val syncPreferences = Injekt.get() + val interval = prefInterval ?: syncPreferences.syncInterval().get() + + if (interval > 0) { + val request = PeriodicWorkRequestBuilder( + interval.toLong(), + TimeUnit.MINUTES, + 10, + TimeUnit.MINUTES, + ) + .addTag(TAG_AUTO) + .build() + + context.workManager.enqueueUniquePeriodicWork(TAG_AUTO, ExistingPeriodicWorkPolicy.UPDATE, request) + } else { + context.workManager.cancelUniqueWork(TAG_AUTO) + } + } + + fun startNow(context: Context) { + val request = OneTimeWorkRequestBuilder() + .addTag(TAG_MANUAL) + .build() + context.workManager.enqueueUniqueWork(TAG_MANUAL, ExistingWorkPolicy.KEEP, request) + } + + fun stop(context: Context) { + context.workManager.cancelUniqueWork(TAG_MANUAL) + } + } +} 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 new file mode 100644 index 000000000..dba8b4af5 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/sync/SyncManager.kt @@ -0,0 +1,327 @@ +package eu.kanade.tachiyomi.data.sync + +import android.content.Context +import android.net.Uri +import eu.kanade.domain.sync.SyncPreferences +import eu.kanade.tachiyomi.data.backup.create.BackupCreator +import eu.kanade.tachiyomi.data.backup.create.BackupOptions +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.backup.models.BackupSerializer +import eu.kanade.tachiyomi.data.backup.restore.BackupRestoreJob +import eu.kanade.tachiyomi.data.backup.restore.RestoreOptions +import eu.kanade.tachiyomi.data.backup.restore.restorers.MangaRestorer +import eu.kanade.tachiyomi.data.sync.service.GoogleDriveSyncService +import eu.kanade.tachiyomi.data.sync.service.SyncData +import eu.kanade.tachiyomi.data.sync.service.SyncYomiSyncService +import kotlinx.serialization.json.Json +import kotlinx.serialization.protobuf.ProtoBuf +import logcat.LogPriority +import logcat.logcat +import tachiyomi.data.Chapters +import tachiyomi.data.DatabaseHandler +import tachiyomi.data.manga.MangaMapper.mapManga +import tachiyomi.domain.category.interactor.GetCategories +import tachiyomi.domain.manga.model.Manga +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get +import java.io.File +import java.io.IOException +import java.util.Date +import kotlin.system.measureTimeMillis + +/** + * 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 getCategories: GetCategories = Injekt.get(), +) { + private val backupCreator: BackupCreator = BackupCreator(context, false) + private val notifier: SyncNotifier = SyncNotifier(context) + private val mangaRestorer: MangaRestorer = MangaRestorer() + + enum class SyncService(val value: Int) { + NONE(0), + SYNCYOMI(1), + GOOGLE_DRIVE(2), + ; + + companion object { + fun fromInt(value: Int) = entries.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() { + // Reset isSyncing in case it was left over or failed syncing during restore. + handler.await(inTransaction = true) { + mangasQueries.resetIsSyncing() + chaptersQueries.resetIsSyncing() + } + + val syncOptions = syncPreferences.getSyncSettings() + val databaseManga = getAllMangaThatNeedsSync() + + val backupOptions = 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, + ) + val backup = Backup( + backupManga = backupCreator.backupMangas(databaseManga, backupOptions), + backupCategories = backupCreator.backupCategories(backupOptions), + backupSources = backupCreator.backupSources(databaseManga), + backupPreferences = backupCreator.backupAppPreferences(backupOptions), + backupSourcePreferences = backupCreator.backupSourcePreferences(backupOptions), + + // SY --> + backupSavedSearches = backupCreator.backupSavedSearches(), + // SY <-- + ) + + // Create the SyncData object + val syncData = SyncData( + backup = backup, + ) + + // Handle sync based on the selected service + val syncService = when (val syncService = SyncService.fromInt(syncPreferences.syncService().get())) { + SyncService.SYNCYOMI -> { + SyncYomiSyncService( + context, + json, + syncPreferences, + notifier, + ) + } + + SyncService.GOOGLE_DRIVE -> { + GoogleDriveSyncService(context, json, syncPreferences) + } + + else -> { + logcat(LogPriority.ERROR) { "Invalid sync service type: $syncService" } + null + } + } + + 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 + } + + if (remoteBackup != null) { + val (filteredFavorites, nonFavorites) = filterFavoritesAndNonFavorites(remoteBackup) + updateNonFavorites(nonFavorites) + + val newSyncData = backup.copy( + backupManga = filteredFavorites, + backupCategories = remoteBackup.backupCategories, + backupSources = remoteBackup.backupSources, + backupPreferences = remoteBackup.backupPreferences, + backupSourcePreferences = remoteBackup.backupSourcePreferences, + + // SY --> + backupSavedSearches = remoteBackup.backupSavedSearches, + // SY <-- + ) + + // It's local sync no need to restore data. (just update remote data) + if (filteredFavorites.isEmpty()) { + // update the sync timestamp + syncPreferences.lastSyncTimestamp().set(Date().time) + notifier.showSyncSuccess("Sync completed successfully") + return + } + + val backupUri = writeSyncDataToCache(context, newSyncData) + logcat(LogPriority.DEBUG) { "Got Backup Uri: $backupUri" } + if (backupUri != null) { + BackupRestoreJob.start( + context, + backupUri, + sync = true, + options = RestoreOptions( + appSettings = true, + sourceSettings = true, + library = true, + ), + ) + + // update the sync timestamp + syncPreferences.lastSyncTimestamp().set(Date().time) + } else { + logcat(LogPriority.ERROR) { "Failed to write sync data to file" } + } + } + } + + private fun writeSyncDataToCache(context: Context, backup: Backup): Uri? { + val cacheFile = File(context.cacheDir, "tachiyomi_sync_data.proto.gz") + return try { + cacheFile.outputStream().use { output -> + output.write(ProtoBuf.encodeToByteArray(BackupSerializer, backup)) + Uri.fromFile(cacheFile) + } + } catch (e: IOException) { + logcat(LogPriority.ERROR) { "Failed to write sync data to cache" } + null + } + } + + /** + * Retrieves all manga from the local database. + * + * @return a list of all manga stored in the database + */ + private suspend fun getAllMangaFromDB(): List { + return handler.awaitList { mangasQueries.getAllManga(::mapManga) } + } + + private suspend fun getAllMangaThatNeedsSync(): List { + return handler.awaitList { mangasQueries.getMangasWithFavoriteTimestamp(::mapManga) } + } + + private suspend fun isMangaDifferent(localManga: Manga, remoteManga: BackupManga): Boolean { + val localChapters = handler.await { chaptersQueries.getChaptersByMangaId(localManga.id, 0).executeAsList() } + val localCategories = getCategories.await(localManga.id).map { it.order } + + if (areChaptersDifferent(localChapters, remoteManga.chapters)) { + return true + } + + if (localManga.version != remoteManga.version) { + return true + } + + if (localCategories.toSet() != remoteManga.categories.toSet()) { + return true + } + + return false + } + + private fun areChaptersDifferent(localChapters: List, remoteChapters: List): Boolean { + val localChapterMap = localChapters.associateBy { it.url } + val remoteChapterMap = remoteChapters.associateBy { it.url } + + if (localChapterMap.size != remoteChapterMap.size) { + return true + } + + for ((url, localChapter) in localChapterMap) { + val remoteChapter = remoteChapterMap[url] + + // If a matching remote chapter doesn't exist, or the version numbers are different, consider them different + if (remoteChapter == null || localChapter.version != remoteChapter.version) { + return true + } + } + + return false + } + + /** + * 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> { + val favorites = mutableListOf() + val nonFavorites = mutableListOf() + val logTag = "filterFavoritesAndNonFavorites" + + val elapsedTimeMillis = measureTimeMillis { + val databaseManga = getAllMangaFromDB() + val localMangaMap = databaseManga.associateBy { + Triple(it.source, it.url, it.title) + } + + logcat(LogPriority.DEBUG, logTag) { "Starting to filter favorites and non-favorites from backup data." } + + backup.backupManga.forEach { remoteManga -> + val compositeKey = Triple(remoteManga.source, remoteManga.url, remoteManga.title) + val localManga = localMangaMap[compositeKey] + when { + // Checks if the manga is in favorites and needs updating or adding + remoteManga.favorite -> { + if (localManga == null || isMangaDifferent(localManga, remoteManga)) { + logcat(LogPriority.DEBUG, logTag) { "Adding to favorites: ${remoteManga.title}" } + favorites.add(remoteManga) + } else { + logcat(LogPriority.DEBUG, logTag) { "Already up-to-date favorite: ${remoteManga.title}" } + } + } + // Handle non-favorites + !remoteManga.favorite -> { + logcat(LogPriority.DEBUG, logTag) { "Adding to non-favorites: ${remoteManga.title}" } + nonFavorites.add(remoteManga) + } + } + } + } + + val minutes = elapsedTimeMillis / 60000 + val seconds = (elapsedTimeMillis % 60000) / 1000 + logcat(LogPriority.DEBUG, logTag) { + "Filtering completed in ${minutes}m ${seconds}s. Favorites found: ${favorites.size}, " + + "Non-favorites found: ${nonFavorites.size}" + } + + 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) { + val localMangaList = getAllMangaFromDB() + + val localMangaMap = localMangaList.associateBy { Triple(it.source, it.url, it.title) } + + nonFavorites.forEach { nonFavorite -> + val key = Triple(nonFavorite.source, nonFavorite.url, nonFavorite.title) + localMangaMap[key]?.let { localManga -> + if (localManga.favorite != nonFavorite.favorite) { + val updatedManga = localManga.copy(favorite = nonFavorite.favorite) + mangaRestorer.updateManga(updatedManga) + } + } + } + } +} 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 new file mode 100644 index 000000000..2321240a6 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/sync/SyncNotifier.kt @@ -0,0 +1,86 @@ +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_library)) + + 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) + } + } + + 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/data/sync/models/SyncTriggerOptions.kt b/app/src/main/java/eu/kanade/tachiyomi/data/sync/models/SyncTriggerOptions.kt new file mode 100644 index 000000000..b5dfaf001 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/sync/models/SyncTriggerOptions.kt @@ -0,0 +1,72 @@ +package eu.kanade.tachiyomi.data.sync.models + +import dev.icerock.moko.resources.StringResource +import kotlinx.collections.immutable.persistentListOf +import tachiyomi.i18n.MR + +data class SyncTriggerOptions( + val syncOnChapterRead: Boolean = false, + val syncOnChapterOpen: Boolean = false, + val syncOnAppStart: Boolean = false, + val syncOnAppResume: Boolean = false, + val syncOnLibraryUpdate: Boolean = false, +) { + fun asBooleanArray() = booleanArrayOf( + syncOnChapterRead, + syncOnChapterOpen, + syncOnAppStart, + syncOnAppResume, + syncOnLibraryUpdate, + ) + + fun anyEnabled() = syncOnChapterRead || + syncOnChapterOpen || + syncOnAppStart || + syncOnAppResume || + syncOnLibraryUpdate + + companion object { + val mainOptions = persistentListOf( + Entry( + label = MR.strings.sync_on_chapter_read, + getter = SyncTriggerOptions::syncOnChapterRead, + setter = { options, enabled -> options.copy(syncOnChapterRead = enabled) }, + ), + Entry( + label = MR.strings.sync_on_chapter_open, + getter = SyncTriggerOptions::syncOnChapterOpen, + setter = { options, enabled -> options.copy(syncOnChapterOpen = enabled) }, + ), + Entry( + label = MR.strings.sync_on_app_start, + getter = SyncTriggerOptions::syncOnAppStart, + setter = { options, enabled -> options.copy(syncOnAppStart = enabled) }, + ), + Entry( + label = MR.strings.sync_on_app_resume, + getter = SyncTriggerOptions::syncOnAppResume, + setter = { options, enabled -> options.copy(syncOnAppResume = enabled) }, + ), + Entry( + label = MR.strings.sync_on_library_update, + getter = SyncTriggerOptions::syncOnLibraryUpdate, + setter = { options, enabled -> options.copy(syncOnLibraryUpdate = enabled) }, + ), + ) + + fun fromBooleanArray(array: BooleanArray) = SyncTriggerOptions( + syncOnChapterRead = array[0], + syncOnChapterOpen = array[1], + syncOnAppStart = array[2], + syncOnAppResume = array[3], + syncOnLibraryUpdate = array[4], + ) + } + + data class Entry( + val label: StringResource, + val getter: (SyncTriggerOptions) -> Boolean, + val setter: (SyncTriggerOptions, Boolean) -> SyncTriggerOptions, + val enabled: (SyncTriggerOptions) -> Boolean = { true }, + ) +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/sync/service/GoogleDriveSyncService.kt b/app/src/main/java/eu/kanade/tachiyomi/data/sync/service/GoogleDriveSyncService.kt new file mode 100644 index 000000000..7beb6d512 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/sync/service/GoogleDriveSyncService.kt @@ -0,0 +1,522 @@ +package eu.kanade.tachiyomi.data.sync.service + +import android.app.Activity +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.util.Log +import com.google.api.client.auth.oauth2.TokenResponseException +import com.google.api.client.googleapis.auth.oauth2.GoogleAuthorizationCodeFlow +import com.google.api.client.googleapis.auth.oauth2.GoogleAuthorizationCodeTokenRequest +import com.google.api.client.googleapis.auth.oauth2.GoogleClientSecrets +import com.google.api.client.googleapis.auth.oauth2.GoogleCredential +import com.google.api.client.googleapis.auth.oauth2.GoogleTokenResponse +import com.google.api.client.http.ByteArrayContent +import com.google.api.client.http.javanet.NetHttpTransport +import com.google.api.client.json.JsonFactory +import com.google.api.client.json.jackson2.JacksonFactory +import com.google.api.services.drive.Drive +import com.google.api.services.drive.DriveScopes +import com.google.api.services.drive.model.File +import eu.kanade.domain.sync.SyncPreferences +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.withContext +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import logcat.LogPriority +import logcat.logcat +import tachiyomi.core.common.i18n.stringResource +import tachiyomi.i18n.MR +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.io.IOException +import java.io.InputStreamReader +import java.time.Instant +import java.util.zip.GZIPInputStream +import java.util.zip.GZIPOutputStream + +class GoogleDriveSyncService(context: Context, json: Json, syncPreferences: SyncPreferences) : SyncService( + context, + json, + syncPreferences, +) { + constructor(context: Context) : this( + context, + Json { + encodeDefaults = true + ignoreUnknownKeys = true + }, + Injekt.get(), + ) + + enum class DeleteSyncDataStatus { + NOT_INITIALIZED, + NO_FILES, + SUCCESS, + ERROR, + } + + private val appName = context.stringResource(MR.strings.app_name) + + private val remoteFileName = "${appName}_sync_data.gz" + + private val lockFileName = "${appName}_sync.lock" + + private val googleDriveService = GoogleDriveService(context) + + override suspend fun beforeSync() { + try { + googleDriveService.refreshToken() + val drive = googleDriveService.driveService + ?: throw Exception(context.stringResource(MR.strings.google_drive_not_signed_in)) + + var backoff = 1000L + var retries = 0 // Retry counter + val maxRetries = 10 // Maximum number of retries + + while (retries < maxRetries) { + val lockFiles = findLockFile(drive) + logcat(LogPriority.DEBUG) { "Found ${lockFiles.size} lock file(s)" } + + when { + lockFiles.isEmpty() -> { + logcat(LogPriority.DEBUG) { "No lock file found, creating a new one" } + createLockFile(drive) + break + } + lockFiles.size == 1 -> { + val lockFile = lockFiles.first() + val createdTime = Instant.parse(lockFile.createdTime.toString()) + val ageMinutes = java.time.Duration.between(createdTime, Instant.now()).toMinutes() + logcat(LogPriority.DEBUG) { "Lock file age: $ageMinutes minutes" } + if (ageMinutes <= 3) { + logcat(LogPriority.DEBUG) { "Lock file is new, proceeding with sync" } + break + } else { + logcat(LogPriority.DEBUG) { "Lock file is old, deleting and creating a new one" } + deleteLockFile(drive) + createLockFile(drive) + break + } + } + else -> { + logcat(LogPriority.DEBUG) { "Multiple lock files found, applying backoff" } + delay(backoff) // Apply backoff strategy + backoff = (backoff * 2).coerceAtMost(16000L) + logcat(LogPriority.DEBUG) { "Backoff increased to $backoff milliseconds" } + } + } + retries++ // Increment retry counter + logcat(LogPriority.DEBUG) { "Loop iteration complete, retry count: $retries, backoff time: $backoff" } + } + + if (retries >= maxRetries) { + logcat(LogPriority.ERROR) { "Max retries reached, exiting sync process" } + throw Exception(context.stringResource(MR.strings.error_before_sync_gdrive) + ": Max retries reached.") + } + } catch (e: Exception) { + logcat(LogPriority.ERROR) { "Error in GoogleDrive beforeSync: ${e.message}" } + throw Exception(context.stringResource(MR.strings.error_before_sync_gdrive) + ": ${e.message}") + } + } + + override suspend fun pullSyncData(): SyncData? { + val drive = googleDriveService.driveService + + if (drive == null) { + logcat(LogPriority.DEBUG) { "Google Drive service not initialized" } + throw Exception(context.stringResource(MR.strings.google_drive_not_signed_in)) + } + + val fileList = getAppDataFileList(drive) + if (fileList.isEmpty()) { + logcat(LogPriority.INFO) { "No files found in app data" } + return null + } + + val gdriveFileId = fileList[0].id + logcat(LogPriority.DEBUG) { "Google Drive File ID: $gdriveFileId" } + + val outputStream = ByteArrayOutputStream() + try { + drive.files().get(gdriveFileId).executeMediaAndDownloadTo(outputStream) + logcat(LogPriority.DEBUG) { "File downloaded successfully" } + } catch (e: Exception) { + logcat(LogPriority.ERROR) { "Error downloading file: ${e.message}" } + return null + } + + return withContext(Dispatchers.IO) { + try { + val gzipInputStream = GZIPInputStream(ByteArrayInputStream(outputStream.toByteArray())) + val jsonString = gzipInputStream.bufferedReader(Charsets.UTF_8).use { it.readText() } + val syncData = json.decodeFromString(SyncData.serializer(), jsonString) + logcat(LogPriority.DEBUG) { "JSON deserialized successfully" } + syncData + } catch (e: Exception) { + logcat( + LogPriority.ERROR, + ) { "Failed to convert json to sync data with kotlinx.serialization: ${e.message}" } + throw Exception(e.message) + } + } + } + + override suspend fun pushSyncData(syncData: SyncData) { + val jsonData = json.encodeToString(syncData) + val drive = googleDriveService.driveService + ?: throw Exception(context.stringResource(MR.strings.google_drive_not_signed_in)) + + val fileList = getAppDataFileList(drive) + val byteArrayOutputStream = ByteArrayOutputStream() + withContext(Dispatchers.IO) { + GZIPOutputStream(byteArrayOutputStream).use { gzipOutputStream -> + gzipOutputStream.write(jsonData.toByteArray(Charsets.UTF_8)) + } + logcat(LogPriority.DEBUG) { "JSON serialized successfully" } + } + + val byteArrayContent = ByteArrayContent("application/octet-stream", byteArrayOutputStream.toByteArray()) + + try { + if (fileList.isNotEmpty()) { + // File exists, so update it + val fileId = fileList[0].id + drive.files().update(fileId, null, byteArrayContent).execute() + logcat(LogPriority.DEBUG) { "Updated existing sync data file in Google Drive with file ID: $fileId" } + } else { + // File doesn't exist, so create it + val fileMetadata = File().apply { + name = remoteFileName + mimeType = "application/gzip" + parents = listOf("appDataFolder") + } + val uploadedFile = drive.files().create(fileMetadata, byteArrayContent) + .setFields("id") + .execute() + + logcat( + LogPriority.DEBUG, + ) { "Created new sync data file in Google Drive with file ID: ${uploadedFile.id}" } + } + + // Data has been successfully pushed or updated, delete the lock file + deleteLockFile(drive) + } catch (e: Exception) { + logcat(LogPriority.ERROR) { "Failed to push or update sync data: ${e.message}" } + throw Exception(context.stringResource(MR.strings.error_uploading_sync_data) + ": ${e.message}") + } + } + + private fun getAppDataFileList(drive: Drive): MutableList { + try { + // Search for the existing file by name in the appData folder + val query = "mimeType='application/gzip' and name = '$remoteFileName'" + val fileList = drive.files() + .list() + .setSpaces("appDataFolder") + .setQ(query) + .setFields("files(id, name, createdTime)") + .execute() + .files + Log.d("GoogleDrive", "AppData folder file list: $fileList") + + return fileList + } catch (e: Exception) { + Log.e("GoogleDrive", "Error no sync data found in appData folder: ${e.message}") + return mutableListOf() + } + } + + private fun createLockFile(drive: Drive) { + try { + val fileMetadata = File().apply { + name = lockFileName + mimeType = "text/plain" + parents = listOf("appDataFolder") + } + + // Create an empty content to upload as the lock file + val emptyContent = ByteArrayContent.fromString("text/plain", "") + + val file = drive.files().create(fileMetadata, emptyContent) + .setFields("id, name, createdTime") + .execute() + + Log.d("GoogleDrive", "Created lock file with ID: ${file.id}") + } catch (e: Exception) { + Log.e("GoogleDrive", "Error creating lock file: ${e.message}") + throw Exception(e.message) + } + } + + private fun findLockFile(drive: Drive): MutableList { + try { + val query = "mimeType='text/plain' and name = '$lockFileName'" + val fileList = drive.files() + .list() + .setSpaces("appDataFolder") + .setQ(query) + .setFields("files(id, name, createdTime)") + .execute().files + Log.d("GoogleDrive", "Lock file search result: $fileList") + return fileList + } catch (e: Exception) { + Log.e("GoogleDrive", "Error finding lock file: ${e.message}") + return mutableListOf() + } + } + + private fun deleteLockFile(drive: Drive) { + try { + val lockFiles = findLockFile(drive) + + if (lockFiles.isNotEmpty()) { + for (file in lockFiles) { + drive.files().delete(file.id).execute() + Log.d("GoogleDrive", "Deleted lock file with ID: ${file.id}") + } + } else { + Log.d("GoogleDrive", "No lock file found to delete.") + } + } catch (e: Exception) { + Log.e("GoogleDrive", "Error deleting lock file: ${e.message}") + throw Exception(context.stringResource(MR.strings.error_deleting_google_drive_lock_file)) + } + } + + suspend fun deleteSyncDataFromGoogleDrive(): DeleteSyncDataStatus { + val drive = googleDriveService.driveService + + if (drive == null) { + logcat(LogPriority.ERROR) { "Google Drive service not initialized" } + return DeleteSyncDataStatus.NOT_INITIALIZED + } + googleDriveService.refreshToken() + + return withContext(Dispatchers.IO) { + try { + val appDataFileList = getAppDataFileList(drive) + + if (appDataFileList.isEmpty()) { + logcat(LogPriority.DEBUG) { "No sync data file found in appData folder of Google Drive" } + DeleteSyncDataStatus.NO_FILES + } else { + for (file in appDataFileList) { + drive.files().delete(file.id).execute() + logcat( + LogPriority.DEBUG, + ) { "Deleted sync data file in appData folder of Google Drive with file ID: ${file.id}" } + } + DeleteSyncDataStatus.SUCCESS + } + } catch (e: Exception) { + logcat(LogPriority.ERROR) { "Error occurred while interacting with Google Drive: ${e.message}" } + DeleteSyncDataStatus.ERROR + } + } + } +} + +class GoogleDriveService(private val context: Context) { + var driveService: Drive? = null + companion object { + const val REDIRECT_URI = "eu.kanade.google.oauth:/oauth2redirect" + } + private val syncPreferences = Injekt.get() + + init { + initGoogleDriveService() + } + + /** + * Initializes the Google Drive service by obtaining the access token and refresh token from the SyncPreferences + * and setting up the service using the obtained tokens. + */ + private fun initGoogleDriveService() { + val accessToken = syncPreferences.googleDriveAccessToken().get() + val refreshToken = syncPreferences.googleDriveRefreshToken().get() + + if (accessToken == "" || refreshToken == "") { + driveService = null + return + } + + setupGoogleDriveService(accessToken, refreshToken) + } + + /** + * Launches an Intent to open the user's default browser for Google Drive sign-in. + * The Intent carries the authorization URL, which prompts the user to sign in + * and grant the application permission to access their Google Drive account. + * @return An Intent configured to launch a browser for Google Drive OAuth sign-in. + */ + fun getSignInIntent(): Intent { + val authorizationUrl = generateAuthorizationUrl() + + return Intent(Intent.ACTION_VIEW).apply { + data = Uri.parse(authorizationUrl) + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + } + + /** + * Generates the authorization URL required for the user to grant the application + * permission to access their Google Drive account. + * Sets the approval prompt to "force" to ensure that the user is always prompted to grant access, + * even if they have previously granted access. + * @return The authorization URL. + */ + private fun generateAuthorizationUrl(): String { + val jsonFactory: JsonFactory = JacksonFactory.getDefaultInstance() + val secrets = GoogleClientSecrets.load( + jsonFactory, + InputStreamReader(context.assets.open("client_secrets.json")), + ) + + val flow = GoogleAuthorizationCodeFlow.Builder( + NetHttpTransport(), + jsonFactory, + secrets, + listOf(DriveScopes.DRIVE_FILE, DriveScopes.DRIVE_APPDATA), + ).setAccessType("offline").build() + + return flow.newAuthorizationUrl() + .setRedirectUri(REDIRECT_URI) + .setApprovalPrompt("force") + .build() + } + internal suspend fun refreshToken() = withContext(Dispatchers.IO) { + val refreshToken = syncPreferences.googleDriveRefreshToken().get() + val accessToken = syncPreferences.googleDriveAccessToken().get() + + val jsonFactory: JsonFactory = JacksonFactory.getDefaultInstance() + val secrets = GoogleClientSecrets.load( + jsonFactory, + InputStreamReader(context.assets.open("client_secrets.json")), + ) + + val credential = GoogleCredential.Builder() + .setJsonFactory(jsonFactory) + .setTransport(NetHttpTransport()) + .setClientSecrets(secrets) + .build() + + if (refreshToken == "") { + throw Exception(context.stringResource(MR.strings.google_drive_not_signed_in)) + } + + credential.refreshToken = refreshToken + + logcat(LogPriority.DEBUG) { "Refreshing access token with: $refreshToken" } + + try { + credential.refreshToken() + val newAccessToken = credential.accessToken + // Save the new access token + syncPreferences.googleDriveAccessToken().set(newAccessToken) + setupGoogleDriveService(newAccessToken, credential.refreshToken) + logcat(LogPriority.DEBUG) { "Google Access token refreshed old: $accessToken new: $newAccessToken" } + } catch (e: TokenResponseException) { + if (e.details.error == "invalid_grant") { + // The refresh token is invalid, prompt the user to sign in again + logcat(LogPriority.ERROR) { "Refresh token is invalid, prompt user to sign in again" } + throw e.message?.let { Exception(it) } ?: Exception("Unknown error") + } else { + // Token refresh failed; handle this situation + logcat(LogPriority.ERROR) { "Failed to refresh access token ${e.message}" } + logcat(LogPriority.ERROR) { "Google Drive sync will be disabled" } + throw e.message?.let { Exception(it) } ?: Exception("Unknown error") + } + } catch (e: IOException) { + // Token refresh failed; handle this situation + logcat(LogPriority.ERROR) { "Failed to refresh access token ${e.message}" } + logcat(LogPriority.ERROR) { "Google Drive sync will be disabled" } + throw e.message?.let { Exception(it) } ?: Exception("Unknown error") + } + } + + /** + * Sets up the Google Drive service using the provided access token and refresh token. + * @param accessToken The access token obtained from the SyncPreferences. + * @param refreshToken The refresh token obtained from the SyncPreferences. + */ + private fun setupGoogleDriveService(accessToken: String, refreshToken: String) { + val jsonFactory: JsonFactory = JacksonFactory.getDefaultInstance() + val secrets = GoogleClientSecrets.load( + jsonFactory, + InputStreamReader(context.assets.open("client_secrets.json")), + ) + + val credential = GoogleCredential.Builder() + .setJsonFactory(jsonFactory) + .setTransport(NetHttpTransport()) + .setClientSecrets(secrets) + .build() + + credential.accessToken = accessToken + credential.refreshToken = refreshToken + + driveService = Drive.Builder( + NetHttpTransport(), + jsonFactory, + credential, + ).setApplicationName(context.stringResource(MR.strings.app_name)) + .build() + } + + /** + * Handles the authorization code returned after the user has granted the application permission to access their Google Drive account. + * It obtains the access token and refresh token using the authorization code, saves the tokens to the SyncPreferences, + * sets up the Google Drive service using the obtained tokens, and initializes the service. + * @param authorizationCode The authorization code obtained from the OAuthCallbackServer. + * @param activity The current activity. + * @param onSuccess A callback function to be called on successful authorization. + * @param onFailure A callback function to be called on authorization failure. + */ + fun handleAuthorizationCode( + authorizationCode: String, + activity: Activity, + onSuccess: () -> Unit, + onFailure: (String) -> Unit, + ) { + val jsonFactory: JsonFactory = JacksonFactory.getDefaultInstance() + val secrets = GoogleClientSecrets.load( + jsonFactory, + InputStreamReader(context.assets.open("client_secrets.json")), + ) + + val tokenResponse: GoogleTokenResponse = GoogleAuthorizationCodeTokenRequest( + NetHttpTransport(), + jsonFactory, + secrets.installed.clientId, + secrets.installed.clientSecret, + authorizationCode, + REDIRECT_URI, + ).setGrantType("authorization_code").execute() + + try { + // Save the access token and refresh token + val accessToken = tokenResponse.accessToken + val refreshToken = tokenResponse.refreshToken + + // Save the tokens to SyncPreferences + syncPreferences.googleDriveAccessToken().set(accessToken) + syncPreferences.googleDriveRefreshToken().set(refreshToken) + + setupGoogleDriveService(accessToken, refreshToken) + initGoogleDriveService() + + activity.runOnUiThread { + onSuccess() + } + } catch (e: Exception) { + activity.runOnUiThread { + onFailure(e.localizedMessage ?: "Unknown error") + } + } + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/sync/service/SyncService.kt b/app/src/main/java/eu/kanade/tachiyomi/data/sync/service/SyncService.kt new file mode 100644 index 000000000..cf61869fa --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/sync/service/SyncService.kt @@ -0,0 +1,511 @@ +package eu.kanade.tachiyomi.data.sync.service + +import android.content.Context +import eu.kanade.domain.sync.SyncPreferences +import eu.kanade.tachiyomi.data.backup.models.Backup +import eu.kanade.tachiyomi.data.backup.models.BackupCategory +import eu.kanade.tachiyomi.data.backup.models.BackupChapter +import eu.kanade.tachiyomi.data.backup.models.BackupManga +import eu.kanade.tachiyomi.data.backup.models.BackupPreference +import eu.kanade.tachiyomi.data.backup.models.BackupSavedSearch +import eu.kanade.tachiyomi.data.backup.models.BackupSource +import eu.kanade.tachiyomi.data.backup.models.BackupSourcePreferences +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import logcat.LogPriority +import logcat.logcat + +@Serializable +data class SyncData( + val backup: Backup? = null, +) + +abstract class SyncService( + val context: Context, + val json: Json, + val syncPreferences: SyncPreferences, +) { + open suspend fun doSync(syncData: SyncData): Backup? { + beforeSync() + + val remoteSData = pullSyncData() + + val finalSyncData = + if (remoteSData == null) { + pushSyncData(syncData) + syncData + } else { + val mergedSyncData = mergeSyncData(syncData, remoteSData) + pushSyncData(mergedSyncData) + mergedSyncData + } + + return finalSyncData.backup + } + + /** + * For refreshing tokens and other possible operations before connecting to the remote storage + */ + open suspend fun beforeSync() {} + + /** + * Download sync data from the remote storage + */ + abstract suspend fun pullSyncData(): SyncData? + + /** + * Upload sync data to the remote storage + */ + abstract suspend fun pushSyncData(syncData: SyncData) + + /** + * Merges the local and remote sync data into a single JSON string. + * + * @param localSyncData The SData containing the local sync data. + * @param remoteSyncData The SData containing the remote sync data. + * @return The JSON string containing the merged sync data. + */ + private fun mergeSyncData(localSyncData: SyncData, remoteSyncData: SyncData): SyncData { + val mergedMangaList = mergeMangaLists(localSyncData.backup?.backupManga, remoteSyncData.backup?.backupManga) + val mergedCategoriesList = + mergeCategoriesLists(localSyncData.backup?.backupCategories, remoteSyncData.backup?.backupCategories) + + val mergedSourcesList = mergeSourcesLists(localSyncData.backup?.backupSources, remoteSyncData.backup?.backupSources) + val mergedPreferencesList = + mergePreferencesLists(localSyncData.backup?.backupPreferences, remoteSyncData.backup?.backupPreferences) + val mergedSourcePreferencesList = mergeSourcePreferencesLists( + localSyncData.backup?.backupSourcePreferences, + remoteSyncData.backup?.backupSourcePreferences, + ) + + // SY --> + val mergedSavedSearchesList = mergeSavedSearchesLists( + localSyncData.backup?.backupSavedSearches, + remoteSyncData.backup?.backupSavedSearches, + ) + // SY <-- + + + // Create the merged Backup object + val mergedBackup = Backup( + backupManga = mergedMangaList, + backupCategories = mergedCategoriesList, + backupSources = mergedSourcesList, + backupPreferences = mergedPreferencesList, + backupSourcePreferences = mergedSourcePreferencesList, + + // SY --> + backupSavedSearches = mergedSavedSearchesList, + // SY <-- + ) + + // Create the merged SData object + return SyncData( + backup = mergedBackup, + ) + } + + /** + * Merges two lists of BackupManga objects, selecting the most recent manga based on the lastModifiedAt value. + * If lastModifiedAt is null for a manga, it treats that manga as the oldest possible for comparison purposes. + * This function is designed to reconcile local and remote manga lists, ensuring the most up-to-date manga is retained. + * + * @param localMangaList The list of local BackupManga objects or null. + * @param remoteMangaList The list of remote BackupManga objects or null. + * @return A list of BackupManga objects, each representing the most recent version of the manga from either local or remote sources. + */ + private fun mergeMangaLists( + localMangaList: List?, + remoteMangaList: List?, + ): List { + val logTag = "MergeMangaLists" + + val localMangaListSafe = localMangaList.orEmpty() + val remoteMangaListSafe = remoteMangaList.orEmpty() + + logcat(LogPriority.DEBUG, logTag) { + "Starting merge. Local list size: ${localMangaListSafe.size}, Remote list size: ${remoteMangaListSafe.size}" + } + + fun mangaCompositeKey(manga: BackupManga): String { + return "${manga.source}|${manga.url}|${manga.title.lowercase().trim()}|${manga.author?.lowercase()?.trim()}" + } + + // Create maps using composite keys + val localMangaMap = localMangaListSafe.associateBy { mangaCompositeKey(it) } + val remoteMangaMap = remoteMangaListSafe.associateBy { mangaCompositeKey(it) } + + logcat(LogPriority.DEBUG, logTag) { + "Starting merge. Local list size: ${localMangaListSafe.size}, Remote list size: ${remoteMangaListSafe.size}" + } + + val mergedList = (localMangaMap.keys + remoteMangaMap.keys).distinct().mapNotNull { compositeKey -> + val local = localMangaMap[compositeKey] + val remote = remoteMangaMap[compositeKey] + + // New version comparison logic + when { + local != null && remote == null -> local + local == null && remote != null -> remote + local != null && remote != null -> { + // Compare versions to decide which manga to keep + if (local.version >= remote.version) { + logcat(LogPriority.DEBUG, logTag) { "Keeping local version of ${local.title} with merged chapters." } + local.copy(chapters = mergeChapters(local.chapters, remote.chapters)) + } else { + logcat(LogPriority.DEBUG, logTag) { "Keeping remote version of ${remote.title} with merged chapters." } + remote.copy(chapters = mergeChapters(local.chapters, remote.chapters)) + } + } + else -> null // No manga found for key + } + } + + // Counting favorites and non-favorites + val (favorites, nonFavorites) = mergedList.partition { it.favorite } + + logcat(LogPriority.DEBUG, logTag) { + "Merge completed. Total merged manga: ${mergedList.size}, Favorites: ${favorites.size}, " + + "Non-Favorites: ${nonFavorites.size}" + } + + return mergedList + } + +/** + * Merges two lists of BackupChapter objects, selecting the most recent chapter based on the lastModifiedAt value. + * If lastModifiedAt is null for a chapter, it treats that chapter as the oldest possible for comparison purposes. + * This function is designed to reconcile local and remote chapter lists, ensuring the most up-to-date chapter is retained. + * + * @param localChapters The list of local BackupChapter objects. + * @param remoteChapters The list of remote BackupChapter objects. + * @return A list of BackupChapter objects, each representing the most recent version of the chapter from either local or remote sources. + * + * - This function is used in scenarios where local and remote chapter lists need to be synchronized. + * - It iterates over the union of the URLs from both local and remote chapters. + * - For each URL, it compares the corresponding local and remote chapters based on the lastModifiedAt value. + * - If only one source (local or remote) has the chapter for a URL, that chapter is used. + * - If both sources have the chapter, the one with the more recent lastModifiedAt value is chosen. + * - If lastModifiedAt is null or missing, the chapter is considered the oldest for safety, ensuring that any chapter with a valid timestamp is preferred. + * - The resulting list contains the most recent chapters from the combined set of local and remote chapters. + */ + private fun mergeChapters( + localChapters: List, + remoteChapters: List, + ): List { + val logTag = "MergeChapters" + + fun chapterCompositeKey(chapter: BackupChapter): String { + return "${chapter.url}|${chapter.name}|${chapter.chapterNumber}" + } + + val localChapterMap = localChapters.associateBy { chapterCompositeKey(it) } + val remoteChapterMap = remoteChapters.associateBy { chapterCompositeKey(it) } + + logcat(LogPriority.DEBUG, logTag) { + "Starting chapter merge. Local chapters: ${localChapters.size}, Remote chapters: ${remoteChapters.size}" + } + + // Merge both chapter maps based on version numbers + val mergedChapters = (localChapterMap.keys + remoteChapterMap.keys).distinct().mapNotNull { compositeKey -> + val localChapter = localChapterMap[compositeKey] + val remoteChapter = remoteChapterMap[compositeKey] + + logcat(LogPriority.DEBUG, logTag) { + "Processing chapter key: $compositeKey. Local chapter: ${localChapter != null}, " + + "Remote chapter: ${remoteChapter != null}" + } + + when { + localChapter != null && remoteChapter == null -> { + logcat(LogPriority.DEBUG, logTag) { "Keeping local chapter: ${localChapter.name}." } + localChapter + } + localChapter == null && remoteChapter != null -> { + logcat(LogPriority.DEBUG, logTag) { "Taking remote chapter: ${remoteChapter.name}." } + remoteChapter + } + localChapter != null && remoteChapter != null -> { + // Use version number to decide which chapter to keep + val chosenChapter = if (localChapter.version >= remoteChapter.version) localChapter else remoteChapter + logcat(LogPriority.DEBUG, logTag) { + "Merging chapter: ${chosenChapter.name}. Chosen version from: ${ + if (localChapter.version >= remoteChapter.version) "Local" else "Remote" + }, Local version: ${localChapter.version}, Remote version: ${remoteChapter.version}." + } + chosenChapter + } + else -> { + logcat(LogPriority.DEBUG, logTag) { + "No chapter found for composite key: $compositeKey. Skipping." + } + null + } + } + } + + logcat(LogPriority.DEBUG, logTag) { "Chapter merge completed. Total merged chapters: ${mergedChapters.size}" } + + return mergedChapters + } + + /** + * Merges two lists of SyncCategory objects, prioritizing the category with the most recent order value. + * + * @param localCategoriesList The list of local SyncCategory objects. + * @param remoteCategoriesList The list of remote SyncCategory objects. + * @return The merged list of SyncCategory objects. + */ + private fun mergeCategoriesLists( + localCategoriesList: List?, + remoteCategoriesList: List?, + ): List { + if (localCategoriesList == null) return remoteCategoriesList ?: emptyList() + if (remoteCategoriesList == null) return localCategoriesList + val localCategoriesMap = localCategoriesList.associateBy { it.name } + val remoteCategoriesMap = remoteCategoriesList.associateBy { it.name } + + val mergedCategoriesMap = mutableMapOf() + + localCategoriesMap.forEach { (name, localCategory) -> + val remoteCategory = remoteCategoriesMap[name] + if (remoteCategory != null) { + // Compare and merge local and remote categories + val mergedCategory = if (localCategory.order > remoteCategory.order) { + localCategory + } else { + remoteCategory + } + mergedCategoriesMap[name] = mergedCategory + } else { + // If the category is only in the local list, add it to the merged list + mergedCategoriesMap[name] = localCategory + } + } + + // Add any categories from the remote list that are not in the local list + remoteCategoriesMap.forEach { (name, remoteCategory) -> + if (!mergedCategoriesMap.containsKey(name)) { + mergedCategoriesMap[name] = remoteCategory + } + } + + return mergedCategoriesMap.values.toList() + } + + private fun mergeSourcesLists( + localSources: List?, + remoteSources: List? + ): List { + val logTag = "MergeSources" + + // Create maps using sourceId as key + val localSourceMap = localSources?.associateBy { it.sourceId } ?: emptyMap() + val remoteSourceMap = remoteSources?.associateBy { it.sourceId } ?: emptyMap() + + logcat(LogPriority.DEBUG, logTag) { + "Starting source merge. Local sources: ${localSources?.size}, Remote sources: ${remoteSources?.size}" + } + + // Merge both source maps + val mergedSources = (localSourceMap.keys + remoteSourceMap.keys).distinct().mapNotNull { sourceId -> + val localSource = localSourceMap[sourceId] + val remoteSource = remoteSourceMap[sourceId] + + logcat(LogPriority.DEBUG, logTag) { + "Processing source ID: $sourceId. Local source: ${localSource != null}, " + + "Remote source: ${remoteSource != null}" + } + + when { + localSource != null && remoteSource == null -> { + logcat(LogPriority.DEBUG, logTag) { "Using local source: ${localSource.name}." } + localSource + } + remoteSource != null && localSource == null -> { + logcat(LogPriority.DEBUG, logTag) { "Using remote source: ${remoteSource.name}." } + remoteSource + } + else -> { + logcat(LogPriority.DEBUG, logTag) { "Remote and local is not empty: $sourceId. Skipping." } + null + } + } + } + + logcat(LogPriority.DEBUG, logTag) { "Source merge completed. Total merged sources: ${mergedSources.size}" } + + return mergedSources + } + + private fun mergePreferencesLists( + localPreferences: List?, + remotePreferences: List? + ): List { + val logTag = "MergePreferences" + + // Create maps using key as the unique identifier + val localPreferencesMap = localPreferences?.associateBy { it.key } ?: emptyMap() + val remotePreferencesMap = remotePreferences?.associateBy { it.key } ?: emptyMap() + + logcat(LogPriority.DEBUG, logTag) { + "Starting preferences merge. Local preferences: ${localPreferences?.size}, " + + "Remote preferences: ${remotePreferences?.size}" + } + + // Merge both preferences maps + val mergedPreferences = (localPreferencesMap.keys + remotePreferencesMap.keys).distinct().mapNotNull { key -> + val localPreference = localPreferencesMap[key] + val remotePreference = remotePreferencesMap[key] + + logcat(LogPriority.DEBUG, logTag) { + "Processing preference key: $key. Local preference: ${localPreference != null}, " + + "Remote preference: ${remotePreference != null}" + } + + when { + localPreference != null && remotePreference == null -> { + logcat(LogPriority.DEBUG, logTag) { "Using local preference: ${localPreference.key}." } + localPreference + } + remotePreference != null && localPreference == null -> { + logcat(LogPriority.DEBUG, logTag) { "Using remote preference: ${remotePreference.key}." } + remotePreference + } + else -> { + logcat(LogPriority.DEBUG, logTag) { "Both remote and local have keys. Skipping: $key" } + null + } + } + } + + logcat(LogPriority.DEBUG, logTag) { + "Preferences merge completed. Total merged preferences: ${mergedPreferences.size}" + } + + return mergedPreferences + } + + private fun mergeSourcePreferencesLists( + localPreferences: List?, + remotePreferences: List? + ): List { + val logTag = "MergeSourcePreferences" + + // Create maps using sourceKey as the unique identifier + val localPreferencesMap = localPreferences?.associateBy { it.sourceKey } ?: emptyMap() + val remotePreferencesMap = remotePreferences?.associateBy { it.sourceKey } ?: emptyMap() + + logcat(LogPriority.DEBUG, logTag) { + "Starting source preferences merge. Local source preferences: ${localPreferences?.size}, " + + "Remote source preferences: ${remotePreferences?.size}" + } + + // Merge both source preferences maps + val mergedSourcePreferences = (localPreferencesMap.keys + remotePreferencesMap.keys).distinct().mapNotNull { sourceKey -> + val localSourcePreference = localPreferencesMap[sourceKey] + val remoteSourcePreference = remotePreferencesMap[sourceKey] + + logcat(LogPriority.DEBUG, logTag) { + "Processing source preference key: $sourceKey. " + + "Local source preference: ${localSourcePreference != null}, " + + "Remote source preference: ${remoteSourcePreference != null}" + } + + when { + localSourcePreference != null && remoteSourcePreference == null -> { + logcat(LogPriority.DEBUG, logTag) { + "Using local source preference: ${localSourcePreference.sourceKey}." + } + localSourcePreference + } + remoteSourcePreference != null && localSourcePreference == null -> { + logcat(LogPriority.DEBUG, logTag) { + "Using remote source preference: ${remoteSourcePreference.sourceKey}." + } + remoteSourcePreference + } + localSourcePreference != null && remoteSourcePreference != null -> { + // Merge the individual preferences within the source preferences + val mergedPrefs = mergeIndividualPreferences(localSourcePreference.prefs, remoteSourcePreference.prefs) + BackupSourcePreferences(sourceKey, mergedPrefs) + } + else -> null + } + } + + logcat(LogPriority.DEBUG, logTag) { + "Source preferences merge completed. Total merged source preferences: ${mergedSourcePreferences.size}" + } + + return mergedSourcePreferences + } + + private fun mergeIndividualPreferences( + localPrefs: List, + remotePrefs: List + ): List { + val mergedPrefsMap = (localPrefs + remotePrefs).associateBy { it.key } + return mergedPrefsMap.values.toList() + } + + + + // SY --> + private fun mergeSavedSearchesLists( + localSearches: List?, + remoteSearches: List? + ): List { + val logTag = "MergeSavedSearches" + + // Define a function to create a composite key from a BackupSavedSearch + fun searchCompositeKey(search: BackupSavedSearch): String { + return "${search.name}|${search.source}" + } + + // Create maps using the composite key + val localSearchMap = localSearches?.associateBy { searchCompositeKey(it) } ?: emptyMap() + val remoteSearchMap = remoteSearches?.associateBy { searchCompositeKey(it) } ?: emptyMap() + + logcat(LogPriority.DEBUG, logTag) { + "Starting saved searches merge. Local saved searches: ${localSearches?.size}, " + + "Remote saved searches: ${remoteSearches?.size}" + } + + // Merge both saved searches maps + val mergedSearches = (localSearchMap.keys + remoteSearchMap.keys).distinct().mapNotNull { compositeKey -> + val localSearch = localSearchMap[compositeKey] + val remoteSearch = remoteSearchMap[compositeKey] + + logcat(LogPriority.DEBUG, logTag) { + "Processing saved search key: $compositeKey. Local search: ${localSearch != null}, " + + "Remote search: ${remoteSearch != null}" + } + + when { + localSearch != null && remoteSearch == null -> { + logcat(LogPriority.DEBUG, logTag) { "Using local saved search: ${localSearch.name}." } + localSearch + } + remoteSearch != null && localSearch == null -> { + logcat(LogPriority.DEBUG, logTag) { "Using remote saved search: ${remoteSearch.name}." } + remoteSearch + } + else -> { + logcat(LogPriority.DEBUG, logTag) { + "No saved search found for composite key: $compositeKey. Skipping." + } + null + } + } + } + + logcat(LogPriority.DEBUG, logTag) { + "Saved searches merge completed. Total merged saved searches: ${mergedSearches.size}" + } + + return mergedSearches + } + // SY <-- + +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/sync/service/SyncYomiSyncService.kt b/app/src/main/java/eu/kanade/tachiyomi/data/sync/service/SyncYomiSyncService.kt new file mode 100644 index 000000000..2db2cf203 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/sync/service/SyncYomiSyncService.kt @@ -0,0 +1,198 @@ +package eu.kanade.tachiyomi.data.sync.service + +import android.content.Context +import eu.kanade.domain.sync.SyncPreferences +import eu.kanade.tachiyomi.data.sync.SyncNotifier +import eu.kanade.tachiyomi.network.GET +import eu.kanade.tachiyomi.network.PATCH +import eu.kanade.tachiyomi.network.POST +import kotlinx.coroutines.delay +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +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.common.util.system.logcat +import java.util.concurrent.TimeUnit + +class SyncYomiSyncService( + context: Context, + json: Json, + syncPreferences: SyncPreferences, + private val notifier: SyncNotifier, +) : SyncService(context, json, syncPreferences) { + + @Serializable + enum class SyncStatus { + @SerialName("pending") + Pending, + + @SerialName("syncing") + Syncing, + + @SerialName("success") + Success, + } + + @Serializable + data class LockFile( + @SerialName("id") + val id: Int?, + @SerialName("user_api_key") + val userApiKey: String?, + @SerialName("acquired_by") + val acquiredBy: String?, + @SerialName("last_synced") + val lastSynced: String?, + @SerialName("status") + val status: SyncStatus, + @SerialName("acquired_at") + val acquiredAt: String?, + @SerialName("expires_at") + val expiresAt: String?, + ) + + @Serializable + data class LockfileCreateRequest( + @SerialName("acquired_by") + val acquiredBy: String, + ) + + @Serializable + data class LockfilePatchRequest( + @SerialName("user_api_key") + val userApiKey: String, + @SerialName("acquired_by") + val acquiredBy: String, + ) + + override suspend fun beforeSync() { + val host = syncPreferences.clientHost().get() + val apiKey = syncPreferences.clientAPIKey().get() + val lockFileApi = "$host/api/sync/lock" + val deviceId = syncPreferences.uniqueDeviceID() + val client = OkHttpClient() + val headers = Headers.Builder().add("X-API-Token", apiKey).build() + val json = Json { ignoreUnknownKeys = true } + + val createLockfileRequest = LockfileCreateRequest(deviceId) + val createLockfileJson = json.encodeToString(createLockfileRequest) + + val patchRequest = LockfilePatchRequest(apiKey, deviceId) + val patchJson = json.encodeToString(patchRequest) + + val lockFileRequest = GET( + url = lockFileApi, + headers = headers, + ) + + val lockFileCreate = POST( + url = lockFileApi, + headers = headers, + body = createLockfileJson.toRequestBody("application/json; charset=utf-8".toMediaTypeOrNull()), + ) + + val lockFileUpdate = PATCH( + url = lockFileApi, + headers = headers, + body = patchJson.toRequestBody("application/json; charset=utf-8".toMediaTypeOrNull()), + ) + + // create lock file first + client.newCall(lockFileCreate).execute() + // update lock file acquired_by + client.newCall(lockFileUpdate).execute() + + var backoff = 2000L // Start with 2 seconds + val maxBackoff = 32000L // Maximum backoff time e.g., 32 seconds + var lockFile: LockFile + do { + val response = client.newCall(lockFileRequest).execute() + val responseBody = response.body.string() + lockFile = json.decodeFromString(responseBody) + logcat(LogPriority.DEBUG) { "SyncYomi lock file status: ${lockFile.status}" } + + if (lockFile.status != SyncStatus.Success) { + logcat(LogPriority.DEBUG) { "Lock file not ready, retrying in $backoff ms..." } + delay(backoff) + backoff = (backoff * 2).coerceAtMost(maxBackoff) + } + } while (lockFile.status != SyncStatus.Success) + + // update lock file acquired_by + client.newCall(lockFileUpdate).execute() + } + + override suspend fun pullSyncData(): SyncData? { + val host = syncPreferences.clientHost().get() + val apiKey = syncPreferences.clientAPIKey().get() + val downloadUrl = "$host/api/sync/download" + + val client = OkHttpClient() + val headers = Headers.Builder().add("X-API-Token", apiKey).build() + + val downloadRequest = GET( + url = downloadUrl, + headers = headers, + ) + + client.newCall(downloadRequest).execute().use { response -> + val responseBody = response.body.string() + + if (response.isSuccessful) { + return json.decodeFromString(responseBody) + } else { + notifier.showSyncError("Failed to download sync data: $responseBody") + responseBody.let { logcat(LogPriority.ERROR) { "SyncError:$it" } } + return null + } + } + } + + override suspend fun pushSyncData(syncData: SyncData) { + val host = syncPreferences.clientHost().get() + val apiKey = syncPreferences.clientAPIKey().get() + val uploadUrl = "$host/api/sync/upload" + val timeout = 30L + + // Set timeout to 30 seconds + val client = OkHttpClient.Builder() + .connectTimeout(timeout, TimeUnit.SECONDS) + .readTimeout(timeout, TimeUnit.SECONDS) + .writeTimeout(timeout, TimeUnit.SECONDS) + .build() + + val headers = Headers.Builder().add( + "Content-Type", + "application/gzip", + ).add("Content-Encoding", "gzip").add("X-API-Token", apiKey).build() + + val mediaType = "application/gzip".toMediaTypeOrNull() + + val jsonData = json.encodeToString(syncData) + val body = jsonData.toRequestBody(mediaType).gzip() + + val uploadRequest = POST( + url = uploadUrl, + headers = headers, + body = body, + ) + + client.newCall(uploadRequest).execute().use { + if (it.isSuccessful) { + logcat( + LogPriority.DEBUG, + ) { "SyncYomi sync completed!" } + } else { + val responseBody = it.body.string() + notifier.showSyncError("Failed to upload sync data: $responseBody") + responseBody.let { logcat(LogPriority.ERROR) { "SyncError:$it" } } + } + } + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/di/AppModule.kt b/app/src/main/java/eu/kanade/tachiyomi/di/AppModule.kt index 688142689..0e80d4ff1 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/di/AppModule.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/di/AppModule.kt @@ -17,6 +17,7 @@ import eu.kanade.tachiyomi.data.download.DownloadCache import eu.kanade.tachiyomi.data.download.DownloadManager import eu.kanade.tachiyomi.data.download.DownloadProvider import eu.kanade.tachiyomi.data.saver.ImageSaver +import eu.kanade.tachiyomi.data.sync.service.GoogleDriveService import eu.kanade.tachiyomi.data.track.TrackerManager import eu.kanade.tachiyomi.extension.ExtensionManager import eu.kanade.tachiyomi.network.JavaScriptEngine @@ -185,5 +186,7 @@ class AppModule(val app: Application) : InjektModule { get() // SY <-- } + + addSingletonFactory { GoogleDriveService(app) } } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/di/PreferenceModule.kt b/app/src/main/java/eu/kanade/tachiyomi/di/PreferenceModule.kt index b56c16cae..f358f8ff5 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/di/PreferenceModule.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/di/PreferenceModule.kt @@ -3,6 +3,7 @@ package eu.kanade.tachiyomi.di import android.app.Application import eu.kanade.domain.base.BasePreferences import eu.kanade.domain.source.service.SourcePreferences +import eu.kanade.domain.sync.SyncPreferences import eu.kanade.domain.track.service.TrackPreferences import eu.kanade.domain.ui.UiPreferences import eu.kanade.tachiyomi.core.security.SecurityPreferences @@ -66,5 +67,9 @@ class PreferenceModule(val app: Application) : InjektModule { addSingletonFactory { BasePreferences(app, get()) } + + addSingletonFactory { + SyncPreferences(get()) + } } } 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 279304c8d..a668bbad6 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 @@ -42,6 +42,7 @@ import eu.kanade.presentation.util.Tab import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.data.library.LibraryUpdateJob import eu.kanade.tachiyomi.ui.browse.migration.advanced.design.PreMigrationScreen +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 @@ -51,6 +52,7 @@ import eu.kanade.tachiyomi.ui.reader.ReaderActivity import eu.kanade.tachiyomi.util.system.toast import exh.favorites.FavoritesSyncStatus import exh.source.MERGED_SOURCE_ID +import eu.kanade.tachiyomi.util.system.toast import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.collectLatest @@ -162,6 +164,13 @@ object LibraryTab : Tab { } } }, + onClickSyncNow = { + if (!SyncDataJob.isAnyJobRunning(context)) { + SyncDataJob.startNow(context) + } else { + context.toast(MR.strings.sync_in_progress) + } + }, // SY --> onClickSyncExh = screenModel::openFavoritesSyncDialog.takeIf { state.showSyncExh }, // SY <-- diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderViewModel.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderViewModel.kt index 7188bd39f..52ca44179 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderViewModel.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderViewModel.kt @@ -14,6 +14,7 @@ import eu.kanade.domain.chapter.model.toDbChapter import eu.kanade.domain.manga.interactor.SetMangaViewerFlags import eu.kanade.domain.manga.model.readerOrientation import eu.kanade.domain.manga.model.readingMode +import eu.kanade.domain.sync.SyncPreferences import eu.kanade.domain.track.interactor.TrackChapter import eu.kanade.domain.track.service.TrackPreferences import eu.kanade.domain.ui.UiPreferences @@ -24,6 +25,7 @@ import eu.kanade.tachiyomi.data.download.model.Download import eu.kanade.tachiyomi.data.saver.Image import eu.kanade.tachiyomi.data.saver.ImageSaver import eu.kanade.tachiyomi.data.saver.Location +import eu.kanade.tachiyomi.data.sync.SyncDataJob import eu.kanade.tachiyomi.source.model.Page import eu.kanade.tachiyomi.source.online.HttpSource import eu.kanade.tachiyomi.source.online.MetadataSource @@ -125,6 +127,7 @@ class ReaderViewModel @JvmOverloads constructor( private val upsertHistory: UpsertHistory = Injekt.get(), private val updateChapter: UpdateChapter = Injekt.get(), private val setMangaViewerFlags: SetMangaViewerFlags = Injekt.get(), + private val syncPreferences: SyncPreferences = Injekt.get(), // SY --> private val uiPreferences: UiPreferences = Injekt.get(), private val getFlatMetadataById: GetFlatMetadataById = Injekt.get(), @@ -677,6 +680,8 @@ class ReaderViewModel @JvmOverloads constructor( hasExtraPage: Boolean, /* SY <-- */ ) { val pageIndex = page.index + val syncTriggerOpt = syncPreferences.getSyncTriggerOptions() + val isSyncEnabled = syncPreferences.isSyncEnabled() mutableState.update { it.copy(currentPage = pageIndex + 1) @@ -721,6 +726,11 @@ class ReaderViewModel @JvmOverloads constructor( // SY <-- updateTrackChapterRead(readerChapter) deleteChapterIfNeeded(readerChapter) + + // Check if syncing is enabled for chapter read: + if (isSyncEnabled && syncTriggerOpt.syncOnChapterRead) { + SyncDataJob.startNow(Injekt.get()) + } } updateChapter.await( @@ -730,6 +740,11 @@ class ReaderViewModel @JvmOverloads constructor( lastPageRead = readerChapter.chapter.last_page_read.toLong(), ), ) + + // Check if syncing is enabled for chapter open: + if (isSyncEnabled && syncTriggerOpt.syncOnChapterOpen && readerChapter.chapter.last_page_read == 0) { + SyncDataJob.startNow(Injekt.get()) + } } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/track/GoogleDriveLoginActivity.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/track/GoogleDriveLoginActivity.kt new file mode 100644 index 000000000..65ffa5ae2 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/track/GoogleDriveLoginActivity.kt @@ -0,0 +1,54 @@ +package eu.kanade.tachiyomi.ui.setting.track + +import android.net.Uri +import android.widget.Toast +import androidx.lifecycle.lifecycleScope +import eu.kanade.tachiyomi.data.sync.service.GoogleDriveService +import tachiyomi.core.common.i18n.stringResource +import tachiyomi.core.common.util.lang.launchIO +import tachiyomi.i18n.MR +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get + +class GoogleDriveLoginActivity : BaseOAuthLoginActivity() { + private val googleDriveService = Injekt.get() + override fun handleResult(data: Uri?) { + val code = data?.getQueryParameter("code") + val error = data?.getQueryParameter("error") + if (code != null) { + lifecycleScope.launchIO { + googleDriveService.handleAuthorizationCode( + code, + this@GoogleDriveLoginActivity, + onSuccess = { + Toast.makeText( + this@GoogleDriveLoginActivity, + stringResource(MR.strings.google_drive_login_success), + Toast.LENGTH_LONG, + ).show() + + returnToSettings() + }, + onFailure = { error -> + Toast.makeText( + this@GoogleDriveLoginActivity, + stringResource(MR.strings.google_drive_login_failed, error), + Toast.LENGTH_LONG, + ).show() + returnToSettings() + }, + ) + } + } else if (error != null) { + Toast.makeText( + this@GoogleDriveLoginActivity, + stringResource(MR.strings.google_drive_login_failed, error), + Toast.LENGTH_LONG, + ).show() + + returnToSettings() + } else { + returnToSettings() + } + } +} diff --git a/core/common/src/main/kotlin/eu/kanade/tachiyomi/network/Requests.kt b/core/common/src/main/kotlin/eu/kanade/tachiyomi/network/Requests.kt index 6adb0de8e..75886950a 100755 --- a/core/common/src/main/kotlin/eu/kanade/tachiyomi/network/Requests.kt +++ b/core/common/src/main/kotlin/eu/kanade/tachiyomi/network/Requests.kt @@ -63,6 +63,19 @@ fun PUT( .cacheControl(cache) .build() } +fun PATCH( + url: String, + headers: Headers = DEFAULT_HEADERS, + body: RequestBody = DEFAULT_BODY, + cache: CacheControl = DEFAULT_CACHE_CONTROL, +): Request { + return Request.Builder() + .url(url) + .patch(body) + .headers(headers) + .cacheControl(cache) + .build() +} fun DELETE( url: String, diff --git a/core/common/src/main/kotlin/tachiyomi/core/common/util/system/LogcatExtensions.kt b/core/common/src/main/kotlin/tachiyomi/core/common/util/system/LogcatExtensions.kt index 115f647f5..683aa8422 100644 --- a/core/common/src/main/kotlin/tachiyomi/core/common/util/system/LogcatExtensions.kt +++ b/core/common/src/main/kotlin/tachiyomi/core/common/util/system/LogcatExtensions.kt @@ -7,12 +7,22 @@ import logcat.logcat inline fun Any.logcat( priority: LogPriority = LogPriority.DEBUG, throwable: Throwable? = null, + tag: String? = null, message: () -> String = { "" }, ) = logcat(priority = priority) { - var msg = message() - if (throwable != null) { - if (msg.isNotBlank()) msg += "\n" - msg += throwable.asLog() + val logMessage = StringBuilder() + + if (!tag.isNullOrEmpty()) { + logMessage.append("[$tag] ") } - msg + + val msg = message() + logMessage.append(msg) + + if (throwable != null) { + if (msg.isNotBlank()) logMessage.append("\n") + logMessage.append(throwable.asLog()) + } + + logMessage.toString() } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 9d1f10995..ec8557f6a 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -106,6 +106,9 @@ detekt-gradlePlugin = { module = "io.gitlab.arturbosch.detekt:detekt-gradle-plug detekt-rules-formatting = { module = "io.gitlab.arturbosch.detekt:detekt-formatting", version.ref = "detekt" } detekt-rules-compose = { module = "io.nlopez.compose.rules:detekt", version.ref = "detektCompose" } +google-api-services-drive = "com.google.apis:google-api-services-drive:v3-rev197-1.25.0" +google-api-client-oauth = "com.google.oauth-client:google-oauth-client:1.34.1" + [bundles] acra = ["acra-http", "acra-scheduler"] okhttp = ["okhttp-core", "okhttp-logging", "okhttp-brotli", "okhttp-dnsoverhttps"] diff --git a/i18n/src/commonMain/resources/MR/base/strings.xml b/i18n/src/commonMain/resources/MR/base/strings.xml index ba9e40b7b..7bdc7458d 100755 --- a/i18n/src/commonMain/resources/MR/base/strings.xml +++ b/i18n/src/commonMain/resources/MR/base/strings.xml @@ -28,8 +28,10 @@ Updates History Sources - Backup and restore Data and storage + Backup + Sync + Triggers Statistics Migrate Extensions @@ -208,6 +210,7 @@ One-way progress sync, enhanced sync Sources, extensions, global search Manual & automatic backups, storage space + Manual & automatic backups and sync App lock, secure screen Dump crash logs, battery optimizations @@ -262,6 +265,9 @@ Global update Automatic updates Off + Every 30 minutes + Every hour + Every 3 hours Every 6 hours Every 12 hours Daily @@ -545,6 +551,53 @@ Syncing library Library sync complete + Syncing library failed + Syncing library complete + Sync is already in progress + Host + Enter the host address for synchronizing your library + API key + Enter the API key to synchronize your library + Sync Actions + Sync now + Sync confirmation + Initiate immediate synchronization of your data + Syncing will overwrite your local library with the remote library. Are you sure you want to continue? + Service + Select the service to sync your library with + Sync + Automatic Synchronization + Synchronization frequency + Choose what to sync + Last sync timestamp reset + SyncYomi + Done in %1$s + Last Synchronization: %1$s + Google Drive + Sign in + Signed in successfully + Sign in failed + Authentication + Clear Sync Data from Google Drive + Sync data purged from Google Drive + No sync data found in Google Drive + Error purging sync data from Google Drive, Try to sign in again. + Logged in to Google Drive + Failed to log in to Google Drive: %s + Not signed in to Google Drive + Error uploading sync data to Google Drive + Error Deleting Google Drive Lock File + Error before sync: %s + Purge confirmation + Purging sync data will delete all your sync data from Google Drive. Are you sure you want to continue? + Create sync triggers + Can be used to set sync triggers + Sync on Chapter Read + Sync on Chapter Open + Sync on App Start + Sync on App Resume + Sync on Library Update + Sync library Networking diff --git a/i18n/src/commonMain/resources/MR/nb-rNO/plurals.xml b/i18n/src/commonMain/resources/MR/nb-rNO/plurals.xml index 1d7bf5b64..323002f46 100644 --- a/i18n/src/commonMain/resources/MR/nb-rNO/plurals.xml +++ b/i18n/src/commonMain/resources/MR/nb-rNO/plurals.xml @@ -68,4 +68,8 @@ %d pakkebrønn %d pakkebrønner + + %d pakkebr├╕nn + %d pakkebr├╕nner + \ No newline at end of file diff --git a/i18n/src/commonMain/resources/MR/th/plurals.xml b/i18n/src/commonMain/resources/MR/th/plurals.xml index 78a0c5bdc..d6f26cdb7 100644 --- a/i18n/src/commonMain/resources/MR/th/plurals.xml +++ b/i18n/src/commonMain/resources/MR/th/plurals.xml @@ -51,4 +51,7 @@ %d รีโพ + + %d α╕úα╕╡α╣éα╕₧ + \ No newline at end of file diff --git a/i18n/src/commonMain/resources/MR/tr/plurals.xml b/i18n/src/commonMain/resources/MR/tr/plurals.xml index 7e40a5df6..60e2098c0 100644 --- a/i18n/src/commonMain/resources/MR/tr/plurals.xml +++ b/i18n/src/commonMain/resources/MR/tr/plurals.xml @@ -68,4 +68,8 @@ %d depo %d depo + + %d depo + %d depo + \ No newline at end of file