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