diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupCreateJob.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupCreateJob.kt index 6f960edec..875039e86 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupCreateJob.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupCreateJob.kt @@ -49,7 +49,7 @@ class BackupCreateJob(private val context: Context, workerParams: WorkerParamete } return try { - val location = BackupManager(context).createBackup(uri, flags, isAutoBackup) + val location = BackupCreator(context).createBackup(uri, flags, isAutoBackup) if (!isAutoBackup) notifier.showBackupComplete(UniFile.fromUri(context, location.toUri())) Result.success() } catch (e: Exception) { diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupCreator.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupCreator.kt new file mode 100644 index 000000000..b70df331f --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupCreator.kt @@ -0,0 +1,268 @@ +package eu.kanade.tachiyomi.data.backup + +import android.Manifest +import android.content.Context +import android.net.Uri +import com.hippo.unifile.UniFile +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.data.backup.BackupConst.BACKUP_APP_PREFS +import eu.kanade.tachiyomi.data.backup.BackupConst.BACKUP_APP_PREFS_MASK +import eu.kanade.tachiyomi.data.backup.BackupConst.BACKUP_CATEGORY +import eu.kanade.tachiyomi.data.backup.BackupConst.BACKUP_CATEGORY_MASK +import eu.kanade.tachiyomi.data.backup.BackupConst.BACKUP_CHAPTER +import eu.kanade.tachiyomi.data.backup.BackupConst.BACKUP_CHAPTER_MASK +import eu.kanade.tachiyomi.data.backup.BackupConst.BACKUP_HISTORY +import eu.kanade.tachiyomi.data.backup.BackupConst.BACKUP_HISTORY_MASK +import eu.kanade.tachiyomi.data.backup.BackupConst.BACKUP_SOURCE_PREFS +import eu.kanade.tachiyomi.data.backup.BackupConst.BACKUP_SOURCE_PREFS_MASK +import eu.kanade.tachiyomi.data.backup.BackupConst.BACKUP_TRACK +import eu.kanade.tachiyomi.data.backup.BackupConst.BACKUP_TRACK_MASK +import eu.kanade.tachiyomi.data.backup.models.Backup +import eu.kanade.tachiyomi.data.backup.models.BackupCategory +import eu.kanade.tachiyomi.data.backup.models.BackupHistory +import eu.kanade.tachiyomi.data.backup.models.BackupManga +import eu.kanade.tachiyomi.data.backup.models.BackupPreference +import eu.kanade.tachiyomi.data.backup.models.BackupSerializer +import eu.kanade.tachiyomi.data.backup.models.BackupSource +import eu.kanade.tachiyomi.data.backup.models.BackupSourcePreferences +import eu.kanade.tachiyomi.data.backup.models.BooleanPreferenceValue +import eu.kanade.tachiyomi.data.backup.models.FloatPreferenceValue +import eu.kanade.tachiyomi.data.backup.models.IntPreferenceValue +import eu.kanade.tachiyomi.data.backup.models.LongPreferenceValue +import eu.kanade.tachiyomi.data.backup.models.StringPreferenceValue +import eu.kanade.tachiyomi.data.backup.models.StringSetPreferenceValue +import eu.kanade.tachiyomi.data.backup.models.backupCategoryMapper +import eu.kanade.tachiyomi.data.backup.models.backupChapterMapper +import eu.kanade.tachiyomi.data.backup.models.backupTrackMapper +import eu.kanade.tachiyomi.source.ConfigurableSource +import eu.kanade.tachiyomi.source.preferenceKey +import eu.kanade.tachiyomi.source.sourcePreferences +import eu.kanade.tachiyomi.util.system.hasPermission +import kotlinx.serialization.protobuf.ProtoBuf +import logcat.LogPriority +import okio.buffer +import okio.gzip +import okio.sink +import tachiyomi.core.preference.Preference +import tachiyomi.core.preference.PreferenceStore +import tachiyomi.core.util.system.logcat +import tachiyomi.data.DatabaseHandler +import tachiyomi.domain.backup.service.BackupPreferences +import tachiyomi.domain.category.interactor.GetCategories +import tachiyomi.domain.category.model.Category +import tachiyomi.domain.history.interactor.GetHistory +import tachiyomi.domain.manga.interactor.GetFavorites +import tachiyomi.domain.manga.model.Manga +import tachiyomi.domain.source.service.SourceManager +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get +import java.io.FileOutputStream + +class BackupCreator( + private val context: Context, +) { + + private val handler: DatabaseHandler = Injekt.get() + private val sourceManager: SourceManager = Injekt.get() + private val backupPreferences: BackupPreferences = Injekt.get() + private val getCategories: GetCategories = Injekt.get() + private val getFavorites: GetFavorites = Injekt.get() + private val getHistory: GetHistory = Injekt.get() + private val preferenceStore: PreferenceStore = Injekt.get() + + internal val parser = ProtoBuf + + /** + * Create backup file. + * + * @param uri path of Uri + * @param isAutoBackup backup called from scheduled backup job + */ + suspend fun createBackup(uri: Uri, flags: Int, isAutoBackup: Boolean): String { + if (!context.hasPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE)) { + throw IllegalStateException(context.getString(R.string.missing_storage_permission)) + } + + val databaseManga = getFavorites.await() + val backup = Backup( + backupMangas(databaseManga, flags), + backupCategories(flags), + emptyList(), + prepExtensionInfoForSync(databaseManga), + backupAppPreferences(flags), + backupSourcePreferences(flags), + ) + + var file: UniFile? = null + try { + file = ( + if (isAutoBackup) { + // Get dir of file and create + var dir = UniFile.fromUri(context, uri) + dir = dir.createDirectory("automatic") + + // Delete older backups + val numberOfBackups = backupPreferences.numberOfBackups().get() + dir.listFiles { _, filename -> Backup.filenameRegex.matches(filename) } + .orEmpty() + .sortedByDescending { it.name } + .drop(numberOfBackups - 1) + .forEach { it.delete() } + + // Create new file to place backup + dir.createFile(Backup.getFilename()) + } else { + UniFile.fromUri(context, uri) + } + ) + ?: throw Exception(context.getString(R.string.create_backup_file_error)) + + if (!file.isFile) { + throw IllegalStateException("Failed to get handle on a backup file") + } + + val byteArray = parser.encodeToByteArray(BackupSerializer, backup) + if (byteArray.isEmpty()) { + throw IllegalStateException(context.getString(R.string.empty_backup_error)) + } + + file.openOutputStream().also { + // Force overwrite old file + (it as? FileOutputStream)?.channel?.truncate(0) + }.sink().gzip().buffer().use { it.write(byteArray) } + val fileUri = file.uri + + // Make sure it's a valid backup file + BackupFileValidator().validate(context, fileUri) + + return fileUri.toString() + } catch (e: Exception) { + logcat(LogPriority.ERROR, e) + file?.delete() + throw e + } + } + + private fun prepExtensionInfoForSync(mangas: List): List { + return mangas + .asSequence() + .map(Manga::source) + .distinct() + .map(sourceManager::getOrStub) + .map(BackupSource::copyFrom) + .toList() + } + + /** + * Backup the categories of library + * + * @return list of [BackupCategory] to be backed up + */ + private suspend fun backupCategories(options: Int): List { + // Check if user wants category information in backup + return if (options and BACKUP_CATEGORY_MASK == BACKUP_CATEGORY) { + getCategories.await() + .filterNot(Category::isSystemCategory) + .map(backupCategoryMapper) + } else { + emptyList() + } + } + + private suspend fun backupMangas(mangas: List, flags: Int): List { + return mangas.map { + backupManga(it, flags) + } + } + + /** + * Convert a manga to Json + * + * @param manga manga that gets converted + * @param options options for the backup + * @return [BackupManga] containing manga in a serializable form + */ + private suspend fun backupManga(manga: Manga, options: Int): BackupManga { + // Entry for this manga + val mangaObject = BackupManga.copyFrom(manga) + + // Check if user wants chapter information in backup + if (options and BACKUP_CHAPTER_MASK == BACKUP_CHAPTER) { + // Backup all the chapters + val chapters = handler.awaitList { chaptersQueries.getChaptersByMangaId(manga.id, backupChapterMapper) } + if (chapters.isNotEmpty()) { + mangaObject.chapters = chapters + } + } + + // Check if user wants category information in backup + if (options and BACKUP_CATEGORY_MASK == BACKUP_CATEGORY) { + // Backup categories for this manga + val categoriesForManga = getCategories.await(manga.id) + if (categoriesForManga.isNotEmpty()) { + mangaObject.categories = categoriesForManga.map { it.order } + } + } + + // Check if user wants track information in backup + if (options and BACKUP_TRACK_MASK == BACKUP_TRACK) { + val tracks = handler.awaitList { manga_syncQueries.getTracksByMangaId(manga.id, backupTrackMapper) } + if (tracks.isNotEmpty()) { + mangaObject.tracking = tracks + } + } + + // Check if user wants history information in backup + if (options and BACKUP_HISTORY_MASK == BACKUP_HISTORY) { + val historyByMangaId = getHistory.await(manga.id) + if (historyByMangaId.isNotEmpty()) { + val history = historyByMangaId.map { history -> + val chapter = handler.awaitOne { chaptersQueries.getChapterById(history.chapterId) } + BackupHistory(chapter.url, history.readAt?.time ?: 0L, history.readDuration) + } + if (history.isNotEmpty()) { + mangaObject.history = history + } + } + } + + return mangaObject + } + + private fun backupAppPreferences(flags: Int): List { + if (flags and BACKUP_APP_PREFS_MASK != BACKUP_APP_PREFS) return emptyList() + + return preferenceStore.getAll().toBackupPreferences() + } + + private fun backupSourcePreferences(flags: Int): List { + if (flags and BACKUP_SOURCE_PREFS_MASK != BACKUP_SOURCE_PREFS) return emptyList() + + return sourceManager.getOnlineSources() + .filterIsInstance() + .map { + BackupSourcePreferences( + it.preferenceKey(), + it.sourcePreferences().all.toBackupPreferences(), + ) + } + } + + @Suppress("UNCHECKED_CAST") + private fun Map.toBackupPreferences(): List { + return this.filterKeys { !Preference.isPrivate(it) } + .mapNotNull { (key, value) -> + when (value) { + is Int -> BackupPreference(key, IntPreferenceValue(value)) + is Long -> BackupPreference(key, LongPreferenceValue(value)) + is Float -> BackupPreference(key, FloatPreferenceValue(value)) + is String -> BackupPreference(key, StringPreferenceValue(value)) + is Boolean -> BackupPreference(key, BooleanPreferenceValue(value)) + is Set<*> -> (value as? Set)?.let { + BackupPreference(key, StringSetPreferenceValue(it)) + } + else -> null + } + } + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupManager.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupManager.kt deleted file mode 100644 index 890f32bbb..000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupManager.kt +++ /dev/null @@ -1,646 +0,0 @@ -package eu.kanade.tachiyomi.data.backup - -import android.Manifest -import android.content.Context -import android.net.Uri -import com.hippo.unifile.UniFile -import eu.kanade.domain.chapter.model.copyFrom -import eu.kanade.tachiyomi.R -import eu.kanade.tachiyomi.data.backup.BackupConst.BACKUP_APP_PREFS -import eu.kanade.tachiyomi.data.backup.BackupConst.BACKUP_APP_PREFS_MASK -import eu.kanade.tachiyomi.data.backup.BackupConst.BACKUP_CATEGORY -import eu.kanade.tachiyomi.data.backup.BackupConst.BACKUP_CATEGORY_MASK -import eu.kanade.tachiyomi.data.backup.BackupConst.BACKUP_CHAPTER -import eu.kanade.tachiyomi.data.backup.BackupConst.BACKUP_CHAPTER_MASK -import eu.kanade.tachiyomi.data.backup.BackupConst.BACKUP_SOURCE_PREFS -import eu.kanade.tachiyomi.data.backup.BackupConst.BACKUP_SOURCE_PREFS_MASK -import eu.kanade.tachiyomi.data.backup.BackupConst.BACKUP_HISTORY -import eu.kanade.tachiyomi.data.backup.BackupConst.BACKUP_HISTORY_MASK -import eu.kanade.tachiyomi.data.backup.BackupConst.BACKUP_TRACK -import eu.kanade.tachiyomi.data.backup.BackupConst.BACKUP_TRACK_MASK -import eu.kanade.tachiyomi.data.backup.models.Backup -import eu.kanade.tachiyomi.data.backup.models.BackupCategory -import eu.kanade.tachiyomi.data.backup.models.BackupSourcePreferences -import eu.kanade.tachiyomi.data.backup.models.BackupHistory -import eu.kanade.tachiyomi.data.backup.models.BackupManga -import eu.kanade.tachiyomi.data.backup.models.BackupPreference -import eu.kanade.tachiyomi.data.backup.models.BackupSerializer -import eu.kanade.tachiyomi.data.backup.models.BackupSource -import eu.kanade.tachiyomi.data.backup.models.BooleanPreferenceValue -import eu.kanade.tachiyomi.data.backup.models.FloatPreferenceValue -import eu.kanade.tachiyomi.data.backup.models.IntPreferenceValue -import eu.kanade.tachiyomi.data.backup.models.LongPreferenceValue -import eu.kanade.tachiyomi.data.backup.models.StringPreferenceValue -import eu.kanade.tachiyomi.data.backup.models.StringSetPreferenceValue -import eu.kanade.tachiyomi.data.backup.models.backupCategoryMapper -import eu.kanade.tachiyomi.data.backup.models.backupChapterMapper -import eu.kanade.tachiyomi.data.backup.models.backupTrackMapper -import eu.kanade.tachiyomi.source.ConfigurableSource -import eu.kanade.tachiyomi.source.model.copyFrom -import eu.kanade.tachiyomi.source.preferenceKey -import eu.kanade.tachiyomi.source.sourcePreferences -import eu.kanade.tachiyomi.util.system.hasPermission -import kotlinx.serialization.protobuf.ProtoBuf -import logcat.LogPriority -import okio.buffer -import okio.gzip -import okio.sink -import tachiyomi.core.preference.Preference -import tachiyomi.core.preference.PreferenceStore -import tachiyomi.core.util.system.logcat -import tachiyomi.data.DatabaseHandler -import tachiyomi.data.Manga_sync -import tachiyomi.data.Mangas -import tachiyomi.data.UpdateStrategyColumnAdapter -import tachiyomi.domain.backup.service.BackupPreferences -import tachiyomi.domain.category.interactor.GetCategories -import tachiyomi.domain.category.model.Category -import tachiyomi.domain.history.interactor.GetHistory -import tachiyomi.domain.history.model.HistoryUpdate -import tachiyomi.domain.library.service.LibraryPreferences -import tachiyomi.domain.manga.interactor.GetFavorites -import tachiyomi.domain.manga.model.Manga -import tachiyomi.domain.source.service.SourceManager -import uy.kohesive.injekt.Injekt -import uy.kohesive.injekt.api.get -import java.io.FileOutputStream -import java.util.Date -import kotlin.math.max - -class BackupManager( - private val context: Context, -) { - - private val handler: DatabaseHandler = Injekt.get() - private val sourceManager: SourceManager = Injekt.get() - private val backupPreferences: BackupPreferences = Injekt.get() - private val libraryPreferences: LibraryPreferences = Injekt.get() - private val getCategories: GetCategories = Injekt.get() - private val getFavorites: GetFavorites = Injekt.get() - private val getHistory: GetHistory = Injekt.get() - private val preferenceStore: PreferenceStore = Injekt.get() - - internal val parser = ProtoBuf - - /** - * Create backup file from database - * - * @param uri path of Uri - * @param isAutoBackup backup called from scheduled backup job - */ - suspend fun createBackup(uri: Uri, flags: Int, isAutoBackup: Boolean): String { - if (!context.hasPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE)) { - throw IllegalStateException(context.getString(R.string.missing_storage_permission)) - } - - val databaseManga = getFavorites.await() - val backup = Backup( - backupMangas(databaseManga, flags), - backupCategories(flags), - emptyList(), - prepExtensionInfoForSync(databaseManga), - backupAppPreferences(flags), - backupSourcePreferences(flags), - ) - - var file: UniFile? = null - try { - file = ( - if (isAutoBackup) { - // Get dir of file and create - var dir = UniFile.fromUri(context, uri) - dir = dir.createDirectory("automatic") - - // Delete older backups - val numberOfBackups = backupPreferences.numberOfBackups().get() - dir.listFiles { _, filename -> Backup.filenameRegex.matches(filename) } - .orEmpty() - .sortedByDescending { it.name } - .drop(numberOfBackups - 1) - .forEach { it.delete() } - - // Create new file to place backup - dir.createFile(Backup.getFilename()) - } else { - UniFile.fromUri(context, uri) - } - ) - ?: throw Exception(context.getString(R.string.create_backup_file_error)) - - if (!file.isFile) { - throw IllegalStateException("Failed to get handle on a backup file") - } - - val byteArray = parser.encodeToByteArray(BackupSerializer, backup) - if (byteArray.isEmpty()) { - throw IllegalStateException(context.getString(R.string.empty_backup_error)) - } - - file.openOutputStream().also { - // Force overwrite old file - (it as? FileOutputStream)?.channel?.truncate(0) - }.sink().gzip().buffer().use { it.write(byteArray) } - val fileUri = file.uri - - // Make sure it's a valid backup file - BackupFileValidator().validate(context, fileUri) - - return fileUri.toString() - } catch (e: Exception) { - logcat(LogPriority.ERROR, e) - file?.delete() - throw e - } - } - - private fun prepExtensionInfoForSync(mangas: List): List { - return mangas - .asSequence() - .map(Manga::source) - .distinct() - .map(sourceManager::getOrStub) - .map(BackupSource::copyFrom) - .toList() - } - - /** - * Backup the categories of library - * - * @return list of [BackupCategory] to be backed up - */ - private suspend fun backupCategories(options: Int): List { - // Check if user wants category information in backup - return if (options and BACKUP_CATEGORY_MASK == BACKUP_CATEGORY) { - getCategories.await() - .filterNot(Category::isSystemCategory) - .map(backupCategoryMapper) - } else { - emptyList() - } - } - - private suspend fun backupMangas(mangas: List, flags: Int): List { - return mangas.map { - backupManga(it, flags) - } - } - - /** - * Convert a manga to Json - * - * @param manga manga that gets converted - * @param options options for the backup - * @return [BackupManga] containing manga in a serializable form - */ - private suspend fun backupManga(manga: Manga, options: Int): BackupManga { - // Entry for this manga - val mangaObject = BackupManga.copyFrom(manga) - - // Check if user wants chapter information in backup - if (options and BACKUP_CHAPTER_MASK == BACKUP_CHAPTER) { - // Backup all the chapters - val chapters = handler.awaitList { chaptersQueries.getChaptersByMangaId(manga.id, backupChapterMapper) } - if (chapters.isNotEmpty()) { - mangaObject.chapters = chapters - } - } - - // Check if user wants category information in backup - if (options and BACKUP_CATEGORY_MASK == BACKUP_CATEGORY) { - // Backup categories for this manga - val categoriesForManga = getCategories.await(manga.id) - if (categoriesForManga.isNotEmpty()) { - mangaObject.categories = categoriesForManga.map { it.order } - } - } - - // Check if user wants track information in backup - if (options and BACKUP_TRACK_MASK == BACKUP_TRACK) { - val tracks = handler.awaitList { manga_syncQueries.getTracksByMangaId(manga.id, backupTrackMapper) } - if (tracks.isNotEmpty()) { - mangaObject.tracking = tracks - } - } - - // Check if user wants history information in backup - if (options and BACKUP_HISTORY_MASK == BACKUP_HISTORY) { - val historyByMangaId = getHistory.await(manga.id) - if (historyByMangaId.isNotEmpty()) { - val history = historyByMangaId.map { history -> - val chapter = handler.awaitOne { chaptersQueries.getChapterById(history.chapterId) } - BackupHistory(chapter.url, history.readAt?.time ?: 0L, history.readDuration) - } - if (history.isNotEmpty()) { - mangaObject.history = history - } - } - } - - return mangaObject - } - - private fun backupAppPreferences(flags: Int): List { - if (flags and BACKUP_APP_PREFS_MASK != BACKUP_APP_PREFS) return emptyList() - - return preferenceStore.getAll().toBackupPreferences() - } - - private fun backupSourcePreferences(flags: Int): List { - if (flags and BACKUP_SOURCE_PREFS_MASK != BACKUP_SOURCE_PREFS) return emptyList() - - return sourceManager.getOnlineSources() - .filterIsInstance() - .map { - BackupSourcePreferences( - it.preferenceKey(), - it.sourcePreferences().all.toBackupPreferences() - ) - } - } - - @Suppress("UNCHECKED_CAST") - private fun Map.toBackupPreferences(): List { - return this.filterKeys { !Preference.isPrivate(it) } - .mapNotNull { (key, value) -> - when (value) { - is Int -> BackupPreference(key, IntPreferenceValue(value)) - is Long -> BackupPreference(key, LongPreferenceValue(value)) - is Float -> BackupPreference(key, FloatPreferenceValue(value)) - is String -> BackupPreference(key, StringPreferenceValue(value)) - is Boolean -> BackupPreference(key, BooleanPreferenceValue(value)) - is Set<*> -> (value as? Set)?.let { - BackupPreference(key, StringSetPreferenceValue(it)) - } - else -> null - } - } - } - - internal suspend fun restoreExistingManga(manga: Manga, dbManga: Mangas): Manga { - var updatedManga = manga.copy(id = dbManga._id) - updatedManga = updatedManga.copyFrom(dbManga) - updateManga(updatedManga) - return updatedManga - } - - /** - * Fetches manga information - * - * @param manga manga that needs updating - * @return Updated manga info. - */ - internal suspend fun restoreNewManga(manga: Manga): Manga { - return manga.copy( - initialized = manga.description != null, - id = insertManga(manga), - ) - } - - /** - * Restore the categories from Json - * - * @param backupCategories list containing categories - */ - internal suspend fun restoreCategories(backupCategories: List) { - // Get categories from file and from db - val dbCategories = getCategories.await() - - val categories = backupCategories.map { - var category = it.getCategory() - var found = false - for (dbCategory in dbCategories) { - // If the category is already in the db, assign the id to the file's category - // and do nothing - if (category.name == dbCategory.name) { - category = category.copy(id = dbCategory.id) - found = true - break - } - } - if (!found) { - // Let the db assign the id - val id = handler.awaitOneExecutable { - categoriesQueries.insert(category.name, category.order, category.flags) - categoriesQueries.selectLastInsertedRowId() - } - category = category.copy(id = id) - } - - category - } - - libraryPreferences.categorizedDisplaySettings().set( - (dbCategories + categories) - .distinctBy { it.flags } - .size > 1, - ) - } - - /** - * Restores the categories a manga is in. - * - * @param manga the manga whose categories have to be restored. - * @param categories the categories to restore. - */ - internal suspend fun restoreCategories(manga: Manga, categories: List, backupCategories: List) { - val dbCategories = getCategories.await() - val mangaCategoriesToUpdate = mutableListOf>() - - categories.forEach { backupCategoryOrder -> - backupCategories.firstOrNull { - it.order == backupCategoryOrder.toLong() - }?.let { backupCategory -> - dbCategories.firstOrNull { dbCategory -> - dbCategory.name == backupCategory.name - }?.let { dbCategory -> - mangaCategoriesToUpdate.add(Pair(manga.id, dbCategory.id)) - } - } - } - - // Update database - if (mangaCategoriesToUpdate.isNotEmpty()) { - handler.await(true) { - mangas_categoriesQueries.deleteMangaCategoryByMangaId(manga.id) - mangaCategoriesToUpdate.forEach { (mangaId, categoryId) -> - mangas_categoriesQueries.insert(mangaId, categoryId) - } - } - } - } - - /** - * Restore history from Json - * - * @param history list containing history to be restored - */ - internal suspend fun restoreHistory(history: List) { - // List containing history to be updated - val toUpdate = mutableListOf() - for ((url, lastRead, readDuration) in history) { - var dbHistory = handler.awaitOneOrNull { historyQueries.getHistoryByChapterUrl(url) } - // Check if history already in database and update - if (dbHistory != null) { - dbHistory = dbHistory.copy( - last_read = Date(max(lastRead, dbHistory.last_read?.time ?: 0L)), - time_read = max(readDuration, dbHistory.time_read) - dbHistory.time_read, - ) - toUpdate.add( - HistoryUpdate( - chapterId = dbHistory.chapter_id, - readAt = dbHistory.last_read!!, - sessionReadDuration = dbHistory.time_read, - ), - ) - } else { - // If not in database create - handler - .awaitOneOrNull { chaptersQueries.getChapterByUrl(url) } - ?.let { - toUpdate.add( - HistoryUpdate( - chapterId = it._id, - readAt = Date(lastRead), - sessionReadDuration = readDuration, - ), - ) - } - } - } - handler.await(true) { - toUpdate.forEach { payload -> - historyQueries.upsert( - payload.chapterId, - payload.readAt, - payload.sessionReadDuration, - ) - } - } - } - - /** - * Restores the sync of a manga. - * - * @param manga the manga whose sync have to be restored. - * @param tracks the track list to restore. - */ - internal suspend fun restoreTracking(manga: Manga, tracks: List) { - // Get tracks from database - val dbTracks = handler.awaitList { manga_syncQueries.getTracksByMangaId(manga.id) } - val toUpdate = mutableListOf() - val toInsert = mutableListOf() - - tracks - // Fix foreign keys with the current manga id - .map { it.copy(mangaId = manga.id) } - .forEach { track -> - var isInDatabase = false - for (dbTrack in dbTracks) { - if (track.syncId == dbTrack.sync_id) { - // The sync is already in the db, only update its fields - var temp = dbTrack - if (track.remoteId != dbTrack.remote_id) { - temp = temp.copy(remote_id = track.remoteId) - } - if (track.libraryId != dbTrack.library_id) { - temp = temp.copy(library_id = track.libraryId) - } - temp = temp.copy(last_chapter_read = max(dbTrack.last_chapter_read, track.lastChapterRead)) - isInDatabase = true - toUpdate.add(temp) - break - } - } - if (!isInDatabase) { - // Insert new sync. Let the db assign the id - toInsert.add(track.copy(id = 0)) - } - } - - // Update database - if (toUpdate.isNotEmpty()) { - handler.await(true) { - toUpdate.forEach { track -> - manga_syncQueries.update( - track.manga_id, - track.sync_id, - track.remote_id, - track.library_id, - track.title, - track.last_chapter_read, - track.total_chapters, - track.status, - track.score, - track.remote_url, - track.start_date, - track.finish_date, - track._id, - ) - } - } - } - if (toInsert.isNotEmpty()) { - handler.await(true) { - toInsert.forEach { track -> - manga_syncQueries.insert( - track.mangaId, - track.syncId, - track.remoteId, - track.libraryId, - track.title, - track.lastChapterRead, - track.totalChapters, - track.status, - track.score, - track.remoteUrl, - track.startDate, - track.finishDate, - ) - } - } - } - } - - internal suspend fun restoreChapters(manga: Manga, chapters: List) { - val dbChapters = handler.awaitList { chaptersQueries.getChaptersByMangaId(manga.id) } - - val processed = chapters.map { chapter -> - var updatedChapter = chapter - val dbChapter = dbChapters.find { it.url == updatedChapter.url } - if (dbChapter != null) { - updatedChapter = updatedChapter.copy(id = dbChapter._id) - updatedChapter = updatedChapter.copyFrom(dbChapter) - if (dbChapter.read && !updatedChapter.read) { - updatedChapter = updatedChapter.copy(read = true, lastPageRead = dbChapter.last_page_read) - } else if (updatedChapter.lastPageRead == 0L && dbChapter.last_page_read != 0L) { - updatedChapter = updatedChapter.copy(lastPageRead = dbChapter.last_page_read) - } - if (!updatedChapter.bookmark && dbChapter.bookmark) { - updatedChapter = updatedChapter.copy(bookmark = true) - } - } - - updatedChapter.copy(mangaId = manga.id) - } - - val newChapters = processed.groupBy { it.id > 0 } - newChapters[true]?.let { updateKnownChapters(it) } - newChapters[false]?.let { insertChapters(it) } - } - - /** - * Returns manga - * - * @return [Manga], null if not found - */ - internal suspend fun getMangaFromDatabase(url: String, source: Long): Mangas? { - return handler.awaitOneOrNull { mangasQueries.getMangaByUrlAndSource(url, source) } - } - - /** - * Inserts manga and returns id - * - * @return id of [Manga], null if not found - */ - private suspend fun insertManga(manga: Manga): Long { - return handler.awaitOneExecutable(true) { - mangasQueries.insert( - 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, - ) - mangasQueries.selectLastInsertedRowId() - } - } - - suspend fun updateManga(manga: Manga): Long { - handler.await(true) { - mangasQueries.update( - source = manga.source, - url = manga.url, - artist = manga.artist, - author = manga.author, - description = manga.description, - genre = manga.genre?.joinToString(separator = ", "), - title = manga.title, - status = manga.status, - thumbnailUrl = manga.thumbnailUrl, - favorite = manga.favorite, - lastUpdate = manga.lastUpdate, - nextUpdate = null, - calculateInterval = null, - initialized = manga.initialized, - viewer = manga.viewerFlags, - chapterFlags = manga.chapterFlags, - coverLastModified = manga.coverLastModified, - dateAdded = manga.dateAdded, - mangaId = manga.id, - updateStrategy = manga.updateStrategy.let(UpdateStrategyColumnAdapter::encode), - ) - } - return manga.id - } - - /** - * Inserts list of chapters - */ - private suspend fun insertChapters(chapters: List) { - handler.await(true) { - chapters.forEach { chapter -> - chaptersQueries.insert( - chapter.mangaId, - chapter.url, - chapter.name, - chapter.scanlator, - chapter.read, - chapter.bookmark, - chapter.lastPageRead, - chapter.chapterNumber, - chapter.sourceOrder, - chapter.dateFetch, - chapter.dateUpload, - ) - } - } - } - - /** - * Updates a list of chapters with known database ids - */ - private suspend fun updateKnownChapters(chapters: List) { - handler.await(true) { - chapters.forEach { chapter -> - chaptersQueries.update( - mangaId = null, - url = null, - name = null, - scanlator = null, - read = chapter.read, - bookmark = chapter.bookmark, - lastPageRead = chapter.lastPageRead, - chapterNumber = null, - sourceOrder = null, - dateFetch = null, - dateUpload = null, - chapterId = chapter.id, - ) - } - } - } -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupRestorer.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupRestorer.kt index 2cd4bbc4a..c912be3f9 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupRestorer.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupRestorer.kt @@ -2,6 +2,7 @@ package eu.kanade.tachiyomi.data.backup import android.content.Context import android.net.Uri +import eu.kanade.domain.chapter.model.copyFrom import eu.kanade.domain.manga.interactor.UpdateManga import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.data.backup.models.BackupCategory @@ -16,6 +17,7 @@ import eu.kanade.tachiyomi.data.backup.models.IntPreferenceValue import eu.kanade.tachiyomi.data.backup.models.LongPreferenceValue import eu.kanade.tachiyomi.data.backup.models.StringPreferenceValue import eu.kanade.tachiyomi.data.backup.models.StringSetPreferenceValue +import eu.kanade.tachiyomi.source.model.copyFrom import eu.kanade.tachiyomi.source.sourcePreferences import eu.kanade.tachiyomi.util.BackupUtil import eu.kanade.tachiyomi.util.system.createFileInCacheDir @@ -23,7 +25,14 @@ import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.isActive import tachiyomi.core.preference.AndroidPreferenceStore import tachiyomi.core.preference.PreferenceStore +import tachiyomi.data.DatabaseHandler +import tachiyomi.data.Manga_sync +import tachiyomi.data.Mangas +import tachiyomi.data.UpdateStrategyColumnAdapter +import tachiyomi.domain.category.interactor.GetCategories import tachiyomi.domain.chapter.model.Chapter +import tachiyomi.domain.history.model.HistoryUpdate +import tachiyomi.domain.library.service.LibraryPreferences import tachiyomi.domain.manga.interactor.FetchInterval import tachiyomi.domain.manga.model.Manga import tachiyomi.domain.track.model.Track @@ -34,20 +43,24 @@ import java.text.SimpleDateFormat import java.time.ZonedDateTime import java.util.Date import java.util.Locale +import kotlin.math.max class BackupRestorer( private val context: Context, private val notifier: BackupNotifier, ) { + + private val handler: DatabaseHandler = Injekt.get() private val updateManga: UpdateManga = Injekt.get() + private val getCategories: GetCategories = Injekt.get() private val fetchInterval: FetchInterval = Injekt.get() + private val preferenceStore: PreferenceStore = Injekt.get() + private val libraryPreferences: LibraryPreferences = Injekt.get() private var now = ZonedDateTime.now() private var currentFetchWindow = fetchInterval.getWindow(now) - private var backupManager = BackupManager(context) - private var restoreAmount = 0 private var restoreProgress = 0 @@ -102,7 +115,7 @@ class BackupRestorer( private suspend fun performRestore(uri: Uri, sync: Boolean): Boolean { val backup = BackupUtil.decodeBackup(context, uri) - restoreAmount = backup.backupManga.size + 1 // +1 for categories + restoreAmount = backup.backupManga.size + 3 // +3 for categories, app prefs, source prefs // Restore categories if (backup.backupCategories.isNotEmpty()) { @@ -134,7 +147,38 @@ class BackupRestorer( } private suspend fun restoreCategories(backupCategories: List) { - backupManager.restoreCategories(backupCategories) + // Get categories from file and from db + val dbCategories = getCategories.await() + + val categories = backupCategories.map { + var category = it.getCategory() + var found = false + for (dbCategory in dbCategories) { + // If the category is already in the db, assign the id to the file's category + // and do nothing + if (category.name == dbCategory.name) { + category = category.copy(id = dbCategory.id) + found = true + break + } + } + if (!found) { + // Let the db assign the id + val id = handler.awaitOneExecutable { + categoriesQueries.insert(category.name, category.order, category.flags) + categoriesQueries.selectLastInsertedRowId() + } + category = category.copy(id = id) + } + + category + } + + libraryPreferences.categorizedDisplaySettings().set( + (dbCategories + categories) + .distinctBy { it.flags } + .size > 1, + ) restoreProgress += 1 showRestoreProgress(restoreProgress, restoreAmount, context.getString(R.string.categories), context.getString(R.string.restoring_backup)) @@ -149,14 +193,14 @@ class BackupRestorer( val tracks = backupManga.getTrackingImpl() try { - val dbManga = backupManager.getMangaFromDatabase(manga.url, manga.source) + val dbManga = getMangaFromDatabase(manga.url, manga.source) val restoredManga = if (dbManga == null) { // Manga not in database restoreExistingManga(manga, chapters, categories, history, tracks, backupCategories) } else { // Manga in database // Copy information from manga already in database - val updatedManga = backupManager.restoreExistingManga(manga, dbManga) + val updatedManga = restoreExistingManga(manga, dbManga) // Fetch rest of manga information restoreNewManga(updatedManga, chapters, categories, history, tracks, backupCategories) } @@ -174,6 +218,50 @@ class BackupRestorer( } } + /** + * Returns manga + * + * @return [Manga], null if not found + */ + private suspend fun getMangaFromDatabase(url: String, source: Long): Mangas? { + return handler.awaitOneOrNull { mangasQueries.getMangaByUrlAndSource(url, source) } + } + + private suspend fun restoreExistingManga(manga: Manga, dbManga: Mangas): Manga { + var updatedManga = manga.copy(id = dbManga._id) + updatedManga = updatedManga.copyFrom(dbManga) + updateManga(updatedManga) + return updatedManga + } + + private suspend fun updateManga(manga: Manga): Long { + handler.await(true) { + mangasQueries.update( + source = manga.source, + url = manga.url, + artist = manga.artist, + author = manga.author, + description = manga.description, + genre = manga.genre?.joinToString(separator = ", "), + title = manga.title, + status = manga.status, + thumbnailUrl = manga.thumbnailUrl, + favorite = manga.favorite, + lastUpdate = manga.lastUpdate, + nextUpdate = null, + calculateInterval = null, + initialized = manga.initialized, + viewer = manga.viewerFlags, + chapterFlags = manga.chapterFlags, + coverLastModified = manga.coverLastModified, + dateAdded = manga.dateAdded, + mangaId = manga.id, + updateStrategy = manga.updateStrategy.let(UpdateStrategyColumnAdapter::encode), + ) + } + return manga.id + } + /** * Fetches manga information * @@ -189,12 +277,131 @@ class BackupRestorer( tracks: List, backupCategories: List, ): Manga { - val fetchedManga = backupManager.restoreNewManga(manga) - backupManager.restoreChapters(fetchedManga, chapters) + val fetchedManga = restoreNewManga(manga) + restoreChapters(fetchedManga, chapters) restoreExtras(fetchedManga, categories, history, tracks, backupCategories) return fetchedManga } + private suspend fun restoreChapters(manga: Manga, chapters: List) { + val dbChapters = handler.awaitList { chaptersQueries.getChaptersByMangaId(manga.id) } + + val processed = chapters.map { chapter -> + var updatedChapter = chapter + val dbChapter = dbChapters.find { it.url == updatedChapter.url } + if (dbChapter != null) { + updatedChapter = updatedChapter.copy(id = dbChapter._id) + updatedChapter = updatedChapter.copyFrom(dbChapter) + if (dbChapter.read && !updatedChapter.read) { + updatedChapter = updatedChapter.copy(read = true, lastPageRead = dbChapter.last_page_read) + } else if (updatedChapter.lastPageRead == 0L && dbChapter.last_page_read != 0L) { + updatedChapter = updatedChapter.copy(lastPageRead = dbChapter.last_page_read) + } + if (!updatedChapter.bookmark && dbChapter.bookmark) { + updatedChapter = updatedChapter.copy(bookmark = true) + } + } + + updatedChapter.copy(mangaId = manga.id) + } + + val newChapters = processed.groupBy { it.id > 0 } + newChapters[true]?.let { updateKnownChapters(it) } + newChapters[false]?.let { insertChapters(it) } + } + + /** + * Inserts list of chapters + */ + private suspend fun insertChapters(chapters: List) { + handler.await(true) { + chapters.forEach { chapter -> + chaptersQueries.insert( + chapter.mangaId, + chapter.url, + chapter.name, + chapter.scanlator, + chapter.read, + chapter.bookmark, + chapter.lastPageRead, + chapter.chapterNumber, + chapter.sourceOrder, + chapter.dateFetch, + chapter.dateUpload, + ) + } + } + } + + /** + * Updates a list of chapters with known database ids + */ + private suspend fun updateKnownChapters(chapters: List) { + handler.await(true) { + chapters.forEach { chapter -> + chaptersQueries.update( + mangaId = null, + url = null, + name = null, + scanlator = null, + read = chapter.read, + bookmark = chapter.bookmark, + lastPageRead = chapter.lastPageRead, + chapterNumber = null, + sourceOrder = null, + dateFetch = null, + dateUpload = null, + chapterId = chapter.id, + ) + } + } + } + + /** + * Fetches manga information + * + * @param manga manga that needs updating + * @return Updated manga info. + */ + private suspend fun restoreNewManga(manga: Manga): Manga { + return manga.copy( + initialized = manga.description != null, + id = insertManga(manga), + ) + } + + /** + * Inserts manga and returns id + * + * @return id of [Manga], null if not found + */ + private suspend fun insertManga(manga: Manga): Long { + return handler.awaitOneExecutable(true) { + mangasQueries.insert( + 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, + ) + mangasQueries.selectLastInsertedRowId() + } + } + private suspend fun restoreNewManga( backupManga: Manga, chapters: List, @@ -203,19 +410,187 @@ class BackupRestorer( tracks: List, backupCategories: List, ): Manga { - backupManager.restoreChapters(backupManga, chapters) + restoreChapters(backupManga, chapters) restoreExtras(backupManga, categories, history, tracks, backupCategories) return backupManga } private suspend fun restoreExtras(manga: Manga, categories: List, history: List, tracks: List, backupCategories: List) { - backupManager.restoreCategories(manga, categories, backupCategories) - backupManager.restoreHistory(history) - backupManager.restoreTracking(manga, tracks) + restoreCategories(manga, categories, backupCategories) + restoreHistory(history) + restoreTracking(manga, tracks) + } + + /** + * 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 mangaCategoriesToUpdate = mutableListOf>() + + categories.forEach { backupCategoryOrder -> + backupCategories.firstOrNull { + it.order == backupCategoryOrder.toLong() + }?.let { backupCategory -> + dbCategories.firstOrNull { dbCategory -> + dbCategory.name == backupCategory.name + }?.let { dbCategory -> + mangaCategoriesToUpdate.add(Pair(manga.id, dbCategory.id)) + } + } + } + + // Update database + if (mangaCategoriesToUpdate.isNotEmpty()) { + handler.await(true) { + mangas_categoriesQueries.deleteMangaCategoryByMangaId(manga.id) + mangaCategoriesToUpdate.forEach { (mangaId, categoryId) -> + mangas_categoriesQueries.insert(mangaId, categoryId) + } + } + } + } + + /** + * Restore history from Json + * + * @param history list containing history to be restored + */ + private suspend fun restoreHistory(history: List) { + // List containing history to be updated + val toUpdate = mutableListOf() + for ((url, lastRead, readDuration) in history) { + var dbHistory = handler.awaitOneOrNull { historyQueries.getHistoryByChapterUrl(url) } + // Check if history already in database and update + if (dbHistory != null) { + dbHistory = dbHistory.copy( + last_read = Date(max(lastRead, dbHistory.last_read?.time ?: 0L)), + time_read = max(readDuration, dbHistory.time_read) - dbHistory.time_read, + ) + toUpdate.add( + HistoryUpdate( + chapterId = dbHistory.chapter_id, + readAt = dbHistory.last_read!!, + sessionReadDuration = dbHistory.time_read, + ), + ) + } else { + // If not in database create + handler + .awaitOneOrNull { chaptersQueries.getChapterByUrl(url) } + ?.let { + toUpdate.add( + HistoryUpdate( + chapterId = it._id, + readAt = Date(lastRead), + sessionReadDuration = readDuration, + ), + ) + } + } + } + handler.await(true) { + toUpdate.forEach { payload -> + historyQueries.upsert( + payload.chapterId, + payload.readAt, + payload.sessionReadDuration, + ) + } + } + } + + /** + * Restores the sync of a manga. + * + * @param manga the manga whose sync have to be restored. + * @param tracks the track list to restore. + */ + private suspend fun restoreTracking(manga: Manga, tracks: List) { + // Get tracks from database + val dbTracks = handler.awaitList { manga_syncQueries.getTracksByMangaId(manga.id) } + val toUpdate = mutableListOf() + val toInsert = mutableListOf() + + tracks + // Fix foreign keys with the current manga id + .map { it.copy(mangaId = manga.id) } + .forEach { track -> + var isInDatabase = false + for (dbTrack in dbTracks) { + if (track.syncId == dbTrack.sync_id) { + // The sync is already in the db, only update its fields + var temp = dbTrack + if (track.remoteId != dbTrack.remote_id) { + temp = temp.copy(remote_id = track.remoteId) + } + if (track.libraryId != dbTrack.library_id) { + temp = temp.copy(library_id = track.libraryId) + } + temp = temp.copy(last_chapter_read = max(dbTrack.last_chapter_read, track.lastChapterRead)) + isInDatabase = true + toUpdate.add(temp) + break + } + } + if (!isInDatabase) { + // Insert new sync. Let the db assign the id + toInsert.add(track.copy(id = 0)) + } + } + + // Update database + if (toUpdate.isNotEmpty()) { + handler.await(true) { + toUpdate.forEach { track -> + manga_syncQueries.update( + track.manga_id, + track.sync_id, + track.remote_id, + track.library_id, + track.title, + track.last_chapter_read, + track.total_chapters, + track.status, + track.score, + track.remote_url, + track.start_date, + track.finish_date, + track._id, + ) + } + } + } + if (toInsert.isNotEmpty()) { + handler.await(true) { + toInsert.forEach { track -> + manga_syncQueries.insert( + track.mangaId, + track.syncId, + track.remoteId, + track.libraryId, + track.title, + track.lastChapterRead, + track.totalChapters, + track.status, + track.score, + track.remoteUrl, + track.startDate, + track.finishDate, + ) + } + } + } } private fun restoreAppPreferences(preferences: List) { restorePreferences(preferences, preferenceStore) + + restoreProgress += 1 + showRestoreProgress(restoreProgress, restoreAmount, context.getString(R.string.app_settings), context.getString(R.string.restoring_backup)) } private fun restoreSourcePreferences(preferences: List) { @@ -223,6 +598,9 @@ class BackupRestorer( val sourcePrefs = AndroidPreferenceStore(context, sourcePreferences(it.sourceKey)) restorePreferences(it.prefs, sourcePrefs) } + + restoreProgress += 1 + showRestoreProgress(restoreProgress, restoreAmount, context.getString(R.string.source_settings), context.getString(R.string.restoring_backup)) } private fun restorePreferences( diff --git a/app/src/main/java/eu/kanade/tachiyomi/util/BackupUtil.kt b/app/src/main/java/eu/kanade/tachiyomi/util/BackupUtil.kt index 0abd22f13..e67d7cd2f 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/util/BackupUtil.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/util/BackupUtil.kt @@ -2,7 +2,7 @@ package eu.kanade.tachiyomi.util import android.content.Context import android.net.Uri -import eu.kanade.tachiyomi.data.backup.BackupManager +import eu.kanade.tachiyomi.data.backup.BackupCreator import eu.kanade.tachiyomi.data.backup.models.Backup import eu.kanade.tachiyomi.data.backup.models.BackupSerializer import okio.buffer @@ -14,7 +14,7 @@ object BackupUtil { * Decode a potentially-gzipped backup. */ fun decodeBackup(context: Context, uri: Uri): Backup { - val backupManager = BackupManager(context) + val backupCreator = BackupCreator(context) val backupStringSource = context.contentResolver.openInputStream(uri)!!.source().buffer() @@ -27,6 +27,6 @@ object BackupUtil { backupStringSource }.use { it.readByteArray() } - return backupManager.parser.decodeFromByteArray(BackupSerializer, backupString) + return backupCreator.parser.decodeFromByteArray(BackupSerializer, backupString) } }