diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 200855fea386..b0742a3fc696 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 072585f59a30..f4445d017b95 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 c6a13b2b0746..fbc658c12c46 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 b6b0e9fc2b39..e17f06a550fb 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 a6f9955a0971..9e51db03839f 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 000000000000..86865113f0f9 --- /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 000000000000..27136ecebf7e --- /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 0e70af8326a9..e54f28457513 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 73ccbd3dca61..182f840fb792 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 a84e6af90430..d58213aac7b8 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 855a59a4e338..0c9ba8524710 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 - } - - // 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, - ) + when { + dbChapter == null -> chapter // New chapter + chapter.forComparison() == dbChapter.forComparison() -> null // Same state; skip + else -> updateChapterBasedOnSyncState(chapter, dbChapter) } - 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 57625bc22b7f..52dd8c3a0324 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 df4ca6c97ddd..cd490814135b 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 000000000000..e1f949029a01 --- /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 000000000000..dba8b4af5e2a --- /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 000000000000..2321240a669c --- /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 000000000000..b5dfaf001047 --- /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 000000000000..7beb6d5125de --- /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 000000000000..cf61869fabbb --- /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 000000000000..2db2cf203094 --- /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 688142689799..0e80d4ff1a74 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 b56c16cae3f6..f358f8ff587f 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 279304c8dca0..a668bbad6654 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 7188bd39fd04..52ca44179546 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 000000000000..65ffa5ae253f --- /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 6adb0de8ef3d..75886950af28 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 115f647f50d2..683aa8422416 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() + val logMessage = StringBuilder() + + if (!tag.isNullOrEmpty()) { + logMessage.append("[$tag] ") + } + + val msg = message() + logMessage.append(msg) + if (throwable != null) { - if (msg.isNotBlank()) msg += "\n" - msg += throwable.asLog() + if (msg.isNotBlank()) logMessage.append("\n") + logMessage.append(throwable.asLog()) } - msg + + logMessage.toString() } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 9d1f10995332..ec8557f6a459 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 ba9e40b7b31a..7bdc7458da5d 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 1d7bf5b64709..323002f460ca 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 78a0c5bdc3ad..d6f26cdb7b5d 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 7e40a5df65fd..60e2098c0174 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