From 178b00280b854144ad35cd344fb91f1fea983752 Mon Sep 17 00:00:00 2001 From: KaiserBh Date: Wed, 20 Dec 2023 22:12:46 +1100 Subject: [PATCH] feat: added triggers for syncing. Experimental for now, but works fine so far, I don't know about google api limit but I think it's pretty generous. Signed-off-by: KaiserBh --- .../settings/screen/SettingsDataScreen.kt | 12 ++ .../settings/screen/data/SyncOptionsScreen.kt | 125 ++++++++++++++++++ app/src/main/java/eu/kanade/tachiyomi/App.kt | 9 ++ .../tachiyomi/ui/reader/ReaderViewModel.kt | 16 +++ .../tachiyomi/domain/sync/SyncPreferences.kt | 14 ++ .../commonMain/resources/MR/base/strings.xml | 6 +- 6 files changed, 181 insertions(+), 1 deletion(-) create mode 100644 app/src/main/java/eu/kanade/presentation/more/settings/screen/data/SyncOptionsScreen.kt 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 077c68ee6..5d382d3f1 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 @@ -34,6 +34,7 @@ import cafe.adriel.voyager.navigator.currentOrThrow import com.hippo.unifile.UniFile import eu.kanade.presentation.more.settings.Preference import eu.kanade.presentation.more.settings.screen.data.CreateBackupScreen +import eu.kanade.presentation.more.settings.screen.data.SyncOptionsScreen import eu.kanade.presentation.more.settings.widget.BasePreferenceWidget import eu.kanade.presentation.more.settings.widget.PrefsHorizontalPadding import eu.kanade.presentation.util.relativeTimeSpanString @@ -559,6 +560,7 @@ private fun getSyncNowPref(): Preference.PreferenceGroup { return Preference.PreferenceGroup( title = stringResource(MR.strings.pref_sync_now_group_title), preferenceItems = listOf( + getSyncOptionsPref(), Preference.PreferenceItem.TextPreference( title = stringResource(MR.strings.pref_sync_now), subtitle = stringResource(MR.strings.pref_sync_now_subtitle), @@ -570,6 +572,16 @@ private fun getSyncNowPref(): Preference.PreferenceGroup { ) } +@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(SyncOptionsScreen()) }, + ) +} + @Composable private fun getAutomaticSyncGroup(syncPreferences: SyncPreferences): Preference.PreferenceGroup { val context = LocalContext.current diff --git a/app/src/main/java/eu/kanade/presentation/more/settings/screen/data/SyncOptionsScreen.kt b/app/src/main/java/eu/kanade/presentation/more/settings/screen/data/SyncOptionsScreen.kt new file mode 100644 index 000000000..27c1255af --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/more/settings/screen/data/SyncOptionsScreen.kt @@ -0,0 +1,125 @@ +package eu.kanade.presentation.more.settings.screen.data + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.collectAsState +import androidx.compose.ui.Modifier +import cafe.adriel.voyager.core.model.StateScreenModel +import cafe.adriel.voyager.core.model.rememberScreenModel +import cafe.adriel.voyager.navigator.LocalNavigator +import cafe.adriel.voyager.navigator.currentOrThrow +import eu.kanade.presentation.components.AppBar +import eu.kanade.presentation.util.Screen +import kotlinx.collections.immutable.PersistentSet +import kotlinx.collections.immutable.minus +import kotlinx.collections.immutable.plus +import kotlinx.collections.immutable.toPersistentSet +import kotlinx.coroutines.flow.update +import tachiyomi.i18n.MR +import tachiyomi.presentation.core.components.LabeledCheckbox +import tachiyomi.presentation.core.components.material.Scaffold +import tachiyomi.presentation.core.components.material.padding +import tachiyomi.presentation.core.i18n.stringResource +import androidx.compose.runtime.getValue +import tachiyomi.domain.sync.SyncPreferences +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get + +class SyncOptionsScreen : 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 -> + Column( + modifier = Modifier + .padding(contentPadding) + .fillMaxSize(), + ) { + LazyColumn( + modifier = Modifier + .weight(1f) + .padding(horizontal = MaterialTheme.padding.medium), + ) { + SyncChoices.forEach { (k, v) -> + item { + LabeledCheckbox( + label = stringResource(v), + checked = state.flags.contains(k), + onCheckedChange = { + model.toggleOptionFlag(k) + }, + ) + } + } + } + + HorizontalDivider() + } + } + } +} + +private class SyncOptionsScreenModel : StateScreenModel(State()) { + private val syncPreferences = Injekt.get() + + init { + loadInitialFlags() + } + + private fun loadInitialFlags() { + val savedFlags = syncPreferences.syncFlags().get() + val flagSet = SyncPreferences.Flags.values().filter { flag -> + savedFlags and flag > 0 + }.toSet().toPersistentSet() + + mutableState.update { State(flags = flagSet) } + } + + fun toggleOptionFlag(option: Int) { + mutableState.update { currentState -> + val newFlags = if (currentState.flags.contains(option)) { + currentState.flags - option + } else { + currentState.flags + option + } + saveFlags(newFlags) + currentState.copy(flags = newFlags) + } + } + + private fun saveFlags(flags: PersistentSet) { + val flagsInt = flags.fold(0) { acc, flag -> acc or flag } + syncPreferences.syncFlags().set(flagsInt) + } + + + @Immutable + data class State( + val flags: PersistentSet = SyncChoices.keys.toPersistentSet(), + ) +} + +private val SyncChoices = mapOf( + SyncPreferences.Flags.SYNC_ON_CHAPTER_READ to MR.strings.sync_on_chapter_read, + SyncPreferences.Flags.SYNC_ON_CHAPTER_OPEN to MR.strings.sync_on_chapter_open, + SyncPreferences.Flags.SYNC_ON_APP_START to MR.strings.sync_on_app_start, +) + diff --git a/app/src/main/java/eu/kanade/tachiyomi/App.kt b/app/src/main/java/eu/kanade/tachiyomi/App.kt index 27235ba64..59072b5b6 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/App.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/App.kt @@ -32,6 +32,7 @@ import eu.kanade.tachiyomi.data.coil.MangaCoverKeyer import eu.kanade.tachiyomi.data.coil.MangaKeyer 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.network.NetworkHelper @@ -56,6 +57,7 @@ import org.acra.sender.HttpSender import org.conscrypt.Conscrypt import tachiyomi.core.i18n.stringResource import tachiyomi.core.util.system.logcat +import tachiyomi.domain.sync.SyncPreferences import tachiyomi.i18n.MR import tachiyomi.presentation.widget.WidgetManager import uy.kohesive.injekt.Injekt @@ -168,6 +170,13 @@ class App : Application(), DefaultLifecycleObserver, ImageLoaderFactory { override fun onStart(owner: LifecycleOwner) { SecureActivityDelegate.onApplicationStart() + + val syncPreferences: SyncPreferences by injectLazy() + val syncFlags = syncPreferences.syncFlags().get() + if (syncPreferences.syncService().get() != 0 && syncFlags and SyncPreferences.Flags.SYNC_ON_APP_START == SyncPreferences.Flags.SYNC_ON_APP_START) { + SyncDataJob.startNow(this@App) + } + } override fun onStop(owner: LifecycleOwner) { 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 37116eeef..1f4abd66c 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 @@ -21,6 +21,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.ui.reader.loader.ChapterLoader @@ -71,6 +72,7 @@ import tachiyomi.domain.history.model.HistoryUpdate import tachiyomi.domain.manga.interactor.GetManga import tachiyomi.domain.manga.model.Manga import tachiyomi.domain.source.service.SourceManager +import tachiyomi.domain.sync.SyncPreferences import tachiyomi.source.local.isLocal import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.api.get @@ -98,6 +100,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() ) : ViewModel() { private val mutableState = MutableStateFlow(State()) @@ -513,6 +516,7 @@ class ReaderViewModel @JvmOverloads constructor( */ private suspend fun updateChapterProgress(readerChapter: ReaderChapter, page: Page) { val pageIndex = page.index + val syncFlags = syncPreferences.syncFlags().get() mutableState.update { it.copy(currentPage = pageIndex + 1) @@ -527,6 +531,12 @@ class ReaderViewModel @JvmOverloads constructor( readerChapter.chapter.read = true updateTrackChapterRead(readerChapter) deleteChapterIfNeeded(readerChapter) + + // Check if syncing is enabled for chapter read: + if (syncPreferences.syncService().get() != 0 && + syncFlags and SyncPreferences.Flags.SYNC_ON_CHAPTER_READ == SyncPreferences.Flags.SYNC_ON_CHAPTER_READ) { + SyncDataJob.startNow(Injekt.get()) + } } updateChapter.await( @@ -536,6 +546,12 @@ class ReaderViewModel @JvmOverloads constructor( lastPageRead = readerChapter.chapter.last_page_read.toLong(), ), ) + + // Check if syncing is enabled for chapter open: + if (syncPreferences.syncService().get() != 0 && + syncFlags and SyncPreferences.Flags.SYNC_ON_CHAPTER_OPEN == SyncPreferences.Flags.SYNC_ON_CHAPTER_OPEN) { + SyncDataJob.startNow(Injekt.get()) + } } } diff --git a/domain/src/main/java/tachiyomi/domain/sync/SyncPreferences.kt b/domain/src/main/java/tachiyomi/domain/sync/SyncPreferences.kt index 5f2b6ab98..630d82622 100644 --- a/domain/src/main/java/tachiyomi/domain/sync/SyncPreferences.kt +++ b/domain/src/main/java/tachiyomi/domain/sync/SyncPreferences.kt @@ -6,6 +6,18 @@ import tachiyomi.core.preference.PreferenceStore class SyncPreferences( private val preferenceStore: PreferenceStore, ) { + object Flags { + const val NONE = 0x0 + const val SYNC_ON_CHAPTER_READ = 0x1 + const val SYNC_ON_CHAPTER_OPEN = 0x2 + const val SYNC_ON_APP_START = 0x4 + + const val Defaults = NONE + + fun values() = listOf(NONE, SYNC_ON_CHAPTER_READ, SYNC_ON_CHAPTER_OPEN, SYNC_ON_APP_START) + } + + fun syncHost() = preferenceStore.getString("sync_host", "https://sync.tachiyomi.org") fun syncAPIKey() = preferenceStore.getString("sync_api_key", "") fun lastSyncTimestamp() = preferenceStore.getLong(Preference.appStateKey("last_sync_timestamp"), 0L) @@ -22,4 +34,6 @@ class SyncPreferences( Preference.appStateKey("google_drive_refresh_token"), "", ) + + fun syncFlags() = preferenceStore.getInt("sync_flags", Flags.Defaults) } diff --git a/i18n/src/commonMain/resources/MR/base/strings.xml b/i18n/src/commonMain/resources/MR/base/strings.xml index 3bfd511e8..7f0c7e36e 100644 --- a/i18n/src/commonMain/resources/MR/base/strings.xml +++ b/i18n/src/commonMain/resources/MR/base/strings.xml @@ -566,7 +566,11 @@ Not signed in to Google Drive 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 + On chapter read + On every open chapter page (EXPERIMENTAL NOT RECOMMENDED, everytime you go to next page it or previous it will sync.) + On app start Networking