From 306befb214c497c4ebd162d3d613fc0528a0c72d Mon Sep 17 00:00:00 2001 From: AntsyLich <59261191+AntsyLich@users.noreply.github.com> Date: Sun, 27 Sep 2026 22:30:36 +0600 Subject: [PATCH] Restrict raw database access to the data module app no longer touches the database directly. Backup restore's queries move into a new RestoreRepository in :data, which restores a batch of entries all or nothing, stopping between entries if cancelled, as before. The remaining direct calls (excluded scanlators, clear database, backup creation, store and category restore) go through their repositories, and the database bindings move from AppBindings into :data. --- app/build.gradle.kts | 4 - .../manga/interactor/GetExcludedScanlators.kt | 17 +- .../manga/interactor/SetExcludedScanlators.kt | 18 +- .../screen/advanced/ClearDatabaseScreen.kt | 11 +- .../create/creators/MangaBackupCreator.kt | 42 +- .../data/backup/models/BackupChapter.kt | 48 +-- .../data/backup/models/BackupManga.kt | 4 +- .../data/backup/models/BackupTracking.kt | 47 +- .../data/backup/restore/BackupRestorer.kt | 30 +- .../restore/restorers/CategoriesRestorer.kt | 21 +- .../restorers/ExtensionStoreRestorer.kt | 27 +- .../backup/restore/restorers/MangaRestorer.kt | 404 ++---------------- app/src/main/java/mihon/app/di/AppBindings.kt | 47 -- .../core/common/extensions/JsonObject.kt | 5 + data/build.gradle.kts | 3 +- .../ExtensionStoreRepositoryImpl.kt | 2 +- .../java/tachiyomi/data/DatabaseAdapter.kt | 12 +- .../java/tachiyomi/data/DatabaseBindings.kt | 51 +++ .../data/backup/RestoreRepositoryImpl.kt | 298 +++++++++++++ .../data/category/CategoryRepositoryImpl.kt | 6 + .../data/history/HistoryRepositoryImpl.kt | 4 + .../data/manga/MangaRepositoryImpl.kt | 33 ++ domain/build.gradle.kts | 2 +- .../repository/ExtensionStoreRepository.kt | 2 + .../domain/backup/model/RestoredManga.kt | 21 + .../backup/repository/RestoreRepository.kt | 16 + .../category/repository/CategoryRepository.kt | 2 + .../history/repository/HistoryRepository.kt | 2 + .../manga/repository/MangaRepository.kt | 8 + 29 files changed, 580 insertions(+), 607 deletions(-) create mode 100644 data/src/main/java/tachiyomi/data/DatabaseBindings.kt create mode 100644 data/src/main/java/tachiyomi/data/backup/RestoreRepositoryImpl.kt create mode 100644 domain/src/main/java/tachiyomi/domain/backup/model/RestoredManga.kt create mode 100644 domain/src/main/java/tachiyomi/domain/backup/repository/RestoreRepository.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index e5ff32b61..202aca121 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -233,14 +233,10 @@ dependencies { implementation(libs.androidx.paging.runtime) implementation(libs.androidx.paging.compose) - implementation(libs.androidx.sqlite.bundled) - implementation(libs.kotlin.reflect) implementation(libs.bundles.kotlinx.coroutines) - implementation(libs.sqldelight.async) - implementation(libs.kotlinx.datetime) // AndroidX libraries diff --git a/app/src/main/java/eu/kanade/domain/manga/interactor/GetExcludedScanlators.kt b/app/src/main/java/eu/kanade/domain/manga/interactor/GetExcludedScanlators.kt index f6dd5c758..f8bd66e1a 100644 --- a/app/src/main/java/eu/kanade/domain/manga/interactor/GetExcludedScanlators.kt +++ b/app/src/main/java/eu/kanade/domain/manga/interactor/GetExcludedScanlators.kt @@ -1,28 +1,19 @@ package eu.kanade.domain.manga.interactor -import app.cash.sqldelight.async.coroutines.awaitAsList import dev.zacsweers.metro.Inject import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.map -import tachiyomi.data.Database -import tachiyomi.data.subscribeToList +import tachiyomi.domain.manga.repository.MangaRepository @Inject class GetExcludedScanlators( - private val database: Database, + private val mangaRepository: MangaRepository, ) { suspend fun await(mangaId: Long): Set { - return database.excluded_scanlatorsQueries - .getExcludedScanlatorsByMangaId(mangaId) - .awaitAsList() - .toSet() + return mangaRepository.getExcludedScanlators(mangaId) } fun subscribe(mangaId: Long): Flow> { - return database.excluded_scanlatorsQueries - .getExcludedScanlatorsByMangaId(mangaId) - .subscribeToList() - .map { it.toSet() } + return mangaRepository.getExcludedScanlatorsAsFlow(mangaId) } } diff --git a/app/src/main/java/eu/kanade/domain/manga/interactor/SetExcludedScanlators.kt b/app/src/main/java/eu/kanade/domain/manga/interactor/SetExcludedScanlators.kt index 37b2778f2..d11a95bb2 100644 --- a/app/src/main/java/eu/kanade/domain/manga/interactor/SetExcludedScanlators.kt +++ b/app/src/main/java/eu/kanade/domain/manga/interactor/SetExcludedScanlators.kt @@ -1,26 +1,14 @@ package eu.kanade.domain.manga.interactor -import app.cash.sqldelight.async.coroutines.awaitAsList import dev.zacsweers.metro.Inject -import tachiyomi.data.Database +import tachiyomi.domain.manga.repository.MangaRepository @Inject class SetExcludedScanlators( - private val database: Database, + private val mangaRepository: MangaRepository, ) { suspend fun await(mangaId: Long, excludedScanlators: Set) { - database.transaction { - val currentExcluded = database.excluded_scanlatorsQueries - .getExcludedScanlatorsByMangaId(mangaId) - .awaitAsList() - .toSet() - val toAdd = excludedScanlators.minus(currentExcluded) - for (scanlator in toAdd) { - database.excluded_scanlatorsQueries.insert(mangaId, scanlator) - } - val toRemove = currentExcluded.minus(excludedScanlators) - database.excluded_scanlatorsQueries.remove(mangaId, toRemove) - } + mangaRepository.setExcludedScanlators(mangaId, excludedScanlators) } } diff --git a/app/src/main/java/eu/kanade/presentation/more/settings/screen/advanced/ClearDatabaseScreen.kt b/app/src/main/java/eu/kanade/presentation/more/settings/screen/advanced/ClearDatabaseScreen.kt index e715f2991..3c15bc17e 100644 --- a/app/src/main/java/eu/kanade/presentation/more/settings/screen/advanced/ClearDatabaseScreen.kt +++ b/app/src/main/java/eu/kanade/presentation/more/settings/screen/advanced/ClearDatabaseScreen.kt @@ -51,9 +51,9 @@ import mihon.icons.materialsymbols.rounded.FlipToBack import mihon.icons.materialsymbols.rounded.SelectAll import tachiyomi.core.common.util.lang.launchIO import tachiyomi.core.common.util.lang.launchUI -import tachiyomi.core.common.util.lang.toLong import tachiyomi.core.common.util.lang.withNonCancellableContext -import tachiyomi.data.Database +import tachiyomi.domain.history.repository.HistoryRepository +import tachiyomi.domain.manga.repository.MangaRepository import tachiyomi.domain.source.interactor.GetSourcesWithNonLibraryManga import tachiyomi.domain.source.model.Source import tachiyomi.domain.source.model.SourceWithCount @@ -229,7 +229,8 @@ class ClearDatabaseScreen : Screen() { @ViewModelKey @ContributesIntoMap(AppScope::class, binding = binding()) class ClearDatabaseViewModel( - private val database: Database, + private val mangaRepository: MangaRepository, + private val historyRepository: HistoryRepository, private val getSourcesWithNonLibraryManga: GetSourcesWithNonLibraryManga, ) : ViewModel() { @@ -253,8 +254,8 @@ class ClearDatabaseViewModel( suspend fun removeMangaBySourceId(keepReadManga: Boolean) = withNonCancellableContext { val state = state.value as? State.Ready ?: return@withNonCancellableContext - database.mangasQueries.deleteNonLibraryManga(state.selection, keepReadManga.toLong()) - database.historyQueries.removeResettedHistory() + mangaRepository.deleteNonLibraryManga(state.selection, keepReadManga) + historyRepository.deleteResetHistory() } fun toggleSelection(source: Source) = state.update { state -> diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/create/creators/MangaBackupCreator.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/create/creators/MangaBackupCreator.kt index c8062a812..a01e92123 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/backup/create/creators/MangaBackupCreator.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/backup/create/creators/MangaBackupCreator.kt @@ -1,24 +1,25 @@ package eu.kanade.tachiyomi.data.backup.create.creators -import app.cash.sqldelight.async.coroutines.awaitAsList -import app.cash.sqldelight.async.coroutines.awaitAsOne import dev.zacsweers.metro.Inject import eu.kanade.tachiyomi.data.backup.create.BackupOptions -import eu.kanade.tachiyomi.data.backup.models.BackupChapter import eu.kanade.tachiyomi.data.backup.models.BackupHistory import eu.kanade.tachiyomi.data.backup.models.BackupManga -import eu.kanade.tachiyomi.data.backup.models.backupChapterMapper -import eu.kanade.tachiyomi.data.backup.models.backupTrackMapper +import eu.kanade.tachiyomi.data.backup.models.toBackupChapter +import eu.kanade.tachiyomi.data.backup.models.toBackupTracking import eu.kanade.tachiyomi.ui.reader.setting.ReadingMode -import tachiyomi.data.Database -import tachiyomi.data.MemoColumnAdapter +import mihon.core.common.extensions.toByteArray import tachiyomi.domain.category.interactor.GetCategories +import tachiyomi.domain.chapter.repository.ChapterRepository import tachiyomi.domain.history.interactor.GetHistory import tachiyomi.domain.manga.model.Manga +import tachiyomi.domain.manga.repository.MangaRepository +import tachiyomi.domain.track.repository.TrackRepository @Inject class MangaBackupCreator( - private val database: Database, + private val mangaRepository: MangaRepository, + private val chapterRepository: ChapterRepository, + private val trackRepository: TrackRepository, private val getCategories: GetCategories, private val getHistory: GetHistory, ) { @@ -33,20 +34,13 @@ class MangaBackupCreator( // Entry for this manga val mangaObject = manga.toBackupManga() - mangaObject.excludedScanlators = database.excluded_scanlatorsQueries - .getExcludedScanlatorsByMangaId(manga.id) - .awaitAsList() + mangaObject.excludedScanlators = mangaRepository.getExcludedScanlators(manga.id).toList() if (options.chapters) { // Backup all the chapters - database.chaptersQueries - .getChaptersByMangaId( - mangaId = manga.id, - applyScanlatorFilter = 0, // false - mapper = backupChapterMapper, - ) - .awaitAsList() - .takeUnless(List::isEmpty) + chapterRepository.getChapterByMangaId(manga.id, applyScanlatorFilter = false) + .map { it.toBackupChapter() } + .takeUnless { it.isEmpty() } ?.let { mangaObject.chapters = it } } @@ -59,9 +53,7 @@ class MangaBackupCreator( } if (options.tracking) { - val tracks = database.manga_syncQueries - .getTracksByMangaId(manga.id, backupTrackMapper) - .awaitAsList() + val tracks = trackRepository.getTracksByMangaId(manga.id).map { it.toBackupTracking() } if (tracks.isNotEmpty()) { mangaObject.tracking = tracks } @@ -71,9 +63,7 @@ class MangaBackupCreator( val historyByMangaId = getHistory.await(manga.id) if (historyByMangaId.isNotEmpty()) { val history = historyByMangaId.map { history -> - val chapter = database.chaptersQueries - .getChapterById(history.chapterId) - .awaitAsOne() + val chapter = checkNotNull(chapterRepository.getChapterById(history.chapterId)) BackupHistory(chapter.url, history.readAt?.time ?: 0L, history.readDuration) } if (history.isNotEmpty()) { @@ -106,5 +96,5 @@ private fun Manga.toBackupManga() = favoriteModifiedAt = this.favoriteModifiedAt, notes = this.notes, initialized = this.initialized, - memo = MemoColumnAdapter.encode(this.memo), + memo = this.memo.toByteArray(), ) diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/models/BackupChapter.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/models/BackupChapter.kt index 9afc12145..20b7d2f0a 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/backup/models/BackupChapter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/backup/models/BackupChapter.kt @@ -1,10 +1,10 @@ package eu.kanade.tachiyomi.data.backup.models import kotlinx.serialization.Serializable -import kotlinx.serialization.json.JsonObject import kotlinx.serialization.protobuf.ProtoNumber import mihon.core.common.extensions.JsonObjectEmptyBytes -import tachiyomi.data.MemoColumnAdapter +import mihon.core.common.extensions.toByteArray +import mihon.core.common.extensions.toJsonObject import tachiyomi.domain.chapter.model.Chapter @Serializable @@ -39,37 +39,21 @@ class BackupChapter( dateFetch = this@BackupChapter.dateFetch, dateUpload = this@BackupChapter.dateUpload, sourceOrder = this@BackupChapter.sourceOrder, - memo = MemoColumnAdapter.decode(this@BackupChapter.memo), + memo = this@BackupChapter.memo.toJsonObject(), ) } } -val backupChapterMapper = { - _: Long, - _: Long, - url: String, - name: String, - scanlator: String?, - read: Boolean, - bookmark: Boolean, - lastPageRead: Long, - chapterNumber: Double, - sourceOrder: Long, - dateFetch: Long, - dateUpload: Long, - memo: JsonObject, - -> - BackupChapter( - url = url, - name = name, - chapterNumber = chapterNumber.toFloat(), - scanlator = scanlator, - read = read, - bookmark = bookmark, - lastPageRead = lastPageRead, - dateFetch = dateFetch, - dateUpload = dateUpload, - sourceOrder = sourceOrder, - memo = MemoColumnAdapter.encode(memo), - ) -} +fun Chapter.toBackupChapter() = BackupChapter( + url = url, + name = name, + chapterNumber = chapterNumber.toFloat(), + scanlator = scanlator, + read = read, + bookmark = bookmark, + lastPageRead = lastPageRead, + dateFetch = dateFetch, + dateUpload = dateUpload, + sourceOrder = sourceOrder, + memo = memo.toByteArray(), +) diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/models/BackupManga.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/models/BackupManga.kt index fe3256bac..428958a5a 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/backup/models/BackupManga.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/backup/models/BackupManga.kt @@ -4,7 +4,7 @@ import eu.kanade.tachiyomi.source.model.UpdateStrategy import kotlinx.serialization.Serializable import kotlinx.serialization.protobuf.ProtoNumber import mihon.core.common.extensions.JsonObjectEmptyBytes -import tachiyomi.data.MemoColumnAdapter +import mihon.core.common.extensions.toJsonObject import tachiyomi.domain.manga.model.Manga @Suppress("DEPRECATION") @@ -66,7 +66,7 @@ class BackupManga( favoriteModifiedAt = this@BackupManga.favoriteModifiedAt, notes = this@BackupManga.notes, initialized = this@BackupManga.initialized, - memo = MemoColumnAdapter.decode(this@BackupManga.memo), + memo = this@BackupManga.memo.toJsonObject(), ) } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/models/BackupTracking.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/models/BackupTracking.kt index 56108e68e..67dc92b57 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/backup/models/BackupTracking.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/backup/models/BackupTracking.kt @@ -54,35 +54,18 @@ data class BackupTracking( } } -val backupTrackMapper = { - _: Long, - _: Long, - syncId: Long, - mediaId: Long, - libraryId: Long?, - title: String, - lastChapterRead: Double, - totalChapters: Long, - status: Long, - score: Double, - remoteUrl: String, - startDate: Long, - finishDate: Long, - private: Boolean, - -> - BackupTracking( - syncId = syncId.toInt(), - mediaId = mediaId, - // forced not null so its compatible with 1.x backup system - libraryId = libraryId ?: 0, - title = title, - lastChapterRead = lastChapterRead.toFloat(), - totalChapters = totalChapters.toInt(), - score = score.toFloat(), - status = status.toInt(), - startedReadingDate = startDate, - finishedReadingDate = finishDate, - trackingUrl = remoteUrl, - private = private, - ) -} +fun Track.toBackupTracking() = BackupTracking( + syncId = trackerId.toInt(), + mediaId = remoteId, + // forced not null so its compatible with 1.x backup system + libraryId = libraryId ?: 0, + title = title, + lastChapterRead = lastChapterRead.toFloat(), + totalChapters = totalChapters.toInt(), + score = score.toFloat(), + status = status.toInt(), + startedReadingDate = startDate, + finishedReadingDate = finishDate, + trackingUrl = remoteUrl, + private = private, +) 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 a4d0ca231..5de4c5eb9 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 @@ -26,7 +26,6 @@ import kotlinx.coroutines.launch import logcat.LogPriority import tachiyomi.core.common.i18n.stringResource import tachiyomi.core.common.util.system.logcat -import tachiyomi.data.Database import tachiyomi.i18n.MR import java.io.File import java.text.SimpleDateFormat @@ -43,7 +42,6 @@ class BackupRestorer( @Assisted private val notifier: BackupNotifier, @Assisted private val isSync: Boolean, private val context: Context, - private val database: Database, private val downloadCache: DownloadCache, private val categoriesRestorer: CategoriesRestorer, private val preferenceRestorer: PreferenceRestorer, @@ -170,12 +168,8 @@ class BackupRestorer( .chunked(100) .forEach { chunk -> val restoredAsBatch = try { - database.transaction { - chunk.forEach { - ensureActive() - mangaRestorer.restore(it, backupCategories) - } - } + ensureActive() + mangaRestorer.restore(chunk, backupCategories) true } catch (e: Exception) { ensureActive() @@ -190,7 +184,7 @@ class BackupRestorer( ensureActive() try { - mangaRestorer.restore(it, backupCategories) + mangaRestorer.restore(listOf(it), backupCategories) } catch (e: Exception) { ensureActive() val sourceName = sourceMapping[it.source] ?: it.source.toString() @@ -245,18 +239,16 @@ class BackupRestorer( backupExtensionStores .chunked(100) .forEach { chunk -> - database.transaction { - chunk.forEach { - ensureActive() + chunk.forEach { + ensureActive() - try { - extensionStoreRestorer(it) - } catch (e: Exception) { - errors.add(Date() to "Error Adding Repo: ${it.name} : ${e.message}") - } - - restoreProgress.incrementAndFetch() + try { + extensionStoreRestorer(it) + } catch (e: Exception) { + errors.add(Date() to "Error Adding Repo: ${it.name} : ${e.message}") } + + restoreProgress.incrementAndFetch() } notifier.showRestoreProgress( context.stringResource(MR.strings.extensionStores), diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/restore/restorers/CategoriesRestorer.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/restore/restorers/CategoriesRestorer.kt index 16d747e8b..acbdbc94d 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/backup/restore/restorers/CategoriesRestorer.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/backup/restore/restorers/CategoriesRestorer.kt @@ -2,13 +2,13 @@ package eu.kanade.tachiyomi.data.backup.restore.restorers import dev.zacsweers.metro.Inject import eu.kanade.tachiyomi.data.backup.models.BackupCategory -import tachiyomi.data.Database import tachiyomi.domain.category.interactor.GetCategories +import tachiyomi.domain.category.repository.CategoryRepository import tachiyomi.domain.library.service.LibraryPreferences @Inject class CategoriesRestorer( - private val database: Database, + private val categoryRepository: CategoryRepository, private val getCategories: GetCategories, private val libraryPreferences: LibraryPreferences, ) { @@ -19,18 +19,11 @@ class CategoriesRestorer( val dbCategoriesByName = dbCategories.associateBy { it.name } var nextOrder = dbCategories.maxOfOrNull { it.order }?.plus(1) ?: 0 - val categories = database.transactionWithResult { - backupCategories - .sortedBy { it.order } - .map { - val dbCategory = dbCategoriesByName[it.name] - if (dbCategory != null) return@map dbCategory - val order = nextOrder++ - database.categoriesQueries - .insert(it.name, order, it.flags) - .let { id -> it.toCategory(id).copy(order = order) } - } - } + val categories = backupCategories + .sortedBy { it.order } + .filter { it.name !in dbCategoriesByName } + .map { it.toCategory(id = 0).copy(order = nextOrder++) } + categoryRepository.insertAll(categories) libraryPreferences.categorizedDisplaySettings.set( (dbCategories + categories) diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/restore/restorers/ExtensionStoreRestorer.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/restore/restorers/ExtensionStoreRestorer.kt index 7b895b159..845ee0f21 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/backup/restore/restorers/ExtensionStoreRestorer.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/backup/restore/restorers/ExtensionStoreRestorer.kt @@ -2,25 +2,30 @@ package eu.kanade.tachiyomi.data.backup.restore.restorers import dev.zacsweers.metro.Inject import eu.kanade.tachiyomi.data.backup.models.BackupExtensionStore -import tachiyomi.data.Database +import mihon.domain.extension.model.ExtensionStore +import mihon.domain.extension.repository.ExtensionStoreRepository @Inject class ExtensionStoreRestorer( - private val database: Database, + private val extensionStoreRepository: ExtensionStoreRepository, ) { suspend operator fun invoke( backupStore: BackupExtensionStore, ) { - database.extension_storeQueries.upsert( - indexUrl = backupStore.indexUrl, - name = backupStore.name, - badgeLabel = backupStore.badgeLabel ?: backupStore.name, - signingKey = backupStore.signingKey, - contactWebsite = backupStore.contactWebsite, - contactDiscord = backupStore.contactDiscord, - isLegacy = backupStore.isLegacy ?: true, - extensionListUrl = backupStore.extensionListUrl, + extensionStoreRepository.upsert( + ExtensionStore( + indexUrl = backupStore.indexUrl, + name = backupStore.name, + badgeLabel = backupStore.badgeLabel ?: backupStore.name, + signingKey = backupStore.signingKey, + contact = ExtensionStore.Contact( + website = backupStore.contactWebsite, + discord = backupStore.contactDiscord, + ), + isLegacy = backupStore.isLegacy ?: true, + extensionListUrl = backupStore.extensionListUrl, + ), ) } } 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 0f891bfcc..085b49e8a 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 @@ -1,42 +1,22 @@ package eu.kanade.tachiyomi.data.backup.restore.restorers -import app.cash.sqldelight.async.coroutines.awaitAsList -import app.cash.sqldelight.async.coroutines.awaitAsOne -import app.cash.sqldelight.async.coroutines.awaitAsOneOrNull import dev.zacsweers.metro.Inject -import eu.kanade.domain.manga.interactor.UpdateManga import eu.kanade.tachiyomi.data.backup.models.BackupCategory -import eu.kanade.tachiyomi.data.backup.models.BackupChapter -import eu.kanade.tachiyomi.data.backup.models.BackupHistory import eu.kanade.tachiyomi.data.backup.models.BackupManga -import eu.kanade.tachiyomi.data.backup.models.BackupTracking import kotlinx.datetime.TimeZone import kotlinx.datetime.toLocalDateTime -import kotlinx.serialization.json.JsonObject -import tachiyomi.data.Database +import tachiyomi.domain.backup.model.RestoredHistory +import tachiyomi.domain.backup.model.RestoredManga +import tachiyomi.domain.backup.repository.RestoreRepository import tachiyomi.domain.category.interactor.GetCategories -import tachiyomi.domain.chapter.interactor.GetChaptersByMangaId -import tachiyomi.domain.chapter.model.Chapter import tachiyomi.domain.manga.interactor.FetchInterval -import tachiyomi.domain.manga.interactor.GetMangaByUrlAndSourceId -import tachiyomi.domain.manga.model.Manga -import tachiyomi.domain.track.interactor.GetTracks -import tachiyomi.domain.track.interactor.InsertTrack -import tachiyomi.domain.track.model.Track -import java.util.Date -import kotlin.math.max import kotlin.time.Clock @Inject class MangaRestorer( - private val database: Database, + private val restoreRepository: RestoreRepository, private val getCategories: GetCategories, - private val getMangaByUrlAndSourceId: GetMangaByUrlAndSourceId, - private val getChaptersByMangaId: GetChaptersByMangaId, - private val updateManga: UpdateManga, - private val getTracks: GetTracks, - private val insertTrack: InsertTrack, - fetchInterval: FetchInterval, + private val fetchInterval: FetchInterval, ) { private val timeZone = TimeZone.currentSystemDefault() @@ -44,370 +24,42 @@ class MangaRestorer( private val currentFetchWindow = fetchInterval.getWindow(now.date, timeZone) suspend fun sortByNew(backupMangas: List): List { - val urlsBySource = database.mangasQueries - .getAllMangaSourceAndUrl() - .awaitAsList() - .groupBy({ it.source }, { it.url }) + val urlsBySource = restoreRepository.getMangaUrlsBySourceId() return backupMangas .sortedBy { it.url in urlsBySource[it.source].orEmpty() } } + /** + * Restores [backupMangas] all together, so either every one of them is restored or none is. + */ suspend fun restore( - backupManga: BackupManga, + backupMangas: List, backupCategories: List, ) { - database.transaction { - val dbManga = findExistingManga(backupManga) - val manga = backupManga.getMangaImpl() - val restoredManga = if (dbManga == null) { - restoreNewManga(manga) - } else { - restoreExistingManga(manga, dbManga) - } + val dbCategoriesByName = getCategories.await().associateBy { it.name } + val backupCategoriesByOrder = backupCategories.associateBy { it.order } - restoreMangaDetails( - manga = restoredManga, - chapters = backupManga.chapters, - categories = backupManga.categories, - backupCategories = backupCategories, - history = backupManga.history, - tracks = backupManga.tracking, + val entries = backupMangas.map { backupManga -> + RestoredManga( + manga = backupManga.getMangaImpl(), + chapters = backupManga.chapters.map { it.toChapterImpl() }, + categoryIds = backupManga.categories.mapNotNull { backupCategoryOrder -> + backupCategoriesByOrder[backupCategoryOrder]?.let { backupCategory -> + dbCategoriesByName[backupCategory.name]?.id + } + }, + history = backupManga.history.map { + val history = it.getHistoryImpl() + RestoredHistory(it.url, history.readAt, history.readDuration) + }, + tracks = backupManga.tracking.map { it.getTrackImpl() }, excludedScanlators = backupManga.excludedScanlators, ) } - } - private suspend fun findExistingManga(backupManga: BackupManga): Manga? { - return getMangaByUrlAndSourceId.await(backupManga.url, backupManga.source) - } - - private suspend fun restoreExistingManga(manga: Manga, dbManga: Manga): Manga { - val details = if (dbManga.initialized || !manga.initialized) dbManga else manga - return updateManga( - dbManga.copy( - favorite = dbManga.favorite || manga.favorite, - dateAdded = listOfNotNull( - dbManga.dateAdded.takeIf { it > 0 }, - manga.dateAdded.takeIf { it > 0 }, - ).minOrNull() ?: 0L, - title = details.title, - artist = details.artist, - author = details.author, - description = details.description, - genre = details.genre, - status = details.status, - thumbnailUrl = details.thumbnailUrl, - updateStrategy = details.updateStrategy, - initialized = dbManga.initialized || manga.initialized, - // Merge both backup and local data with local winning - memo = JsonObject(manga.memo + dbManga.memo), - ), - ) - } - - private suspend fun updateManga(manga: Manga): Manga { - database.mangasQueries.updateFromBackup( - artist = manga.artist, - author = manga.author, - description = manga.description, - genre = manga.genre, - title = manga.title, - status = manga.status, - thumbnailUrl = manga.thumbnailUrl, - favorite = manga.favorite, - lastUpdate = manga.lastUpdate, - initialized = manga.initialized, - viewer = manga.viewerFlags, - chapterFlags = manga.chapterFlags, - coverLastModified = manga.coverLastModified, - dateAdded = manga.dateAdded, - mangaId = manga.id, - updateStrategy = manga.updateStrategy, - notes = manga.notes, - memo = manga.memo, - ) - return manga - } - - private suspend fun restoreNewManga( - manga: Manga, - ): Manga { - return manga.copy( - id = insertManga(manga), - ) - } - - private suspend fun restoreChapters(manga: Manga, backupChapters: List) { - val dbChaptersByUrl = getChaptersByMangaId.await(manga.id) - .associateBy { it.url } - - val (existingChapters, newChapters) = backupChapters - .mapNotNull { - val chapter = it.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 - } - - chapter - .copyFrom(dbChapter) - .copy( - id = dbChapter.id, - read = chapter.read || dbChapter.read, - bookmark = chapter.bookmark || dbChapter.bookmark, - lastPageRead = max(chapter.lastPageRead, dbChapter.lastPageRead), - dateFetch = dbChapter.dateFetch, - sourceOrder = dbChapter.sourceOrder, - // Merge both backup and local data with local winning - memo = JsonObject(chapter.memo + dbChapter.memo), - ) - } - .partition { it.id > 0 } - - insertNewChapters(newChapters) - updateExistingChapters(existingChapters) - } - - private fun Chapter.forComparison() = - this.copy(id = 0L, mangaId = 0L, dateFetch = 0L, dateUpload = 0L) - - private suspend fun insertNewChapters(chapters: List) { - database.transaction { - chapters.forEach { chapter -> - database.chaptersQueries.insert( - chapter.mangaId, - chapter.url, - chapter.name, - chapter.scanlator, - chapter.read, - chapter.bookmark, - chapter.lastPageRead, - chapter.chapterNumber, - chapter.sourceOrder, - chapter.dateFetch, - chapter.dateUpload, - chapter.memo, - ) - } + restoreRepository.restoreManga(entries) { + fetchInterval.toMangaUpdate(it, now, timeZone, currentFetchWindow) } } - - private suspend fun updateExistingChapters(chapters: List) { - database.transaction { - chapters.forEach { chapter -> - database.chaptersQueries.updateFromBackup( - read = chapter.read, - bookmark = chapter.bookmark, - lastPageRead = chapter.lastPageRead, - chapterId = chapter.id, - memo = chapter.memo, - ) - } - } - } - - /** - * Inserts manga and returns id - * - * @return id of [Manga], null if not found - */ - private suspend fun insertManga(manga: Manga): Long { - return database.mangasQueries.insertReturningId( - source = manga.source, - url = manga.url, - artist = manga.artist, - author = manga.author, - description = manga.description, - genre = manga.genre, - title = manga.title, - status = manga.status, - thumbnailUrl = manga.thumbnailUrl, - favorite = manga.favorite, - lastUpdate = manga.lastUpdate, - nextUpdate = 0L, - calculateInterval = 0L, - initialized = manga.initialized, - viewerFlags = manga.viewerFlags, - chapterFlags = manga.chapterFlags, - coverLastModified = manga.coverLastModified, - dateAdded = manga.dateAdded, - updateStrategy = manga.updateStrategy, - notes = manga.notes, - memo = manga.memo, - ) - .awaitAsOne() - } - - private suspend fun restoreMangaDetails( - manga: Manga, - chapters: List, - categories: List, - backupCategories: List, - history: List, - tracks: List, - excludedScanlators: List, - ): Manga { - restoreCategories(manga, categories, backupCategories) - restoreChapters(manga, chapters) - restoreTracking(manga, tracks) - restoreHistory(manga, history) - restoreExcludedScanlators(manga, excludedScanlators) - updateManga.awaitUpdateFetchInterval(manga, timeZone, now, currentFetchWindow) - return manga - } - - /** - * Restores the categories a manga is in. - * - * @param manga the manga whose categories have to be restored. - * @param categories the categories to restore. - */ - private suspend fun restoreCategories( - manga: Manga, - categories: List, - backupCategories: List, - ) { - val dbCategories = getCategories.await() - val dbCategoriesByName = dbCategories.associateBy { it.name } - - val backupCategoriesByOrder = backupCategories.associateBy { it.order } - - val mangaCategoriesToUpdate = categories.mapNotNull { backupCategoryOrder -> - backupCategoriesByOrder[backupCategoryOrder]?.let { backupCategory -> - dbCategoriesByName[backupCategory.name]?.let { dbCategory -> - Pair(manga.id, dbCategory.id) - } - } - } - - if (mangaCategoriesToUpdate.isNotEmpty()) { - database.transaction { - database.mangas_categoriesQueries.deleteMangaCategoryByMangaId(manga.id) - mangaCategoriesToUpdate.forEach { (mangaId, categoryId) -> - database.mangas_categoriesQueries.insert(mangaId, categoryId) - } - } - } - } - - private suspend fun restoreHistory(manga: Manga, backupHistory: List) { - val toUpdate = backupHistory.mapNotNull { history -> - val dbHistory = database.historyQueries - .getHistoryByChapterUrlAndMangaId(history.url, manga.id) - .awaitAsOneOrNull() - val item = history.getHistoryImpl() - - if (dbHistory == null) { - val chapter = database.chaptersQueries - .getChapterByUrlAndMangaId(history.url, manga.id) - .awaitAsOneOrNull() - return@mapNotNull if (chapter == null) { - // Chapter doesn't exist; skip - null - } else { - // New history entry - item.copy(chapterId = chapter._id) - } - } - - // Update history entry - item.copy( - id = dbHistory._id, - chapterId = dbHistory.chapter_id, - readAt = max(item.readAt?.time ?: 0L, dbHistory.last_read?.time ?: 0L) - .takeIf { it > 0L } - ?.let { Date(it) }, - readDuration = max(item.readDuration, dbHistory.time_read) - dbHistory.time_read, - ) - } - - if (toUpdate.isEmpty()) return - database.transaction { - toUpdate.forEach { - database.historyQueries.upsert( - it.chapterId, - it.readAt, - it.readDuration, - ) - } - } - } - - private suspend fun restoreTracking(manga: Manga, backupTracks: List) { - val dbTrackByTrackerId = getTracks.await(manga.id).associateBy { it.trackerId } - - val (existingTracks, newTracks) = backupTracks - .mapNotNull { - val track = it.getTrackImpl() - val dbTrack = dbTrackByTrackerId[track.trackerId] - ?: // New track - return@mapNotNull track.copy( - id = 0, // Let DB assign new ID - mangaId = manga.id, - ) - - if (track.forComparison() == dbTrack.forComparison()) { - // Same state; skip - return@mapNotNull null - } - - // Update to an existing track - dbTrack.copy( - remoteId = track.remoteId, - libraryId = track.libraryId, - lastChapterRead = max(dbTrack.lastChapterRead, track.lastChapterRead), - ) - } - .partition { it.id > 0 } - - if (newTracks.isNotEmpty()) { - insertTrack.awaitAll(newTracks) - } - - if (existingTracks.isEmpty()) return - database.transaction { - existingTracks.forEach { track -> - database.manga_syncQueries.update( - track.mangaId, - track.trackerId, - track.remoteId, - track.libraryId, - track.title, - track.lastChapterRead, - track.totalChapters, - track.status, - track.score, - track.remoteUrl, - track.startDate, - track.finishDate, - track.private, - track.id, - ) - } - } - } - - private fun Track.forComparison() = this.copy(id = 0L, mangaId = 0L) - - /** - * Restores the excluded scanlators for the manga. - * - * @param manga the manga whose excluded scanlators have to be restored. - * @param excludedScanlators the excluded scanlators to restore. - */ - private suspend fun restoreExcludedScanlators(manga: Manga, excludedScanlators: List) { - if (excludedScanlators.isEmpty()) return - val existingExcludedScanlators = database.excluded_scanlatorsQueries - .getExcludedScanlatorsByMangaId(manga.id) - .awaitAsList() - val toInsert = excludedScanlators.filter { it !in existingExcludedScanlators } - if (toInsert.isEmpty()) return - toInsert.forEach { database.excluded_scanlatorsQueries.insert(manga.id, it) } - } } diff --git a/app/src/main/java/mihon/app/di/AppBindings.kt b/app/src/main/java/mihon/app/di/AppBindings.kt index 219bec558..9274a6580 100644 --- a/app/src/main/java/mihon/app/di/AppBindings.kt +++ b/app/src/main/java/mihon/app/di/AppBindings.kt @@ -1,12 +1,5 @@ package mihon.app.di -import android.content.Context -import androidx.sqlite.driver.bundled.BundledSQLiteDriver -import app.cash.sqldelight.db.SqlDriver -import com.eygraber.sqldelight.androidx.driver.AndroidxSqliteConfiguration -import com.eygraber.sqldelight.androidx.driver.AndroidxSqliteDatabaseType -import com.eygraber.sqldelight.androidx.driver.AndroidxSqliteDriver -import com.eygraber.sqldelight.androidx.driver.FileProvider import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.BindingContainer import dev.zacsweers.metro.Provides @@ -16,50 +9,10 @@ import kotlinx.serialization.protobuf.ProtoBuf import nl.adaptivity.xmlutil.XmlDeclMode import nl.adaptivity.xmlutil.core.XmlVersion import nl.adaptivity.xmlutil.serialization.XML -import tachiyomi.data.Chapters -import tachiyomi.data.Database -import tachiyomi.data.DateColumnAdapter -import tachiyomi.data.History -import tachiyomi.data.Mangas -import tachiyomi.data.MemoColumnAdapter -import tachiyomi.data.StringListColumnAdapter -import tachiyomi.data.UpdateStrategyColumnAdapter @BindingContainer object AppBindings { - @Provides - @SingleIn(AppScope::class) - fun providesSqlDriver(context: Context): SqlDriver { - return AndroidxSqliteDriver( - driver = BundledSQLiteDriver(), - databaseType = AndroidxSqliteDatabaseType.FileProvider(context, "tachiyomi.db"), - schema = Database.Schema, - configuration = AndroidxSqliteConfiguration( - isForeignKeyConstraintsEnabled = true, - ), - ) - } - - @Provides - @SingleIn(AppScope::class) - fun providesDatabase(driver: SqlDriver): Database { - return Database( - driver = driver, - historyAdapter = History.Adapter( - last_readAdapter = DateColumnAdapter, - ), - mangasAdapter = Mangas.Adapter( - genreAdapter = StringListColumnAdapter, - update_strategyAdapter = UpdateStrategyColumnAdapter, - memoAdapter = MemoColumnAdapter, - ), - chaptersAdapter = Chapters.Adapter( - memoAdapter = MemoColumnAdapter, - ), - ) - } - @Provides @SingleIn(AppScope::class) fun providesJson(): Json = Json { diff --git a/core/common/src/main/kotlin/mihon/core/common/extensions/JsonObject.kt b/core/common/src/main/kotlin/mihon/core/common/extensions/JsonObject.kt index d7d17b164..1fc7998f3 100644 --- a/core/common/src/main/kotlin/mihon/core/common/extensions/JsonObject.kt +++ b/core/common/src/main/kotlin/mihon/core/common/extensions/JsonObject.kt @@ -1,5 +1,6 @@ package mihon.core.common.extensions +import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonObject val JsonObjectEmpty = JsonObject(emptyMap()) @@ -8,3 +9,7 @@ val JsonObjectEmptyBytes = byteArrayOf(0x7B, 0x7D) val JsonObject.Companion.EMPTY: JsonObject inline get() = JsonObjectEmpty + +fun JsonObject.toByteArray(): ByteArray = toString().encodeToByteArray() + +fun ByteArray.toJsonObject(): JsonObject = Json.decodeFromString(decodeToString()) diff --git a/data/build.gradle.kts b/data/build.gradle.kts index 632bfbf73..b575beb4f 100644 --- a/data/build.gradle.kts +++ b/data/build.gradle.kts @@ -42,5 +42,6 @@ dependencies { implementation(libs.kotlinx.datetime) - api(libs.bundles.sqldelight) + implementation(libs.androidx.sqlite.bundled) + implementation(libs.bundles.sqldelight) } diff --git a/data/src/main/java/mihon/data/extension/repository/ExtensionStoreRepositoryImpl.kt b/data/src/main/java/mihon/data/extension/repository/ExtensionStoreRepositoryImpl.kt index 25e50bbd5..4f63f4132 100644 --- a/data/src/main/java/mihon/data/extension/repository/ExtensionStoreRepositoryImpl.kt +++ b/data/src/main/java/mihon/data/extension/repository/ExtensionStoreRepositoryImpl.kt @@ -66,7 +66,7 @@ class ExtensionStoreRepositoryImpl( } } - private suspend fun upsert(store: ExtensionStore) { + override suspend fun upsert(store: ExtensionStore) { database.extension_storeQueries.upsert( indexUrl = store.indexUrl, name = store.name, diff --git a/data/src/main/java/tachiyomi/data/DatabaseAdapter.kt b/data/src/main/java/tachiyomi/data/DatabaseAdapter.kt index c45b8dccb..33b495fe6 100644 --- a/data/src/main/java/tachiyomi/data/DatabaseAdapter.kt +++ b/data/src/main/java/tachiyomi/data/DatabaseAdapter.kt @@ -2,8 +2,9 @@ package tachiyomi.data import app.cash.sqldelight.ColumnAdapter import eu.kanade.tachiyomi.source.model.UpdateStrategy -import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonObject +import mihon.core.common.extensions.toByteArray +import mihon.core.common.extensions.toJsonObject import java.util.Date object DateColumnAdapter : ColumnAdapter { @@ -31,11 +32,6 @@ object UpdateStrategyColumnAdapter : ColumnAdapter { } object MemoColumnAdapter : ColumnAdapter { - override fun decode(databaseValue: ByteArray): JsonObject { - return Json.decodeFromString(databaseValue.decodeToString()) - } - - override fun encode(value: JsonObject): ByteArray { - return value.toString().encodeToByteArray() - } + override fun decode(databaseValue: ByteArray): JsonObject = databaseValue.toJsonObject() + override fun encode(value: JsonObject): ByteArray = value.toByteArray() } diff --git a/data/src/main/java/tachiyomi/data/DatabaseBindings.kt b/data/src/main/java/tachiyomi/data/DatabaseBindings.kt new file mode 100644 index 000000000..d9151c5d2 --- /dev/null +++ b/data/src/main/java/tachiyomi/data/DatabaseBindings.kt @@ -0,0 +1,51 @@ +package tachiyomi.data + +import android.content.Context +import androidx.sqlite.driver.bundled.BundledSQLiteDriver +import app.cash.sqldelight.db.SqlDriver +import com.eygraber.sqldelight.androidx.driver.AndroidxSqliteConfiguration +import com.eygraber.sqldelight.androidx.driver.AndroidxSqliteDatabaseType +import com.eygraber.sqldelight.androidx.driver.AndroidxSqliteDriver +import com.eygraber.sqldelight.androidx.driver.FileProvider +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.BindingContainer +import dev.zacsweers.metro.ContributesTo +import dev.zacsweers.metro.Provides +import dev.zacsweers.metro.SingleIn + +@ContributesTo(AppScope::class) +@BindingContainer +object DatabaseBindings { + + @Provides + @SingleIn(AppScope::class) + fun providesSqlDriver(context: Context): SqlDriver { + return AndroidxSqliteDriver( + driver = BundledSQLiteDriver(), + databaseType = AndroidxSqliteDatabaseType.FileProvider(context, "tachiyomi.db"), + schema = Database.Schema, + configuration = AndroidxSqliteConfiguration( + isForeignKeyConstraintsEnabled = true, + ), + ) + } + + @Provides + @SingleIn(AppScope::class) + fun providesDatabase(driver: SqlDriver): Database { + return Database( + driver = driver, + historyAdapter = History.Adapter( + last_readAdapter = DateColumnAdapter, + ), + mangasAdapter = Mangas.Adapter( + genreAdapter = StringListColumnAdapter, + update_strategyAdapter = UpdateStrategyColumnAdapter, + memoAdapter = MemoColumnAdapter, + ), + chaptersAdapter = Chapters.Adapter( + memoAdapter = MemoColumnAdapter, + ), + ) + } +} diff --git a/data/src/main/java/tachiyomi/data/backup/RestoreRepositoryImpl.kt b/data/src/main/java/tachiyomi/data/backup/RestoreRepositoryImpl.kt new file mode 100644 index 000000000..a2c3342ad --- /dev/null +++ b/data/src/main/java/tachiyomi/data/backup/RestoreRepositoryImpl.kt @@ -0,0 +1,298 @@ +package tachiyomi.data.backup + +import app.cash.sqldelight.async.coroutines.awaitAsList +import app.cash.sqldelight.async.coroutines.awaitAsOne +import app.cash.sqldelight.async.coroutines.awaitAsOneOrNull +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import dev.zacsweers.metro.Inject +import dev.zacsweers.metro.SingleIn +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.ensureActive +import kotlinx.serialization.json.JsonObject +import tachiyomi.data.Database +import tachiyomi.domain.backup.model.RestoredHistory +import tachiyomi.domain.backup.model.RestoredManga +import tachiyomi.domain.backup.repository.RestoreRepository +import tachiyomi.domain.chapter.model.Chapter +import tachiyomi.domain.chapter.repository.ChapterRepository +import tachiyomi.domain.manga.model.Manga +import tachiyomi.domain.manga.model.MangaUpdate +import tachiyomi.domain.manga.repository.MangaRepository +import tachiyomi.domain.track.model.Track +import tachiyomi.domain.track.repository.TrackRepository +import java.util.Date +import kotlin.math.max + +@Inject +@SingleIn(AppScope::class) +@ContributesBinding(AppScope::class) +class RestoreRepositoryImpl( + private val database: Database, + private val mangaRepository: MangaRepository, + private val chapterRepository: ChapterRepository, + private val trackRepository: TrackRepository, +) : RestoreRepository { + + override suspend fun getMangaUrlsBySourceId(): Map> { + return database.mangasQueries + .getAllMangaSourceAndUrl() + .awaitAsList() + .groupBy({ it.source }, { it.url }) + } + + override suspend fun restoreManga(entries: List, update: suspend (Manga) -> MangaUpdate) { + database.transaction { + entries.forEach { entry -> + // A cancelled restore stops between entries and rolls the whole batch back + currentCoroutineContext().ensureActive() + mangaRepository.update(update(restore(entry))) + } + } + } + + private suspend fun restore(entry: RestoredManga): Manga { + val dbManga = mangaRepository.getMangaByUrlAndSourceId(entry.manga.url, entry.manga.source) + val manga = if (dbManga == null) { + entry.manga.copy(id = insertManga(entry.manga)) + } else { + updateManga(mergeManga(entry.manga, dbManga)) + } + + if (entry.categoryIds.isNotEmpty()) { + mangaRepository.setMangaCategories(manga.id, entry.categoryIds) + } + restoreChapters(manga, entry.chapters) + restoreTracking(manga, entry.tracks) + restoreHistory(manga, entry.history) + restoreExcludedScanlators(manga, entry.excludedScanlators) + return manga + } + + private fun mergeManga(manga: Manga, dbManga: Manga): Manga { + val details = if (dbManga.initialized || !manga.initialized) dbManga else manga + return dbManga.copy( + favorite = dbManga.favorite || manga.favorite, + dateAdded = listOfNotNull( + dbManga.dateAdded.takeIf { it > 0 }, + manga.dateAdded.takeIf { it > 0 }, + ).minOrNull() ?: 0L, + title = details.title, + artist = details.artist, + author = details.author, + description = details.description, + genre = details.genre, + status = details.status, + thumbnailUrl = details.thumbnailUrl, + updateStrategy = details.updateStrategy, + initialized = dbManga.initialized || manga.initialized, + // Merge both backup and local data with local winning + memo = JsonObject(manga.memo + dbManga.memo), + ) + } + + private suspend fun updateManga(manga: Manga): Manga { + database.mangasQueries.updateFromBackup( + artist = manga.artist, + author = manga.author, + description = manga.description, + genre = manga.genre, + title = manga.title, + status = manga.status, + thumbnailUrl = manga.thumbnailUrl, + favorite = manga.favorite, + lastUpdate = manga.lastUpdate, + initialized = manga.initialized, + viewer = manga.viewerFlags, + chapterFlags = manga.chapterFlags, + coverLastModified = manga.coverLastModified, + dateAdded = manga.dateAdded, + mangaId = manga.id, + updateStrategy = manga.updateStrategy, + notes = manga.notes, + memo = manga.memo, + ) + return manga + } + + private suspend fun insertManga(manga: Manga): Long { + return database.mangasQueries.insertReturningId( + source = manga.source, + url = manga.url, + artist = manga.artist, + author = manga.author, + description = manga.description, + genre = manga.genre, + title = manga.title, + status = manga.status, + thumbnailUrl = manga.thumbnailUrl, + favorite = manga.favorite, + lastUpdate = manga.lastUpdate, + nextUpdate = 0L, + calculateInterval = 0L, + initialized = manga.initialized, + viewerFlags = manga.viewerFlags, + chapterFlags = manga.chapterFlags, + coverLastModified = manga.coverLastModified, + dateAdded = manga.dateAdded, + updateStrategy = manga.updateStrategy, + notes = manga.notes, + memo = manga.memo, + ) + .awaitAsOne() + } + + private suspend fun restoreChapters(manga: Manga, restoredChapters: List) { + val dbChaptersByUrl = chapterRepository.getChapterByMangaId(manga.id) + .associateBy { it.url } + + val (existingChapters, newChapters) = restoredChapters + .mapNotNull { + val chapter = it.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 + } + + chapter + .copyFrom(dbChapter) + .copy( + id = dbChapter.id, + read = chapter.read || dbChapter.read, + bookmark = chapter.bookmark || dbChapter.bookmark, + lastPageRead = max(chapter.lastPageRead, dbChapter.lastPageRead), + dateFetch = dbChapter.dateFetch, + sourceOrder = dbChapter.sourceOrder, + // Merge both backup and local data with local winning + memo = JsonObject(chapter.memo + dbChapter.memo), + ) + } + .partition { it.id > 0 } + + newChapters.forEach { chapter -> + database.chaptersQueries.insert( + chapter.mangaId, + chapter.url, + chapter.name, + chapter.scanlator, + chapter.read, + chapter.bookmark, + chapter.lastPageRead, + chapter.chapterNumber, + chapter.sourceOrder, + chapter.dateFetch, + chapter.dateUpload, + chapter.memo, + ) + } + existingChapters.forEach { chapter -> + database.chaptersQueries.updateFromBackup( + read = chapter.read, + bookmark = chapter.bookmark, + lastPageRead = chapter.lastPageRead, + chapterId = chapter.id, + memo = chapter.memo, + ) + } + } + + private fun Chapter.forComparison() = + this.copy(id = 0L, mangaId = 0L, dateFetch = 0L, dateUpload = 0L) + + private suspend fun restoreHistory(manga: Manga, restoredHistory: List) { + val toUpdate = restoredHistory.mapNotNull { history -> + val dbHistory = database.historyQueries + .getHistoryByChapterUrlAndMangaId(history.chapterUrl, manga.id) + .awaitAsOneOrNull() + + if (dbHistory == null) { + val chapter = database.chaptersQueries + .getChapterByUrlAndMangaId(history.chapterUrl, manga.id) + .awaitAsOneOrNull() + // Chapter doesn't exist; skip + ?: return@mapNotNull null + // New history entry + return@mapNotNull Triple(chapter._id, history.readAt, history.readDuration) + } + + // Update history entry + Triple( + dbHistory.chapter_id, + max(history.readAt?.time ?: 0L, dbHistory.last_read?.time ?: 0L) + .takeIf { it > 0L } + ?.let { Date(it) }, + max(history.readDuration, dbHistory.time_read) - dbHistory.time_read, + ) + } + + toUpdate.forEach { (chapterId, readAt, readDuration) -> + database.historyQueries.upsert(chapterId, readAt, readDuration) + } + } + + private suspend fun restoreTracking(manga: Manga, restoredTracks: List) { + val dbTrackByTrackerId = trackRepository.getTracksByMangaId(manga.id).associateBy { it.trackerId } + + val (existingTracks, newTracks) = restoredTracks + .mapNotNull { track -> + val dbTrack = dbTrackByTrackerId[track.trackerId] + ?: // New track + return@mapNotNull track.copy( + id = 0, // Let DB assign new ID + mangaId = manga.id, + ) + + if (track.forComparison() == dbTrack.forComparison()) { + // Same state; skip + return@mapNotNull null + } + + // Update to an existing track + dbTrack.copy( + remoteId = track.remoteId, + libraryId = track.libraryId, + lastChapterRead = max(dbTrack.lastChapterRead, track.lastChapterRead), + ) + } + .partition { it.id > 0 } + + if (newTracks.isNotEmpty()) { + trackRepository.insertAll(newTracks) + } + + existingTracks.forEach { track -> + database.manga_syncQueries.update( + track.mangaId, + track.trackerId, + track.remoteId, + track.libraryId, + track.title, + track.lastChapterRead, + track.totalChapters, + track.status, + track.score, + track.remoteUrl, + track.startDate, + track.finishDate, + track.private, + track.id, + ) + } + } + + private fun Track.forComparison() = this.copy(id = 0L, mangaId = 0L) + + private suspend fun restoreExcludedScanlators(manga: Manga, excludedScanlators: List) { + if (excludedScanlators.isEmpty()) return + val existingExcludedScanlators = database.excluded_scanlatorsQueries + .getExcludedScanlatorsByMangaId(manga.id) + .awaitAsList() + val toInsert = excludedScanlators.filter { it !in existingExcludedScanlators } + toInsert.forEach { database.excluded_scanlatorsQueries.insert(manga.id, it) } + } +} diff --git a/data/src/main/java/tachiyomi/data/category/CategoryRepositoryImpl.kt b/data/src/main/java/tachiyomi/data/category/CategoryRepositoryImpl.kt index 90f2f75f0..729703d54 100644 --- a/data/src/main/java/tachiyomi/data/category/CategoryRepositoryImpl.kt +++ b/data/src/main/java/tachiyomi/data/category/CategoryRepositoryImpl.kt @@ -57,6 +57,12 @@ class CategoryRepositoryImpl( ) } + override suspend fun insertAll(categories: List) { + database.transaction { + categories.forEach { insert(it) } + } + } + override suspend fun updateName(categoryId: Long, name: String) { database.categoriesQueries.updateName(name = name, categoryId = categoryId) } diff --git a/data/src/main/java/tachiyomi/data/history/HistoryRepositoryImpl.kt b/data/src/main/java/tachiyomi/data/history/HistoryRepositoryImpl.kt index fa2471884..4830bbf78 100644 --- a/data/src/main/java/tachiyomi/data/history/HistoryRepositoryImpl.kt +++ b/data/src/main/java/tachiyomi/data/history/HistoryRepositoryImpl.kt @@ -74,6 +74,10 @@ class HistoryRepositoryImpl( } } + override suspend fun deleteResetHistory() { + database.historyQueries.removeResettedHistory() + } + override suspend fun upsertHistory(historyUpdate: HistoryUpdate) { try { database.historyQueries.upsert( diff --git a/data/src/main/java/tachiyomi/data/manga/MangaRepositoryImpl.kt b/data/src/main/java/tachiyomi/data/manga/MangaRepositoryImpl.kt index cd1a2972c..c2d107a50 100644 --- a/data/src/main/java/tachiyomi/data/manga/MangaRepositoryImpl.kt +++ b/data/src/main/java/tachiyomi/data/manga/MangaRepositoryImpl.kt @@ -8,10 +8,12 @@ import dev.zacsweers.metro.ContributesBinding import dev.zacsweers.metro.Inject import dev.zacsweers.metro.SingleIn import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map import kotlinx.datetime.TimeZone import kotlinx.datetime.atStartOfDayIn import kotlinx.datetime.toLocalDateTime import logcat.LogPriority +import tachiyomi.core.common.util.lang.toLong import tachiyomi.core.common.util.system.logcat import tachiyomi.data.Database import tachiyomi.data.subscribeToList @@ -123,6 +125,10 @@ class MangaRepositoryImpl( } } + override suspend fun deleteNonLibraryManga(sourceIds: List, keepReadManga: Boolean) { + database.mangasQueries.deleteNonLibraryManga(sourceIds, keepReadManga.toLong()) + } + override suspend fun setMangaCategories(mangaId: Long, categoryIds: List) { database.transaction { database.mangas_categoriesQueries.deleteMangaCategoryByMangaId(mangaId) @@ -132,6 +138,33 @@ class MangaRepositoryImpl( } } + override suspend fun getExcludedScanlators(mangaId: Long): Set { + return database.excluded_scanlatorsQueries + .getExcludedScanlatorsByMangaId(mangaId) + .awaitAsList() + .toSet() + } + + override fun getExcludedScanlatorsAsFlow(mangaId: Long): Flow> { + return database.excluded_scanlatorsQueries + .getExcludedScanlatorsByMangaId(mangaId) + .subscribeToList() + .map { it.toSet() } + } + + override suspend fun setExcludedScanlators(mangaId: Long, excludedScanlators: Set) { + database.transaction { + val current = database.excluded_scanlatorsQueries + .getExcludedScanlatorsByMangaId(mangaId) + .awaitAsList() + .toSet() + excludedScanlators.minus(current).forEach { scanlator -> + database.excluded_scanlatorsQueries.insert(mangaId, scanlator) + } + database.excluded_scanlatorsQueries.remove(mangaId, current.minus(excludedScanlators)) + } + } + override suspend fun update(update: MangaUpdate): Boolean { return try { partialUpdate(update) diff --git a/domain/build.gradle.kts b/domain/build.gradle.kts index 6325e8144..005fe14b0 100644 --- a/domain/build.gradle.kts +++ b/domain/build.gradle.kts @@ -28,7 +28,7 @@ dependencies { implementation(libs.unifile) - api(libs.sqldelight.androidxPaging) + api(libs.androidx.paging.runtime) compileOnly(platform(libs.androidx.compose.bom)) compileOnly(libs.androidx.compose.runtimeAnnotation) diff --git a/domain/src/main/java/mihon/domain/extension/repository/ExtensionStoreRepository.kt b/domain/src/main/java/mihon/domain/extension/repository/ExtensionStoreRepository.kt index 3ab477081..eff7c8175 100644 --- a/domain/src/main/java/mihon/domain/extension/repository/ExtensionStoreRepository.kt +++ b/domain/src/main/java/mihon/domain/extension/repository/ExtensionStoreRepository.kt @@ -9,6 +9,8 @@ interface ExtensionStoreRepository { suspend fun insertFromPreference(indexUrl: String, name: String) + suspend fun upsert(store: ExtensionStore) + suspend fun refreshAll() suspend fun fetchExtensions(): List diff --git a/domain/src/main/java/tachiyomi/domain/backup/model/RestoredManga.kt b/domain/src/main/java/tachiyomi/domain/backup/model/RestoredManga.kt new file mode 100644 index 000000000..8e9cdac1e --- /dev/null +++ b/domain/src/main/java/tachiyomi/domain/backup/model/RestoredManga.kt @@ -0,0 +1,21 @@ +package tachiyomi.domain.backup.model + +import tachiyomi.domain.chapter.model.Chapter +import tachiyomi.domain.manga.model.Manga +import tachiyomi.domain.track.model.Track +import java.util.Date + +data class RestoredManga( + val manga: Manga, + val chapters: List, + val categoryIds: List, + val history: List, + val tracks: List, + val excludedScanlators: List, +) + +data class RestoredHistory( + val chapterUrl: String, + val readAt: Date?, + val readDuration: Long, +) diff --git a/domain/src/main/java/tachiyomi/domain/backup/repository/RestoreRepository.kt b/domain/src/main/java/tachiyomi/domain/backup/repository/RestoreRepository.kt new file mode 100644 index 000000000..e442918b4 --- /dev/null +++ b/domain/src/main/java/tachiyomi/domain/backup/repository/RestoreRepository.kt @@ -0,0 +1,16 @@ +package tachiyomi.domain.backup.repository + +import tachiyomi.domain.backup.model.RestoredManga +import tachiyomi.domain.manga.model.Manga +import tachiyomi.domain.manga.model.MangaUpdate + +interface RestoreRepository { + + suspend fun getMangaUrlsBySourceId(): Map> + + /** + * Restores [entries] all together: either every one of them is restored or none is. [update] is what to change on each + * entry once its chapters and history are in place, and is part of the same all-or-nothing restore. + */ + suspend fun restoreManga(entries: List, update: suspend (Manga) -> MangaUpdate) +} diff --git a/domain/src/main/java/tachiyomi/domain/category/repository/CategoryRepository.kt b/domain/src/main/java/tachiyomi/domain/category/repository/CategoryRepository.kt index f2663d549..1ad636427 100644 --- a/domain/src/main/java/tachiyomi/domain/category/repository/CategoryRepository.kt +++ b/domain/src/main/java/tachiyomi/domain/category/repository/CategoryRepository.kt @@ -17,6 +17,8 @@ interface CategoryRepository { suspend fun insert(category: Category) + suspend fun insertAll(categories: List) + suspend fun updateName(categoryId: Long, name: String) suspend fun updateFlags(categoryId: Long, flags: Long) diff --git a/domain/src/main/java/tachiyomi/domain/history/repository/HistoryRepository.kt b/domain/src/main/java/tachiyomi/domain/history/repository/HistoryRepository.kt index 410bfe951..c4cb27fc2 100644 --- a/domain/src/main/java/tachiyomi/domain/history/repository/HistoryRepository.kt +++ b/domain/src/main/java/tachiyomi/domain/history/repository/HistoryRepository.kt @@ -21,5 +21,7 @@ interface HistoryRepository { suspend fun deleteAllHistory(): Boolean + suspend fun deleteResetHistory() + suspend fun upsertHistory(historyUpdate: HistoryUpdate) } diff --git a/domain/src/main/java/tachiyomi/domain/manga/repository/MangaRepository.kt b/domain/src/main/java/tachiyomi/domain/manga/repository/MangaRepository.kt index 14cbfd249..29be510d4 100644 --- a/domain/src/main/java/tachiyomi/domain/manga/repository/MangaRepository.kt +++ b/domain/src/main/java/tachiyomi/domain/manga/repository/MangaRepository.kt @@ -37,8 +37,16 @@ interface MangaRepository { suspend fun resetViewerFlags(): Boolean + suspend fun deleteNonLibraryManga(sourceIds: List, keepReadManga: Boolean) + suspend fun setMangaCategories(mangaId: Long, categoryIds: List) + suspend fun getExcludedScanlators(mangaId: Long): Set + + fun getExcludedScanlatorsAsFlow(mangaId: Long): Flow> + + suspend fun setExcludedScanlators(mangaId: Long, excludedScanlators: Set) + suspend fun update(update: MangaUpdate): Boolean suspend fun updateRemote(update: MangaRemoteUpdate): Boolean