diff --git a/app/src/main/java/dev/yokai/data/sync/models/SyncTriggerOptions.kt b/app/src/main/java/dev/yokai/data/sync/models/SyncTriggerOptions.kt new file mode 100644 index 0000000000..ef1ca9fbdc --- /dev/null +++ b/app/src/main/java/dev/yokai/data/sync/models/SyncTriggerOptions.kt @@ -0,0 +1,72 @@ +package dev.yokai.data.sync.models + +import androidx.annotation.StringRes +import eu.kanade.tachiyomi.R +import kotlinx.collections.immutable.persistentListOf + +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 = R.string.sync_on_chapter_read, + getter = SyncTriggerOptions::syncOnChapterRead, + setter = { options, enabled -> options.copy(syncOnChapterRead = enabled) }, + ), + Entry( + label = R.string.sync_on_chapter_open, + getter = SyncTriggerOptions::syncOnChapterOpen, + setter = { options, enabled -> options.copy(syncOnChapterOpen = enabled) }, + ), + Entry( + label = R.string.sync_on_app_start, + getter = SyncTriggerOptions::syncOnAppStart, + setter = { options, enabled -> options.copy(syncOnAppStart = enabled) }, + ), + Entry( + label = R.string.sync_on_app_resume, + getter = SyncTriggerOptions::syncOnAppResume, + setter = { options, enabled -> options.copy(syncOnAppResume = enabled) }, + ), + Entry( + label = R.string.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( + @StringRes val label: Int, + val getter: (SyncTriggerOptions) -> Boolean, + val setter: (SyncTriggerOptions, Boolean) -> SyncTriggerOptions, + val enabled: (SyncTriggerOptions) -> Boolean = { true }, + ) +} diff --git a/app/src/main/java/dev/yokai/domain/sync/SyncPreferences.kt b/app/src/main/java/dev/yokai/domain/sync/SyncPreferences.kt new file mode 100644 index 0000000000..6e30c9b811 --- /dev/null +++ b/app/src/main/java/dev/yokai/domain/sync/SyncPreferences.kt @@ -0,0 +1,92 @@ +package dev.yokai.domain.sync + +import dev.yokai.data.sync.models.SyncTriggerOptions +import dev.yokai.domain.sync.models.SyncSettings +import eu.kanade.tachiyomi.core.preference.Preference.Companion.appStateKey +import eu.kanade.tachiyomi.core.preference.PreferenceStore +import java.util.* + +class SyncPreferences( + private val preferenceStore: PreferenceStore, +) { + fun clientHost() = preferenceStore.getString("sync_client_host", "https://sync.tachiyomi.org") + fun clientAPIKey() = preferenceStore.getString("sync_client_api_key", "") + fun lastSyncTimestamp() = preferenceStore.getLong(appStateKey("last_sync_timestamp"), 0L) + + fun syncInterval() = preferenceStore.getInt("sync_interval", 0) + fun syncService() = preferenceStore.getInt("sync_service", 0) + + fun googleDriveAccessToken() = preferenceStore.getString( + appStateKey("google_drive_access_token"), + "", + ) + + fun googleDriveRefreshToken() = preferenceStore.getString( + appStateKey("google_drive_refresh_token"), + "", + ) + + fun uniqueDeviceID(): String { + val uniqueIDPreference = preferenceStore.getString(appStateKey("unique_device_id"), "") + + // Retrieve the current value of the preference + var uniqueID = uniqueIDPreference.get() + if (uniqueID.isBlank()) { + uniqueID = UUID.randomUUID().toString() + uniqueIDPreference.set(uniqueID) + } + + return uniqueID + } + + fun isSyncEnabled(): Boolean { + return syncService().get() != 0 + } + + fun getSyncSettings(): SyncSettings { + return SyncSettings( + libraryEntries = preferenceStore.getBoolean("library_entries", true).get(), + categories = preferenceStore.getBoolean("categories", true).get(), + chapters = preferenceStore.getBoolean("chapters", true).get(), + tracking = preferenceStore.getBoolean("tracking", true).get(), + history = preferenceStore.getBoolean("history", true).get(), + appSettings = preferenceStore.getBoolean("appSettings", true).get(), + sourceSettings = preferenceStore.getBoolean("sourceSettings", true).get(), + privateSettings = preferenceStore.getBoolean("privateSettings", true).get(), + ) + } + + fun setSyncSettings(syncSettings: SyncSettings) { + preferenceStore.getBoolean("library_entries", true).set(syncSettings.libraryEntries) + preferenceStore.getBoolean("categories", true).set(syncSettings.categories) + preferenceStore.getBoolean("chapters", true).set(syncSettings.chapters) + preferenceStore.getBoolean("tracking", true).set(syncSettings.tracking) + preferenceStore.getBoolean("history", true).set(syncSettings.history) + preferenceStore.getBoolean("appSettings", true).set(syncSettings.appSettings) + preferenceStore.getBoolean("sourceSettings", true).set(syncSettings.sourceSettings) + preferenceStore.getBoolean("privateSettings", true).set(syncSettings.privateSettings) + } + + fun getSyncTriggerOptions(): SyncTriggerOptions { + return SyncTriggerOptions( + syncOnChapterRead = preferenceStore.getBoolean("sync_on_chapter_read", false).get(), + syncOnChapterOpen = preferenceStore.getBoolean("sync_on_chapter_open", false).get(), + syncOnAppStart = preferenceStore.getBoolean("sync_on_app_start", false).get(), + syncOnAppResume = preferenceStore.getBoolean("sync_on_app_resume", false).get(), + syncOnLibraryUpdate = preferenceStore.getBoolean("sync_on_library_update", false).get(), + ) + } + + fun setSyncTriggerOptions(syncTriggerOptions: SyncTriggerOptions) { + preferenceStore.getBoolean("sync_on_chapter_read", false) + .set(syncTriggerOptions.syncOnChapterRead) + preferenceStore.getBoolean("sync_on_chapter_open", false) + .set(syncTriggerOptions.syncOnChapterOpen) + preferenceStore.getBoolean("sync_on_app_start", false) + .set(syncTriggerOptions.syncOnAppStart) + preferenceStore.getBoolean("sync_on_app_resume", false) + .set(syncTriggerOptions.syncOnAppResume) + preferenceStore.getBoolean("sync_on_library_update", false) + .set(syncTriggerOptions.syncOnLibraryUpdate) + } +} diff --git a/app/src/main/java/dev/yokai/domain/sync/models/SyncSettings.kt b/app/src/main/java/dev/yokai/domain/sync/models/SyncSettings.kt new file mode 100644 index 0000000000..94f46e0464 --- /dev/null +++ b/app/src/main/java/dev/yokai/domain/sync/models/SyncSettings.kt @@ -0,0 +1,12 @@ +package dev.yokai.domain.sync.models + +data class SyncSettings( + val libraryEntries: Boolean = true, + val categories: Boolean = true, + val chapters: Boolean = true, + val tracking: Boolean = true, + val history: Boolean = true, + val appSettings: Boolean = true, + val sourceSettings: Boolean = true, + val privateSettings: Boolean = false, +) diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/database/mappers/MangaTypeMapping.kt b/app/src/main/java/eu/kanade/tachiyomi/data/database/mappers/MangaTypeMapping.kt index aeb2b9fc5e..6c6ffab6f4 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/database/mappers/MangaTypeMapping.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/database/mappers/MangaTypeMapping.kt @@ -22,6 +22,7 @@ import eu.kanade.tachiyomi.data.database.tables.MangaTable.COL_GENRE import eu.kanade.tachiyomi.data.database.tables.MangaTable.COL_HIDE_TITLE import eu.kanade.tachiyomi.data.database.tables.MangaTable.COL_ID import eu.kanade.tachiyomi.data.database.tables.MangaTable.COL_INITIALIZED +import eu.kanade.tachiyomi.data.database.tables.MangaTable.COL_IS_SYNCING import eu.kanade.tachiyomi.data.database.tables.MangaTable.COL_LAST_UPDATE import eu.kanade.tachiyomi.data.database.tables.MangaTable.COL_SOURCE import eu.kanade.tachiyomi.data.database.tables.MangaTable.COL_STATUS @@ -29,9 +30,11 @@ import eu.kanade.tachiyomi.data.database.tables.MangaTable.COL_THUMBNAIL_URL import eu.kanade.tachiyomi.data.database.tables.MangaTable.COL_TITLE import eu.kanade.tachiyomi.data.database.tables.MangaTable.COL_UPDATE_STRATEGY import eu.kanade.tachiyomi.data.database.tables.MangaTable.COL_URL +import eu.kanade.tachiyomi.data.database.tables.MangaTable.COL_VERSION import eu.kanade.tachiyomi.data.database.tables.MangaTable.COL_VIEWER import eu.kanade.tachiyomi.data.database.tables.MangaTable.TABLE import eu.kanade.tachiyomi.data.database.updateStrategyAdapter +import eu.kanade.tachiyomi.util.system.toBoolean class MangaTypeMapping : SQLiteTypeMapping( MangaPutResolver(), @@ -71,6 +74,8 @@ class MangaPutResolver : DefaultPutResolver() { put(COL_DATE_ADDED, obj.date_added) put(COL_FILTERED_SCANLATORS, obj.filtered_scanlators) put(COL_UPDATE_STRATEGY, obj.update_strategy.let(updateStrategyAdapter::encode)) + put(COL_VERSION, obj.version) + put(COL_IS_SYNCING, obj.isSyncing) } } @@ -86,17 +91,19 @@ interface BaseMangaGetResolver { title = cursor.getString(cursor.getColumnIndex(COL_TITLE)) status = cursor.getInt(cursor.getColumnIndex(COL_STATUS)) thumbnail_url = cursor.getString(cursor.getColumnIndex(COL_THUMBNAIL_URL)) - favorite = cursor.getInt(cursor.getColumnIndex(COL_FAVORITE)) == 1 + favorite = cursor.getInt(cursor.getColumnIndex(COL_FAVORITE)).toBoolean() last_update = cursor.getLong(cursor.getColumnIndex(COL_LAST_UPDATE)) - initialized = cursor.getInt(cursor.getColumnIndex(COL_INITIALIZED)) == 1 + initialized = cursor.getInt(cursor.getColumnIndex(COL_INITIALIZED)).toBoolean() viewer_flags = cursor.getInt(cursor.getColumnIndex(COL_VIEWER)) chapter_flags = cursor.getInt(cursor.getColumnIndex(COL_CHAPTER_FLAGS)) - hide_title = cursor.getInt(cursor.getColumnIndex(COL_HIDE_TITLE)) == 1 + hide_title = cursor.getInt(cursor.getColumnIndex(COL_HIDE_TITLE)).toBoolean() date_added = cursor.getLong(cursor.getColumnIndex(COL_DATE_ADDED)) filtered_scanlators = cursor.getString(cursor.getColumnIndex(COL_FILTERED_SCANLATORS)) update_strategy = cursor.getInt(cursor.getColumnIndex(COL_UPDATE_STRATEGY)).let( updateStrategyAdapter::decode, ) + version = cursor.getInt(cursor.getColumnIndex(COL_VERSION)) + isSyncing = cursor.getInt(cursor.getColumnIndex(COL_IS_SYNCING)).toBoolean() } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/database/models/Manga.kt b/app/src/main/java/eu/kanade/tachiyomi/data/database/models/Manga.kt index 4c63956f70..8e8b68f0d8 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/database/models/Manga.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/database/models/Manga.kt @@ -10,9 +10,10 @@ import eu.kanade.tachiyomi.source.model.SManga import eu.kanade.tachiyomi.ui.reader.settings.OrientationType import eu.kanade.tachiyomi.ui.reader.settings.ReadingModeType import eu.kanade.tachiyomi.util.manga.MangaCoverMetadata +import eu.kanade.tachiyomi.util.system.toBoolean import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.api.get -import java.util.Locale +import java.util.* interface Manga : SManga { @@ -34,6 +35,10 @@ interface Manga : SManga { var filtered_scanlators: String? + var version: Int + + var isSyncing: Boolean + fun isBlank() = id == Long.MIN_VALUE fun isHidden() = status == -1 @@ -359,7 +364,9 @@ interface Manga : SManga { chapterFlags: Long, dateAdded: Long?, filteredScanlators: String?, - updateStrategy: Long + updateStrategy: Long, + version: Long, + isSyncing: Long, ): Manga = create(source).apply { this.id = id this.url = url @@ -370,15 +377,17 @@ interface Manga : SManga { this.title = title this.status = status.toInt() this.thumbnail_url = thumbnailUrl - this.favorite = favorite > 0 + this.favorite = favorite.toBoolean() this.last_update = lastUpdate ?: 0L this.initialized = initialized this.viewer_flags = viewerFlags.toInt() this.chapter_flags = chapterFlags.toInt() - this.hide_title = hideTitle > 0 + this.hide_title = hideTitle.toBoolean() this.date_added = dateAdded ?: 0L this.filtered_scanlators = filteredScanlators this.update_strategy = updateStrategy.toInt().let(updateStrategyAdapter::decode) + this.version = version.toInt() + this.isSyncing = isSyncing.toBoolean() } } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/database/tables/MangaTable.kt b/app/src/main/java/eu/kanade/tachiyomi/data/database/tables/MangaTable.kt index 3c045def9f..2b52605fc0 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/database/tables/MangaTable.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/database/tables/MangaTable.kt @@ -42,4 +42,7 @@ object MangaTable { const val COL_UPDATE_STRATEGY = "update_strategy" + const val COL_VERSION = "version" + + const val COL_IS_SYNCING = "is_syncing" } diff --git a/app/src/main/java/eu/kanade/tachiyomi/network/Requests.kt b/app/src/main/java/eu/kanade/tachiyomi/network/Requests.kt index 6adb0de8ef..0bc128fcec 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/network/Requests.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/network/Requests.kt @@ -1,3 +1,5 @@ +@file:Suppress("FunctionName", "unused") + package eu.kanade.tachiyomi.network import okhttp3.CacheControl @@ -7,7 +9,7 @@ import okhttp3.HttpUrl import okhttp3.HttpUrl.Companion.toHttpUrl import okhttp3.Request import okhttp3.RequestBody -import java.util.concurrent.TimeUnit.MINUTES +import java.util.concurrent.TimeUnit.* private val DEFAULT_CACHE_CONTROL = CacheControl.Builder().maxAge(10, MINUTES).build() private val DEFAULT_HEADERS = Headers.Builder().build() @@ -64,6 +66,20 @@ fun PUT( .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, headers: Headers = DEFAULT_HEADERS, diff --git a/app/src/main/java/eu/kanade/tachiyomi/util/system/BooleanExtensions.kt b/app/src/main/java/eu/kanade/tachiyomi/util/system/BooleanExtensions.kt index ae01fa9685..f9cec049bb 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/util/system/BooleanExtensions.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/util/system/BooleanExtensions.kt @@ -1,4 +1,4 @@ package eu.kanade.tachiyomi.util.system fun Boolean.toInt() = if (this) 1 else 0 -fun Int.toBoolean() = this == 1 +fun Number.toBoolean() = this == 1 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index d8e2738aa6..7a59c3d9ec 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -847,6 +847,60 @@ Not logged into %1$s Include sensitive settings (e.g. tracker login tokens) + Sync + Triggers + Manual & automatic backups and sync + + + 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 Clear chapter cache @@ -1097,6 +1151,9 @@ Manual + Every 30 minutes + Every hour + Every 3 hours Every 6 hours Every 12 hours Daily diff --git a/app/src/main/sqldelight/tachiyomi/data/mangas.sq b/app/src/main/sqldelight/tachiyomi/data/mangas.sq index b9473301be..a345255410 100644 --- a/app/src/main/sqldelight/tachiyomi/data/mangas.sq +++ b/app/src/main/sqldelight/tachiyomi/data/mangas.sq @@ -20,12 +20,29 @@ CREATE TABLE mangas( chapter_flags INTEGER NOT NULL, date_added INTEGER AS Long, filtered_scanlators TEXT, - update_strategy INTEGER NOT NULL DEFAULT 0 + update_strategy INTEGER NOT NULL DEFAULT 0, + version INTEGER NOT NULL DEFAULT 0, + is_syncing INTEGER NOT NULL DEFAULT 0 ); CREATE INDEX mangas_url_index ON mangas(url); CREATE INDEX library_favorite_index ON mangas(favorite) WHERE favorite = 1; +CREATE TRIGGER update_manga_version AFTER UPDATE ON mangas +BEGIN + UPDATE mangas SET version = version + 1 + WHERE _id = new._id AND new.is_syncing = 0 AND ( + new.url != old.url OR + new.description != old.description OR + new.favorite != old.favorite + ); +END; + findAll: SELECT * FROM mangas; + +resetIsSyncing: +UPDATE mangas +SET is_syncing = 0 +WHERE is_syncing = 1;