diff --git a/app/build.gradle b/app/build.gradle index 4d293f5a40..3985fb1a38 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -185,7 +185,9 @@ dependencies { implementation "com.squareup.retrofit2:adapter-rxjava:$retrofit_version" // JSON - implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:1.0.1" + final kotlin_serialization_version = '1.0.1' + implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:$kotlin_serialization_version" + implementation "org.jetbrains.kotlinx:kotlinx-serialization-protobuf:$kotlin_serialization_version" implementation 'com.google.code.gson:gson:2.8.6' implementation 'com.github.salomonbrys.kotson:kotson:2.5.0' diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupConst.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupConst.kt index df76e47d11..1b396bc11b 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupConst.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupConst.kt @@ -7,4 +7,9 @@ object BackupConst { private const val NAME = "BackupRestoreServices" const val EXTRA_URI = "$ID.$NAME.EXTRA_URI" const val EXTRA_FLAGS = "$ID.$NAME.EXTRA_FLAGS" + const val EXTRA_MODE = "$ID.$NAME.EXTRA_MODE" + const val EXTRA_TYPE = "$ID.$NAME.EXTRA_TYPE" + + const val BACKUP_TYPE_LEGACY = 0 + const val BACKUP_TYPE_FULL = 1 } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupCreateService.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupCreateService.kt index a7c2100bec..5f2279d90f 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupCreateService.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupCreateService.kt @@ -9,6 +9,9 @@ import android.os.PowerManager import androidx.core.content.ContextCompat import androidx.core.net.toUri import com.hippo.unifile.UniFile +import eu.kanade.tachiyomi.data.backup.full.FullBackupManager +import eu.kanade.tachiyomi.data.backup.legacy.LegacyBackupManager +import eu.kanade.tachiyomi.data.backup.models.AbstractBackupManager import eu.kanade.tachiyomi.data.notification.Notifications import eu.kanade.tachiyomi.util.system.acquireWakeLock import eu.kanade.tachiyomi.util.system.isServiceRunning @@ -46,11 +49,12 @@ class BackupCreateService : Service() { * @param uri path of Uri * @param flags determines what to backup */ - fun start(context: Context, uri: Uri, flags: Int) { + fun start(context: Context, uri: Uri, flags: Int, type: Int) { if (!isRunning(context)) { val intent = Intent(context, BackupCreateService::class.java).apply { putExtra(BackupConst.EXTRA_URI, uri) putExtra(BackupConst.EXTRA_FLAGS, flags) + putExtra(BackupConst.EXTRA_TYPE, type) } ContextCompat.startForegroundService(context, intent) } @@ -62,7 +66,7 @@ class BackupCreateService : Service() { */ private lateinit var wakeLock: PowerManager.WakeLock - private lateinit var backupManager: BackupManager + private lateinit var backupManager: AbstractBackupManager private lateinit var notifier: BackupNotifier override fun onCreate() { @@ -101,7 +105,8 @@ class BackupCreateService : Service() { try { val uri = intent.getParcelableExtra(BackupConst.EXTRA_URI) val backupFlags = intent.getIntExtra(BackupConst.EXTRA_FLAGS, 0) - backupManager = BackupManager(this) + val backupType = intent.getIntExtra(BackupConst.EXTRA_TYPE, BackupConst.BACKUP_TYPE_LEGACY) + backupManager = if (backupType == BackupConst.BACKUP_TYPE_FULL) FullBackupManager(this) else LegacyBackupManager(this) val backupFileUri = backupManager.createBackup(uri, backupFlags, false)?.toUri() val unifile = UniFile.fromUri(this, backupFileUri) diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupCreatorJob.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupCreatorJob.kt index c78f87011a..e528ff9057 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupCreatorJob.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupCreatorJob.kt @@ -7,6 +7,8 @@ import androidx.work.PeriodicWorkRequestBuilder import androidx.work.WorkManager import androidx.work.Worker import androidx.work.WorkerParameters +import eu.kanade.tachiyomi.data.backup.full.FullBackupManager +import eu.kanade.tachiyomi.data.backup.legacy.LegacyBackupManager import eu.kanade.tachiyomi.data.preference.PreferencesHelper import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.api.get @@ -17,11 +19,13 @@ class BackupCreatorJob(private val context: Context, workerParams: WorkerParamet override fun doWork(): Result { val preferences = Injekt.get() - val backupManager = BackupManager(context) + val backupManager = FullBackupManager(context) + val legacyBackupManager = if (preferences.createLegacyBackup().get()) LegacyBackupManager(context) else null val uri = preferences.backupsDirectory().get().toUri() val flags = BackupCreateService.BACKUP_ALL return try { backupManager.createBackup(uri, flags, true) + legacyBackupManager?.createBackup(uri, flags, true) Result.success() } catch (e: Exception) { Result.failure() diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupNotifier.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupNotifier.kt index d7e24e6eb2..5aa75365ea 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupNotifier.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupNotifier.kt @@ -15,7 +15,7 @@ import uy.kohesive.injekt.injectLazy import java.io.File import java.util.concurrent.TimeUnit -internal class BackupNotifier(private val context: Context) { +class BackupNotifier(private val context: Context) { private val preferences: PreferencesHelper by injectLazy() diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupRestoreService.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupRestoreService.kt index 318d09e075..fa94f1fd3b 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupRestoreService.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupRestoreService.kt @@ -7,45 +7,17 @@ import android.net.Uri import android.os.IBinder import android.os.PowerManager import androidx.core.content.ContextCompat -import com.github.salomonbrys.kotson.fromJson -import com.google.gson.JsonArray -import com.google.gson.JsonElement -import com.google.gson.JsonObject -import com.google.gson.JsonParser -import com.google.gson.stream.JsonReader import eu.kanade.tachiyomi.R -import eu.kanade.tachiyomi.data.backup.models.Backup.CATEGORIES -import eu.kanade.tachiyomi.data.backup.models.Backup.CHAPTERS -import eu.kanade.tachiyomi.data.backup.models.Backup.HISTORY -import eu.kanade.tachiyomi.data.backup.models.Backup.MANGA -import eu.kanade.tachiyomi.data.backup.models.Backup.MANGAS -import eu.kanade.tachiyomi.data.backup.models.Backup.TRACK -import eu.kanade.tachiyomi.data.backup.models.Backup.VERSION -import eu.kanade.tachiyomi.data.backup.models.DHistory -import eu.kanade.tachiyomi.data.database.DatabaseHelper -import eu.kanade.tachiyomi.data.database.models.Chapter -import eu.kanade.tachiyomi.data.database.models.ChapterImpl -import eu.kanade.tachiyomi.data.database.models.Manga -import eu.kanade.tachiyomi.data.database.models.MangaImpl -import eu.kanade.tachiyomi.data.database.models.Track -import eu.kanade.tachiyomi.data.database.models.TrackImpl +import eu.kanade.tachiyomi.data.backup.full.FullBackupRestore +import eu.kanade.tachiyomi.data.backup.legacy.LegacyBackupRestore +import eu.kanade.tachiyomi.data.backup.models.AbstractBackupRestore import eu.kanade.tachiyomi.data.notification.Notifications -import eu.kanade.tachiyomi.data.track.TrackManager -import eu.kanade.tachiyomi.source.Source -import eu.kanade.tachiyomi.util.chapter.NoChaptersException import eu.kanade.tachiyomi.util.system.acquireWakeLock import eu.kanade.tachiyomi.util.system.isServiceRunning import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.GlobalScope -import kotlinx.coroutines.Job import kotlinx.coroutines.launch -import rx.Observable import timber.log.Timber -import uy.kohesive.injekt.injectLazy -import java.io.File -import java.text.SimpleDateFormat -import java.util.Date -import java.util.Locale /** * Restores backup from a JSON file. @@ -69,10 +41,12 @@ class BackupRestoreService : Service() { * @param context context of application * @param uri path of Uri */ - fun start(context: Context, uri: Uri) { + fun start(context: Context, uri: Uri, mode: Int, online: Boolean?) { if (!isRunning(context)) { val intent = Intent(context, BackupRestoreService::class.java).apply { putExtra(BackupConst.EXTRA_URI, uri) + putExtra(BackupConst.EXTRA_MODE, mode) + online?.let { putExtra(BackupConst.EXTRA_TYPE, it) } } ContextCompat.startForegroundService(context, intent) } @@ -95,35 +69,9 @@ class BackupRestoreService : Service() { */ private lateinit var wakeLock: PowerManager.WakeLock - private var job: Job? = null - - /** - * The progress of a backup restore - */ - private var restoreProgress = 0 - - /** - * Amount of manga in Json file (needed for restore) - */ - private var restoreAmount = 0 - - /** - * Mapping of source ID to source name from backup data - */ - private var sourceMapping: Map = emptyMap() - - /** - * List containing errors - */ - private val errors = mutableListOf>() - - private lateinit var backupManager: BackupManager + private var backupRestore: AbstractBackupRestore? = null private lateinit var notifier: BackupNotifier - private val db: DatabaseHelper by injectLazy() - - private val trackManager: TrackManager by injectLazy() - override fun onCreate() { super.onCreate() @@ -144,7 +92,7 @@ class BackupRestoreService : Service() { } private fun destroyJob() { - job?.cancel() + backupRestore?.job?.cancel() if (wakeLock.isHeld) { wakeLock.release() } @@ -165,304 +113,30 @@ class BackupRestoreService : Service() { */ override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { val uri = intent?.getParcelableExtra(BackupConst.EXTRA_URI) ?: return START_NOT_STICKY + val mode = intent.getIntExtra(BackupConst.EXTRA_MODE, BackupConst.BACKUP_TYPE_FULL) + val online = intent.getBooleanExtra(BackupConst.EXTRA_TYPE, true) // Cancel any previous job if needed. - job?.cancel() + backupRestore?.job?.cancel() + + backupRestore = if (mode == BackupConst.BACKUP_TYPE_FULL) FullBackupRestore(this, notifier, online) else LegacyBackupRestore(this, notifier) val handler = CoroutineExceptionHandler { _, exception -> Timber.e(exception) - writeErrorLog() + backupRestore?.writeErrorLog() notifier.showRestoreError(exception.message) stopSelf(startId) } - job = GlobalScope.launch(handler) { - if (!restoreBackup(uri)) { + backupRestore?.job = GlobalScope.launch(handler) { + if (backupRestore?.restoreBackup(uri) == false) { notifier.showRestoreError(getString(R.string.restoring_backup_canceled)) } } - job?.invokeOnCompletion { + backupRestore?.job?.invokeOnCompletion { stopSelf(startId) } return START_NOT_STICKY } - - /** - * Restores data from backup file. - * - * @param uri backup file to restore - */ - private fun restoreBackup(uri: Uri): Boolean { - val startTime = System.currentTimeMillis() - - val reader = JsonReader(contentResolver.openInputStream(uri)!!.bufferedReader()) - val json = JsonParser.parseReader(reader).asJsonObject - - // Get parser version - val version = json.get(VERSION)?.asInt ?: 1 - - // Initialize manager - backupManager = BackupManager(this, version) - - val mangasJson = json.get(MANGAS).asJsonArray - - restoreAmount = mangasJson.size() + 1 // +1 for categories - restoreProgress = 0 - errors.clear() - - // Restore categories - json.get(CATEGORIES)?.let { restoreCategories(it) } - - // Store source mapping for error messages - sourceMapping = BackupRestoreValidator.getSourceMapping(json) - - // Restore individual manga - mangasJson.forEach { - if (job?.isActive != true) { - return false - } - - restoreManga(it.asJsonObject) - } - - val endTime = System.currentTimeMillis() - val time = endTime - startTime - - val logFile = writeErrorLog() - - notifier.showRestoreComplete(time, errors.size, logFile.parent, logFile.name) - return true - } - - private fun restoreCategories(categoriesJson: JsonElement) { - db.inTransaction { - backupManager.restoreCategories(categoriesJson.asJsonArray) - } - - restoreProgress += 1 - showRestoreProgress(restoreProgress, restoreAmount, getString(R.string.categories)) - } - - private fun restoreManga(mangaJson: JsonObject) { - val manga = backupManager.parser.fromJson(mangaJson.get(MANGA)) - val chapters = backupManager.parser.fromJson>( - mangaJson.get(CHAPTERS) - ?: JsonArray() - ) - val categories = backupManager.parser.fromJson>( - mangaJson.get(CATEGORIES) - ?: JsonArray() - ) - val history = backupManager.parser.fromJson>( - mangaJson.get(HISTORY) - ?: JsonArray() - ) - val tracks = backupManager.parser.fromJson>( - mangaJson.get(TRACK) - ?: JsonArray() - ) - - try { - val source = backupManager.sourceManager.get(manga.source) - if (source != null) { - restoreMangaData(manga, source, chapters, categories, history, tracks) - } else { - val sourceName = sourceMapping[manga.source] ?: manga.source.toString() - errors.add(Date() to "${manga.title} - ${getString(R.string.source_not_found_name, sourceName)}") - } - } catch (e: Exception) { - errors.add(Date() to "${manga.title} - ${e.message}") - } - - restoreProgress += 1 - showRestoreProgress(restoreProgress, restoreAmount, manga.title) - } - - /** - * Returns a manga restore observable - * - * @param manga manga data from json - * @param source source to get manga data from - * @param chapters chapters data from json - * @param categories categories data from json - * @param history history data from json - * @param tracks tracking data from json - */ - private fun restoreMangaData( - manga: Manga, - source: Source, - chapters: List, - categories: List, - history: List, - tracks: List - ) { - val dbManga = backupManager.getMangaFromDatabase(manga) - - db.inTransaction { - if (dbManga == null) { - // Manga not in database - restoreMangaFetch(source, manga, chapters, categories, history, tracks) - } else { // Manga in database - // Copy information from manga already in database - backupManager.restoreMangaNoFetch(manga, dbManga) - // Fetch rest of manga information - restoreMangaNoFetch(source, manga, chapters, categories, history, tracks) - } - } - } - - /** - * [Observable] that fetches manga information - * - * @param manga manga that needs updating - * @param chapters chapters of manga that needs updating - * @param categories categories that need updating - */ - private fun restoreMangaFetch( - source: Source, - manga: Manga, - chapters: List, - categories: List, - history: List, - tracks: List - ) { - backupManager.restoreMangaFetchObservable(source, manga) - .onErrorReturn { - errors.add(Date() to "${manga.title} - ${it.message}") - manga - } - .filter { it.id != null } - .flatMap { - chapterFetchObservable(source, it, chapters) - // Convert to the manga that contains new chapters. - .map { manga } - } - .doOnNext { - restoreExtraForManga(it, categories, history, tracks) - } - .flatMap { - trackingFetchObservable(it, tracks) - } - .subscribe() - } - - private fun restoreMangaNoFetch( - source: Source, - backupManga: Manga, - chapters: List, - categories: List, - history: List, - tracks: List - ) { - Observable.just(backupManga) - .flatMap { manga -> - if (!backupManager.restoreChaptersForManga(manga, chapters)) { - chapterFetchObservable(source, manga, chapters) - .map { manga } - } else { - Observable.just(manga) - } - } - .doOnNext { - restoreExtraForManga(it, categories, history, tracks) - } - .flatMap { manga -> - trackingFetchObservable(manga, tracks) - } - .subscribe() - } - - private fun restoreExtraForManga(manga: Manga, categories: List, history: List, tracks: List) { - // Restore categories - backupManager.restoreCategoriesForManga(manga, categories) - - // Restore history - backupManager.restoreHistoryForManga(history) - - // Restore tracking - backupManager.restoreTrackForManga(manga, tracks) - } - - /** - * [Observable] that fetches chapter information - * - * @param source source of manga - * @param manga manga that needs updating - * @return [Observable] that contains manga - */ - private fun chapterFetchObservable(source: Source, manga: Manga, chapters: List): Observable, List>> { - return backupManager.restoreChapterFetchObservable(source, manga, chapters) - // If there's any error, return empty update and continue. - .onErrorReturn { - val errorMessage = if (it is NoChaptersException) { - getString(R.string.no_chapters_error) - } else { - it.message - } - errors.add(Date() to "${manga.title} - $errorMessage") - Pair(emptyList(), emptyList()) - } - } - - /** - * [Observable] that refreshes tracking information - * @param manga manga that needs updating. - * @param tracks list containing tracks from restore file. - * @return [Observable] that contains updated track item - */ - private fun trackingFetchObservable(manga: Manga, tracks: List): Observable { - return Observable.from(tracks) - .flatMap { track -> - val service = trackManager.getService(track.sync_id) - if (service != null && service.isLogged) { - service.refresh(track) - .doOnNext { db.insertTrack(it).executeAsBlocking() } - .onErrorReturn { - errors.add(Date() to "${manga.title} - ${it.message}") - track - } - } else { - errors.add(Date() to "${manga.title} - ${getString(R.string.tracker_not_logged_in, service?.name)}") - Observable.empty() - } - } - } - - /** - * Called to update dialog in [BackupConst] - * - * @param progress restore progress - * @param amount total restoreAmount of manga - * @param title title of restored manga - */ - private fun showRestoreProgress( - progress: Int, - amount: Int, - title: String - ) { - notifier.showRestoreProgress(title, progress, amount) - } - - /** - * Write errors to error log - */ - private fun writeErrorLog(): File { - try { - if (errors.isNotEmpty()) { - val destFile = File(externalCacheDir, "tachiyomi_restore.txt") - val sdf = SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS", Locale.getDefault()) - - destFile.bufferedWriter().use { out -> - errors.forEach { (date, message) -> - out.write("[${sdf.format(date)}] $message\n") - } - } - return destFile - } - } catch (e: Exception) { - // Empty - } - return File("") - } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/full/FullBackupManager.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/full/FullBackupManager.kt new file mode 100644 index 0000000000..79027c73f6 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/backup/full/FullBackupManager.kt @@ -0,0 +1,442 @@ +package eu.kanade.tachiyomi.data.backup.full + +import android.content.Context +import android.net.Uri +import com.hippo.unifile.UniFile +import eu.kanade.tachiyomi.data.backup.BackupCreateService.Companion.BACKUP_CATEGORY +import eu.kanade.tachiyomi.data.backup.BackupCreateService.Companion.BACKUP_CATEGORY_MASK +import eu.kanade.tachiyomi.data.backup.BackupCreateService.Companion.BACKUP_CHAPTER +import eu.kanade.tachiyomi.data.backup.BackupCreateService.Companion.BACKUP_CHAPTER_MASK +import eu.kanade.tachiyomi.data.backup.BackupCreateService.Companion.BACKUP_HISTORY +import eu.kanade.tachiyomi.data.backup.BackupCreateService.Companion.BACKUP_HISTORY_MASK +import eu.kanade.tachiyomi.data.backup.BackupCreateService.Companion.BACKUP_TRACK +import eu.kanade.tachiyomi.data.backup.BackupCreateService.Companion.BACKUP_TRACK_MASK +import eu.kanade.tachiyomi.data.backup.full.models.Backup +import eu.kanade.tachiyomi.data.backup.full.models.BackupCategory +import eu.kanade.tachiyomi.data.backup.full.models.BackupChapter +import eu.kanade.tachiyomi.data.backup.full.models.BackupFull +import eu.kanade.tachiyomi.data.backup.full.models.BackupHistory +import eu.kanade.tachiyomi.data.backup.full.models.BackupManga +import eu.kanade.tachiyomi.data.backup.full.models.BackupSerializer +import eu.kanade.tachiyomi.data.backup.full.models.BackupSource +import eu.kanade.tachiyomi.data.backup.full.models.BackupTracking +import eu.kanade.tachiyomi.data.backup.models.AbstractBackupManager +import eu.kanade.tachiyomi.data.database.models.Chapter +import eu.kanade.tachiyomi.data.database.models.History +import eu.kanade.tachiyomi.data.database.models.Manga +import eu.kanade.tachiyomi.data.database.models.MangaCategory +import eu.kanade.tachiyomi.data.database.models.Track +import eu.kanade.tachiyomi.source.Source +import eu.kanade.tachiyomi.util.chapter.syncChaptersWithSource +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.protobuf.ProtoBuf +import okio.buffer +import okio.gzip +import okio.sink +import rx.Observable +import timber.log.Timber +import kotlin.math.max + +@OptIn(ExperimentalSerializationApi::class) +class FullBackupManager(context: Context) : AbstractBackupManager(context) { + /** + * Parser + */ + val parser = ProtoBuf + + /** + * Create backup Json file from database + * + * @param uri path of Uri + * @param isJob backup called from job + */ + override fun createBackup(uri: Uri, flags: Int, isJob: Boolean): String? { + // Create root object + var backup: Backup? = null + + databaseHelper.inTransaction { + // Get manga from database + val databaseManga = getDatabaseManga() + + backup = Backup( + backupManga(databaseManga, flags), + backupCategories(), + backupExtensionInfo(databaseManga) + ) + } + + try { + // When BackupCreatorJob + if (isJob) { + // Get dir of file and create + var dir = UniFile.fromUri(context, uri) + dir = dir.createDirectory("automatic") + + // Delete older backups + val numberOfBackups = numberOfBackups() + val backupRegex = Regex("""tachiyomi_full_\d+-\d+-\d+_\d+-\d+.proto.gz""") + dir.listFiles { _, filename -> backupRegex.matches(filename) } + .orEmpty() + .sortedByDescending { it.name } + .drop(numberOfBackups - 1) + .forEach { it.delete() } + + // Create new file to place backup + val newFile = dir.createFile(BackupFull.getDefaultFilename()) + ?: throw Exception("Couldn't create backup file") + + val byteArray = parser.encodeToByteArray(BackupSerializer, backup!!) + newFile.openOutputStream().sink().gzip().buffer().use { it.write(byteArray) } + + return newFile.uri.toString() + } else { + val file = UniFile.fromUri(context, uri) + ?: throw Exception("Couldn't create backup file") + val byteArray = parser.encodeToByteArray(BackupSerializer, backup!!) + file.openOutputStream().sink().gzip().buffer().use { it.write(byteArray) } + + return file.uri.toString() + } + } catch (e: Exception) { + Timber.e(e) + throw e + } + } + + private fun getDatabaseManga() = getFavoriteManga() + + private fun backupManga(mangas: List, flags: Int): List { + return mangas.map { + backupMangaObject(it, flags) + } + } + + private fun backupExtensionInfo(mangas: List): List { + return mangas + .asSequence() + .map { it.source } + .distinct() + .map { sourceManager.getOrStub(it) } + .map { BackupSource.copyFrom(it) } + .toList() + } + + /** + * Backup the categories of library + * + * @return list of [BackupCategory] to be backed up + */ + private fun backupCategories(): List { + return databaseHelper.getCategories() + .executeAsBlocking() + .map { BackupCategory.copyFrom(it) } + } + + /** + * 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 fun backupMangaObject(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 = databaseHelper.getChapters(manga).executeAsBlocking() + if (chapters.isNotEmpty()) { + mangaObject.chapters = chapters.map { BackupChapter.copyFrom(it) } + } + } + + // Check if user wants category information in backup + if (options and BACKUP_CATEGORY_MASK == BACKUP_CATEGORY) { + // Backup categories for this manga + val categoriesForManga = databaseHelper.getCategoriesForManga(manga).executeAsBlocking() + if (categoriesForManga.isNotEmpty()) { + mangaObject.categories = categoriesForManga.mapNotNull { it.order } + } + } + + // Check if user wants track information in backup + if (options and BACKUP_TRACK_MASK == BACKUP_TRACK) { + val tracks = databaseHelper.getTracks(manga).executeAsBlocking() + if (tracks.isNotEmpty()) { + mangaObject.tracking = tracks.map { BackupTracking.copyFrom(it) } + } + } + + // Check if user wants history information in backup + if (options and BACKUP_HISTORY_MASK == BACKUP_HISTORY) { + val historyForManga = databaseHelper.getHistoryByMangaId(manga.id!!).executeAsBlocking() + if (historyForManga.isNotEmpty()) { + val history = historyForManga.mapNotNull { history -> + val url = databaseHelper.getChapter(history.chapter_id).executeAsBlocking()?.url + url?.let { BackupHistory(url, history.last_read) } + } + if (history.isNotEmpty()) { + mangaObject.history = history + } + } + } + + return mangaObject + } + + fun restoreMangaNoFetch(manga: Manga, dbManga: Manga) { + manga.id = dbManga.id + manga.copyFrom(dbManga) + insertManga(manga) + } + + /** + * [Observable] that fetches manga information + * + * @param source source of manga + * @param manga manga that needs updating + * @return [Observable] that contains manga + */ + fun restoreMangaFetchObservable(source: Source?, manga: Manga, online: Boolean): Observable { + return if (online && source != null) { + source.fetchMangaDetails(manga) + .map { networkManga -> + manga.copyFrom(networkManga) + manga.favorite = manga.favorite + manga.initialized = true + manga.id = insertManga(manga) + manga + } + } else { + Observable.just(manga) + .map { + it.initialized = it.description != null + it.id = insertManga(it) + it + } + } + } + + /** + * [Observable] that fetches chapter information + * + * @param source source of manga + * @param manga manga that needs updating + * @param chapters list of chapters in the backup + * @return [Observable] that contains manga + */ + fun restoreChapterFetchObservable(source: Source, manga: Manga, chapters: List): Observable, List>> { + return source.fetchChapterList(manga) + .map { syncChaptersWithSource(databaseHelper, it, manga, source) } + .doOnNext { pair -> + if (pair.first.isNotEmpty()) { + chapters.forEach { it.manga_id = manga.id } + updateChapters(chapters) + } + } + } + + /** + * Restore the categories from Json + * + * @param backupCategories list containing categories + */ + internal fun restoreCategories(backupCategories: List) { + // Get categories from file and from db + val dbCategories = databaseHelper.getCategories().executeAsBlocking() + + // Iterate over them + backupCategories.map { it.getCategoryImpl() }.forEach { category -> + // Used to know if the category is already in the db + 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.id = dbCategory.id + found = true + break + } + } + // If the category isn't in the db, remove the id and insert a new category + // Store the inserted id in the category + if (!found) { + // Let the db assign the id + category.id = null + val result = databaseHelper.insertCategory(category).executeAsBlocking() + category.id = result.insertedId()?.toInt() + } + } + } + + /** + * Restores the categories a manga is in. + * + * @param manga the manga whose categories have to be restored. + * @param categories the categories to restore. + */ + internal fun restoreCategoriesForManga(manga: Manga, categories: List, backupCategories: List) { + val dbCategories = databaseHelper.getCategories().executeAsBlocking() + val mangaCategoriesToUpdate = mutableListOf() + categories.forEach { backupCategoryOrder -> + backupCategories.firstOrNull { + it.order == backupCategoryOrder + }?.let { backupCategory -> + dbCategories.firstOrNull { dbCategory -> + dbCategory.name == backupCategory.name + }?.let { dbCategory -> + mangaCategoriesToUpdate += MangaCategory.create(manga, dbCategory) + } + } + } + + // Update database + if (mangaCategoriesToUpdate.isNotEmpty()) { + databaseHelper.deleteOldMangasCategories(listOf(manga)).executeAsBlocking() + databaseHelper.insertMangasCategories(mangaCategoriesToUpdate).executeAsBlocking() + } + } + + /** + * Restore history from Json + * + * @param history list containing history to be restored + */ + internal fun restoreHistoryForManga(history: List) { + // List containing history to be updated + val historyToBeUpdated = mutableListOf() + for ((url, lastRead) in history) { + val dbHistory = databaseHelper.getHistoryByChapterUrl(url).executeAsBlocking() + // Check if history already in database and update + if (dbHistory != null) { + dbHistory.apply { + last_read = max(lastRead, dbHistory.last_read) + } + historyToBeUpdated.add(dbHistory) + } else { + // If not in database create + databaseHelper.getChapter(url).executeAsBlocking()?.let { + val historyToAdd = History.create(it).apply { + last_read = lastRead + } + historyToBeUpdated.add(historyToAdd) + } + } + } + databaseHelper.updateHistoryLastRead(historyToBeUpdated).executeAsBlocking() + } + + /** + * Restores the sync of a manga. + * + * @param manga the manga whose sync have to be restored. + * @param tracks the track list to restore. + */ + internal fun restoreTrackForManga(manga: Manga, tracks: List) { + // Fix foreign keys with the current manga id + tracks.map { it.manga_id = manga.id!! } + + // Get tracks from database + val dbTracks = databaseHelper.getTracks(manga).executeAsBlocking() + val trackToUpdate = mutableListOf() + + tracks.forEach { track -> + val service = trackManager.getService(track.sync_id) + if (service != null && service.isLogged) { + var isInDatabase = false + for (dbTrack in dbTracks) { + if (track.sync_id == dbTrack.sync_id) { + // The sync is already in the db, only update its fields + if (track.media_id != dbTrack.media_id) { + dbTrack.media_id = track.media_id + } + if (track.library_id != dbTrack.library_id) { + dbTrack.library_id = track.library_id + } + dbTrack.last_chapter_read = max(dbTrack.last_chapter_read, track.last_chapter_read) + isInDatabase = true + trackToUpdate.add(dbTrack) + break + } + } + if (!isInDatabase) { + // Insert new sync. Let the db assign the id + track.id = null + trackToUpdate.add(track) + } + } + } + // Update database + if (trackToUpdate.isNotEmpty()) { + databaseHelper.insertTracks(trackToUpdate).executeAsBlocking() + } + } + + /** + * Restore the chapters for manga if chapters already in database + * + * @param manga manga of chapters + * @param chapters list containing chapters that get restored + * @return boolean answering if chapter fetch is not needed + */ + internal fun restoreChaptersForManga(manga: Manga, chapters: List): Boolean { + val dbChapters = databaseHelper.getChapters(manga).executeAsBlocking() + + // Return if fetch is needed + if (dbChapters.isEmpty() || dbChapters.size < chapters.size) { + return false + } + + chapters.forEach { chapter -> + val pos = dbChapters.indexOfFirst { it.url == chapter.url } + if (pos != -1) { + val dbChapter = dbChapters[pos] + chapter.id = dbChapter.id + chapter.copyFrom(dbChapter) + if (dbChapter.read && !chapter.read) { + chapter.read = dbChapter.read + chapter.last_page_read = dbChapter.last_page_read + } else if (chapter.last_page_read == 0 && dbChapter.last_page_read != 0) { + chapter.last_page_read = dbChapter.last_page_read + } + if (!chapter.bookmark && dbChapter.bookmark) { + chapter.bookmark = dbChapter.bookmark + } + } + } + // Filter the chapters that couldn't be found. + chapters.filter { it.id != null } + chapters.map { it.manga_id = manga.id } + + updateChapters(chapters) + return true + } + + internal fun restoreChaptersForMangaOffline(manga: Manga, chapters: List) { + val dbChapters = databaseHelper.getChapters(manga).executeAsBlocking() + + chapters.forEach { chapter -> + val pos = dbChapters.indexOfFirst { it.url == chapter.url } + if (pos != -1) { + val dbChapter = dbChapters[pos] + chapter.id = dbChapter.id + chapter.copyFrom(dbChapter) + if (dbChapter.read && !chapter.read) { + chapter.read = dbChapter.read + chapter.last_page_read = dbChapter.last_page_read + } else if (chapter.last_page_read == 0 && dbChapter.last_page_read != 0) { + chapter.last_page_read = dbChapter.last_page_read + } + if (!chapter.bookmark && dbChapter.bookmark) { + chapter.bookmark = dbChapter.bookmark + } + } + } + chapters.map { it.manga_id = manga.id } + + updateChapters(chapters.filter { it.id != null }) + insertChapters(chapters.filter { it.id == null }) + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/full/FullBackupRestore.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/full/FullBackupRestore.kt new file mode 100644 index 0000000000..8e0d3e6b23 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/backup/full/FullBackupRestore.kt @@ -0,0 +1,283 @@ +package eu.kanade.tachiyomi.data.backup.full + +import android.content.Context +import android.net.Uri +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.data.backup.BackupNotifier +import eu.kanade.tachiyomi.data.backup.full.models.BackupCategory +import eu.kanade.tachiyomi.data.backup.full.models.BackupHistory +import eu.kanade.tachiyomi.data.backup.full.models.BackupManga +import eu.kanade.tachiyomi.data.backup.full.models.BackupSerializer +import eu.kanade.tachiyomi.data.backup.models.AbstractBackupRestore +import eu.kanade.tachiyomi.data.database.models.Chapter +import eu.kanade.tachiyomi.data.database.models.Manga +import eu.kanade.tachiyomi.data.database.models.Track +import eu.kanade.tachiyomi.source.Source +import eu.kanade.tachiyomi.util.chapter.NoChaptersException +import kotlinx.serialization.ExperimentalSerializationApi +import okio.buffer +import okio.gzip +import okio.source +import rx.Observable +import java.util.Date + +@OptIn(ExperimentalSerializationApi::class) +class FullBackupRestore(context: Context, notifier: BackupNotifier, private val online: Boolean) : AbstractBackupRestore(context, notifier) { + private lateinit var fullBackupManager: FullBackupManager + + /** + * Restores data from backup file. + * + * @param uri backup file to restore + */ + override fun restoreBackup(uri: Uri): Boolean { + val startTime = System.currentTimeMillis() + + // Initialize manager + fullBackupManager = FullBackupManager(context) + + val backupString = context.contentResolver.openInputStream(uri)!!.source().gzip().buffer().use { it.readByteArray() } + val backup = fullBackupManager.parser.decodeFromByteArray(BackupSerializer, backupString) + + restoreAmount = backup.backupManga.size + 1 // +1 for categories + restoreProgress = 0 + errors.clear() + + // Restore categories + if (backup.backupCategories.isNotEmpty()) { + restoreCategories(backup.backupCategories) + } + + // Store source mapping for error messages + sourceMapping = backup.backupSources.map { it.sourceId to it.name }.toMap() + + // Restore individual manga, sort by merged source so that merged source manga go last and merged references get the proper ids + backup.backupManga.forEach { + if (job?.isActive != true) { + return false + } + + restoreManga(it, backup.backupCategories, online) + } + + val endTime = System.currentTimeMillis() + val time = endTime - startTime + + val logFile = writeErrorLog() + + notifier.showRestoreComplete(time, errors.size, logFile.parent, logFile.name) + return true + } + + private fun restoreCategories(backupCategories: List) { + db.inTransaction { + fullBackupManager.restoreCategories(backupCategories) + } + + restoreProgress += 1 + showRestoreProgress(restoreProgress, restoreAmount, context.getString(R.string.categories)) + } + + private fun restoreManga(backupManga: BackupManga, backupCategories: List, online: Boolean) { + val manga = backupManga.getMangaImpl() + val chapters = backupManga.getChaptersImpl() + val categories = backupManga.categories + val history = backupManga.history + val tracks = backupManga.getTrackingImpl() + + try { + val source = fullBackupManager.sourceManager.get(manga.source) + if (source != null || !online) { + restoreMangaData(manga, source, chapters, categories, history, tracks, backupCategories, online) + } else { + val sourceName = sourceMapping[manga.source] ?: manga.source.toString() + errors.add(Date() to "${manga.title} - ${context.getString(R.string.source_not_found_name, sourceName)}") + } + } catch (e: Exception) { + errors.add(Date() to "${manga.title} - ${e.message}") + } + + restoreProgress += 1 + showRestoreProgress(restoreProgress, restoreAmount, manga.title) + } + + /** + * Returns a manga restore observable + * + * @param manga manga data from json + * @param source source to get manga data from + * @param chapters chapters data from json + * @param categories categories data from json + * @param history history data from json + * @param tracks tracking data from json + */ + private fun restoreMangaData( + manga: Manga, + source: Source?, + chapters: List, + categories: List, + history: List, + tracks: List, + backupCategories: List, + online: Boolean + ) { + val dbManga = fullBackupManager.getMangaFromDatabase(manga) + + db.inTransaction { + if (dbManga == null) { + // Manga not in database + restoreMangaFetch(source, manga, chapters, categories, history, tracks, backupCategories, online) + } else { // Manga in database + // Copy information from manga already in database + fullBackupManager.restoreMangaNoFetch(manga, dbManga) + // Fetch rest of manga information + restoreMangaNoFetch(source, manga, chapters, categories, history, tracks, backupCategories, online) + } + } + } + + /** + * [Observable] that fetches manga information + * + * @param manga manga that needs updating + * @param chapters chapters of manga that needs updating + * @param categories categories that need updating + */ + private fun restoreMangaFetch( + source: Source?, + manga: Manga, + chapters: List, + categories: List, + history: List, + tracks: List, + backupCategories: List, + online: Boolean + ) { + fullBackupManager.restoreMangaFetchObservable(source, manga, online) + .doOnError { + errors.add(Date() to "${manga.title} - ${it.message}") + } + .filter { it.id != null } + .flatMap { + if (online && source != null) { + chapterFetchObservable(source, it, chapters) + // Convert to the manga that contains new chapters. + .map { manga } + } else { + fullBackupManager.restoreChaptersForMangaOffline(it, chapters) + Observable.just(manga) + } + } + .doOnNext { + restoreExtraForManga(it, categories, history, tracks, backupCategories) + } + .flatMap { + trackingFetchObservable(it, tracks) + } + .subscribe() + } + + private fun restoreMangaNoFetch( + source: Source?, + backupManga: Manga, + chapters: List, + categories: List, + history: List, + tracks: List, + backupCategories: List, + online: Boolean + ) { + Observable.just(backupManga) + .flatMap { manga -> + if (online && source != null) { + if (!fullBackupManager.restoreChaptersForManga(manga, chapters)) { + chapterFetchObservable(source, manga, chapters) + .map { manga } + } else { + Observable.just(manga) + } + } else { + fullBackupManager.restoreChaptersForMangaOffline(manga, chapters) + Observable.just(manga) + } + } + .doOnNext { + restoreExtraForManga(it, categories, history, tracks, backupCategories) + } + .flatMap { manga -> + trackingFetchObservable(manga, tracks) + } + .subscribe() + } + + private fun restoreExtraForManga(manga: Manga, categories: List, history: List, tracks: List, backupCategories: List) { + // Restore categories + fullBackupManager.restoreCategoriesForManga(manga, categories, backupCategories) + + // Restore history + fullBackupManager.restoreHistoryForManga(history) + + // Restore tracking + fullBackupManager.restoreTrackForManga(manga, tracks) + } + + /** + * [Observable] that fetches chapter information + * + * @param source source of manga + * @param manga manga that needs updating + * @return [Observable] that contains manga + */ + private fun chapterFetchObservable(source: Source, manga: Manga, chapters: List): Observable, List>> { + return fullBackupManager.restoreChapterFetchObservable(source, manga, chapters) + // If there's any error, return empty update and continue. + .onErrorReturn { + val errorMessage = if (it is NoChaptersException) { + context.getString(R.string.no_chapters_error) + } else { + it.message + } + errors.add(Date() to "${manga.title} - $errorMessage") + Pair(emptyList(), emptyList()) + } + } + + /** + * [Observable] that refreshes tracking information + * @param manga manga that needs updating. + * @param tracks list containing tracks from restore file. + * @return [Observable] that contains updated track item + */ + private fun trackingFetchObservable(manga: Manga, tracks: List): Observable { + return Observable.from(tracks) + .flatMap { track -> + val service = trackManager.getService(track.sync_id) + if (service != null && service.isLogged) { + service.refresh(track) + .doOnNext { db.insertTrack(it).executeAsBlocking() } + .onErrorReturn { + errors.add(Date() to "${manga.title} - ${it.message}") + track + } + } else { + errors.add(Date() to "${manga.title} - ${context.getString(R.string.tracker_not_logged_in, service?.name)}") + Observable.empty() + } + } + } + + /** + * Called to update dialog in [BackupConst] + * + * @param progress restore progress + * @param amount total restoreAmount of manga + * @param title title of restored manga + */ + private fun showRestoreProgress( + progress: Int, + amount: Int, + title: String + ) { + notifier.showRestoreProgress(title, progress, amount) + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/full/FullBackupRestoreValidator.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/full/FullBackupRestoreValidator.kt new file mode 100644 index 0000000000..3f47d8caef --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/backup/full/FullBackupRestoreValidator.kt @@ -0,0 +1,49 @@ +package eu.kanade.tachiyomi.data.backup.full + +import android.content.Context +import android.net.Uri +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.data.backup.full.models.BackupSerializer +import eu.kanade.tachiyomi.data.backup.models.AbstractBackupRestoreValidator +import kotlinx.serialization.ExperimentalSerializationApi +import okio.buffer +import okio.gzip +import okio.source + +@OptIn(ExperimentalSerializationApi::class) +class FullBackupRestoreValidator : AbstractBackupRestoreValidator() { + /** + * Checks for critical backup file data. + * + * @throws Exception if manga cannot be found. + * @return List of missing sources or missing trackers. + */ + override fun validate(context: Context, uri: Uri): Results { + val backupManager = FullBackupManager(context) + + val backupString = context.contentResolver.openInputStream(uri)!!.source().gzip().buffer().use { it.readByteArray() } + val backup = backupManager.parser.decodeFromByteArray(BackupSerializer, backupString) + + if (backup.backupManga.isEmpty()) { + throw Exception(context.getString(R.string.invalid_backup_file_missing_manga)) + } + + val sources = backup.backupSources.map { it.sourceId to it.name }.toMap() + val missingSources = sources + .filter { sourceManager.get(it.key) == null } + .values + .sorted() + + val trackers = backup.backupManga + .flatMap { it.tracking } + .map { it.syncId } + .distinct() + val missingTrackers = trackers + .mapNotNull { trackManager.getService(it) } + .filter { !it.isLogged } + .map { it.name } + .sorted() + + return Results(missingSources, missingTrackers) + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/full/models/Backup.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/full/models/Backup.kt new file mode 100644 index 0000000000..705fd02a68 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/backup/full/models/Backup.kt @@ -0,0 +1,17 @@ +package eu.kanade.tachiyomi.data.backup.full.models + +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.Serializable +import kotlinx.serialization.protobuf.ProtoNumber + +/** + * Backup json model + */ +@ExperimentalSerializationApi +@Serializable +data class Backup( + @ProtoNumber(1) val backupManga: List, + @ProtoNumber(2) var backupCategories: List = emptyList(), + // Bump by 100 to specify this is a 0.x value + @ProtoNumber(100) var backupSources: List = emptyList(), +) diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/full/models/BackupCategory.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/full/models/BackupCategory.kt new file mode 100644 index 0000000000..1fec2dd4ce --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/backup/full/models/BackupCategory.kt @@ -0,0 +1,35 @@ +package eu.kanade.tachiyomi.data.backup.full.models + +import eu.kanade.tachiyomi.data.database.models.Category +import eu.kanade.tachiyomi.data.database.models.CategoryImpl +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.Serializable +import kotlinx.serialization.protobuf.ProtoNumber + +@ExperimentalSerializationApi +@Serializable +class BackupCategory( + @ProtoNumber(1) var name: String, + @ProtoNumber(2) var order: Int = 0, + // @ProtoNumber(3) val updateInterval: Int = 0, 1.x value not used in 0.x + // Bump by 100 to specify this is a 0.x value + @ProtoNumber(100) var flags: Int = 0, +) { + fun getCategoryImpl(): CategoryImpl { + return CategoryImpl().apply { + name = this@BackupCategory.name + flags = this@BackupCategory.flags + order = this@BackupCategory.order + } + } + + companion object { + fun copyFrom(category: Category): BackupCategory { + return BackupCategory( + name = category.name, + order = category.order, + flags = category.flags + ) + } + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/full/models/BackupChapter.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/full/models/BackupChapter.kt new file mode 100644 index 0000000000..c9cdc0ccde --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/backup/full/models/BackupChapter.kt @@ -0,0 +1,58 @@ +package eu.kanade.tachiyomi.data.backup.full.models + +import eu.kanade.tachiyomi.data.database.models.Chapter +import eu.kanade.tachiyomi.data.database.models.ChapterImpl +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.Serializable +import kotlinx.serialization.protobuf.ProtoNumber + +@ExperimentalSerializationApi +@Serializable +data class BackupChapter( + // in 1.x some of these values have different names + // url is called key in 1.x + @ProtoNumber(1) var url: String, + @ProtoNumber(2) var name: String, + @ProtoNumber(3) var scanlator: String? = null, + @ProtoNumber(4) var read: Boolean = false, + @ProtoNumber(5) var bookmark: Boolean = false, + // lastPageRead is called progress in 1.x + @ProtoNumber(6) var lastPageRead: Int = 0, + @ProtoNumber(7) var dateFetch: Long = 0, + @ProtoNumber(8) var dateUpload: Long = 0, + // chapterNumber is called number is 1.x + @ProtoNumber(9) var chapterNumber: Float = 0F, + @ProtoNumber(10) var sourceOrder: Int = 0, +) { + fun toChapterImpl(): ChapterImpl { + return ChapterImpl().apply { + url = this@BackupChapter.url + name = this@BackupChapter.name + chapter_number = this@BackupChapter.chapterNumber + scanlator = this@BackupChapter.scanlator + read = this@BackupChapter.read + bookmark = this@BackupChapter.bookmark + last_page_read = this@BackupChapter.lastPageRead + date_fetch = this@BackupChapter.dateFetch + date_upload = this@BackupChapter.dateUpload + source_order = this@BackupChapter.sourceOrder + } + } + + companion object { + fun copyFrom(chapter: Chapter): BackupChapter { + return BackupChapter( + url = chapter.url, + name = chapter.name, + chapterNumber = chapter.chapter_number, + scanlator = chapter.scanlator, + read = chapter.read, + bookmark = chapter.bookmark, + lastPageRead = chapter.last_page_read, + dateFetch = chapter.date_fetch, + dateUpload = chapter.date_upload, + sourceOrder = chapter.source_order + ) + } + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/full/models/BackupFull.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/full/models/BackupFull.kt new file mode 100644 index 0000000000..79f40d4320 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/backup/full/models/BackupFull.kt @@ -0,0 +1,12 @@ +package eu.kanade.tachiyomi.data.backup.full.models + +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +object BackupFull { + fun getDefaultFilename(): String { + val date = SimpleDateFormat("yyyy-MM-dd_HH-mm", Locale.getDefault()).format(Date()) + return "tachiyomi_full_$date.proto.gz" + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/full/models/BackupHistory.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/full/models/BackupHistory.kt new file mode 100644 index 0000000000..cd43c4edaa --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/backup/full/models/BackupHistory.kt @@ -0,0 +1,12 @@ +package eu.kanade.tachiyomi.data.backup.full.models + +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.Serializable +import kotlinx.serialization.protobuf.ProtoNumber + +@ExperimentalSerializationApi +@Serializable +data class BackupHistory( + @ProtoNumber(0) var url: String, + @ProtoNumber(1) var lastRead: Long +) diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/full/models/BackupManga.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/full/models/BackupManga.kt new file mode 100644 index 0000000000..9812a78820 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/backup/full/models/BackupManga.kt @@ -0,0 +1,89 @@ +package eu.kanade.tachiyomi.data.backup.full.models + +import eu.kanade.tachiyomi.data.database.models.ChapterImpl +import eu.kanade.tachiyomi.data.database.models.Manga +import eu.kanade.tachiyomi.data.database.models.MangaImpl +import eu.kanade.tachiyomi.data.database.models.TrackImpl +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.Serializable +import kotlinx.serialization.protobuf.ProtoNumber + +@ExperimentalSerializationApi +@Serializable +data class BackupManga( + // in 1.x some of these values have different names + @ProtoNumber(1) var source: Long, + // url is called key in 1.x + @ProtoNumber(2) var url: String, + @ProtoNumber(3) var title: String = "", + @ProtoNumber(4) var artist: String? = null, + @ProtoNumber(5) var author: String? = null, + @ProtoNumber(6) var description: String? = null, + @ProtoNumber(7) var genre: List = emptyList(), + @ProtoNumber(8) var status: Int = 0, + // thumbnailUrl is called cover in 1.x + @ProtoNumber(9) var thumbnailUrl: String? = null, + // @ProtoNumber(10) val customCover: String = "", 1.x value, not used in 0.x + // @ProtoNumber(11) val lastUpdate: Long = 0, 1.x value, not used in 0.x + // @ProtoNumber(12) val lastInit: Long = 0, 1.x value, not used in 0.x + @ProtoNumber(13) var dateAdded: Long = 0, + @ProtoNumber(14) var viewer: Int = 0, + // @ProtoNumber(15) val flags: Int = 0, 1.x value, not used in 0.x + @ProtoNumber(16) var chapters: List = emptyList(), + @ProtoNumber(17) var categories: List = emptyList(), + @ProtoNumber(18) var tracking: List = emptyList(), + // Bump by 100 for values that are not saved/implemented in 1.x but are used in 0.x + @ProtoNumber(100) var favorite: Boolean = true, + @ProtoNumber(101) var chapterFlags: Int = 0, + @ProtoNumber(102) var history: List = emptyList(), +) { + fun getMangaImpl(): MangaImpl { + return MangaImpl().apply { + url = this@BackupManga.url + title = this@BackupManga.title + artist = this@BackupManga.artist + author = this@BackupManga.author + description = this@BackupManga.description + genre = this@BackupManga.genre.joinToString() + status = this@BackupManga.status + thumbnail_url = this@BackupManga.thumbnailUrl + favorite = this@BackupManga.favorite + source = this@BackupManga.source + date_added = this@BackupManga.dateAdded + viewer = this@BackupManga.viewer + chapter_flags = this@BackupManga.chapterFlags + } + } + + fun getChaptersImpl(): List { + return chapters.map { + it.toChapterImpl() + } + } + + fun getTrackingImpl(): List { + return tracking.map { + it.getTrackingImpl() + } + } + + companion object { + fun copyFrom(manga: Manga): BackupManga { + return BackupManga( + url = manga.url, + title = manga.title, + artist = manga.artist, + author = manga.author, + description = manga.description, + genre = manga.getGenres() ?: emptyList(), + status = manga.status, + thumbnailUrl = manga.thumbnail_url, + favorite = manga.favorite, + source = manga.source, + dateAdded = manga.date_added, + viewer = manga.viewer, + chapterFlags = manga.chapter_flags + ) + } + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/full/models/BackupSerializer.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/full/models/BackupSerializer.kt new file mode 100644 index 0000000000..5e3730588f --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/backup/full/models/BackupSerializer.kt @@ -0,0 +1,8 @@ +package eu.kanade.tachiyomi.data.backup.full.models + +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.Serializer + +@ExperimentalSerializationApi +@Serializer(forClass = Backup::class) +object BackupSerializer diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/full/models/BackupSource.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/full/models/BackupSource.kt new file mode 100644 index 0000000000..a4e9e4d23e --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/backup/full/models/BackupSource.kt @@ -0,0 +1,22 @@ +package eu.kanade.tachiyomi.data.backup.full.models + +import eu.kanade.tachiyomi.source.Source +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.Serializable +import kotlinx.serialization.protobuf.ProtoNumber + +@ExperimentalSerializationApi +@Serializable +data class BackupSource( + @ProtoNumber(0) var name: String = "", + @ProtoNumber(1) var sourceId: Long +) { + companion object { + fun copyFrom(source: Source): BackupSource { + return BackupSource( + name = source.name, + sourceId = source.id + ) + } + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/full/models/BackupTracking.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/full/models/BackupTracking.kt new file mode 100644 index 0000000000..982ec3ecc4 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/backup/full/models/BackupTracking.kt @@ -0,0 +1,67 @@ +package eu.kanade.tachiyomi.data.backup.full.models + +import eu.kanade.tachiyomi.data.database.models.Track +import eu.kanade.tachiyomi.data.database.models.TrackImpl +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.Serializable +import kotlinx.serialization.protobuf.ProtoNumber + +@ExperimentalSerializationApi +@Serializable +data class BackupTracking( + // in 1.x some of these values have different types or names + // syncId is called siteId in 1,x + @ProtoNumber(1) var syncId: Int, + // LibraryId is not null in 1.x + @ProtoNumber(2) var libraryId: Long, + @ProtoNumber(3) var mediaId: Int = 0, + // trackingUrl is called mediaUrl in 1.x + @ProtoNumber(4) var trackingUrl: String = "", + @ProtoNumber(5) var title: String = "", + // lastChapterRead is called last read, and it has been changed to a float in 1.x + @ProtoNumber(6) var lastChapterRead: Float = 0F, + @ProtoNumber(7) var totalChapters: Int = 0, + @ProtoNumber(8) var score: Float = 0F, + @ProtoNumber(9) var status: Int = 0, + // startedReadingDate is called startReadTime in 1.x + @ProtoNumber(10) var startedReadingDate: Long = 0, + // finishedReadingDate is called endReadTime in 1.x + @ProtoNumber(11) var finishedReadingDate: Long = 0, +) { + fun getTrackingImpl(): TrackImpl { + return TrackImpl().apply { + sync_id = this@BackupTracking.syncId + media_id = this@BackupTracking.mediaId + library_id = this@BackupTracking.libraryId + title = this@BackupTracking.title + // convert from float to int because of 1.x types + last_chapter_read = this@BackupTracking.lastChapterRead.toInt() + total_chapters = this@BackupTracking.totalChapters + score = this@BackupTracking.score + status = this@BackupTracking.status + started_reading_date = this@BackupTracking.startedReadingDate + finished_reading_date = this@BackupTracking.finishedReadingDate + tracking_url = this@BackupTracking.trackingUrl + } + } + + companion object { + fun copyFrom(track: Track): BackupTracking { + return BackupTracking( + syncId = track.sync_id, + mediaId = track.media_id, + // forced not null so its compatible with 1.x backup system + libraryId = track.library_id!!, + title = track.title, + // convert to float for 1.x + lastChapterRead = track.last_chapter_read.toFloat(), + totalChapters = track.total_chapters, + score = track.score, + status = track.status, + startedReadingDate = track.started_reading_date, + finishedReadingDate = track.finished_reading_date, + trackingUrl = track.tracking_url + ) + } + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupManager.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/legacy/LegacyBackupManager.kt similarity index 86% rename from app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupManager.kt rename to app/src/main/java/eu/kanade/tachiyomi/data/backup/legacy/LegacyBackupManager.kt index e823a60822..82b3b90761 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupManager.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/backup/legacy/LegacyBackupManager.kt @@ -1,4 +1,4 @@ -package eu.kanade.tachiyomi.data.backup +package eu.kanade.tachiyomi.data.backup.legacy import android.content.Context import android.net.Uri @@ -20,21 +20,21 @@ import eu.kanade.tachiyomi.data.backup.BackupCreateService.Companion.BACKUP_HIST import eu.kanade.tachiyomi.data.backup.BackupCreateService.Companion.BACKUP_HISTORY_MASK import eu.kanade.tachiyomi.data.backup.BackupCreateService.Companion.BACKUP_TRACK import eu.kanade.tachiyomi.data.backup.BackupCreateService.Companion.BACKUP_TRACK_MASK -import eu.kanade.tachiyomi.data.backup.models.Backup -import eu.kanade.tachiyomi.data.backup.models.Backup.CATEGORIES -import eu.kanade.tachiyomi.data.backup.models.Backup.CHAPTERS -import eu.kanade.tachiyomi.data.backup.models.Backup.CURRENT_VERSION -import eu.kanade.tachiyomi.data.backup.models.Backup.EXTENSIONS -import eu.kanade.tachiyomi.data.backup.models.Backup.HISTORY -import eu.kanade.tachiyomi.data.backup.models.Backup.MANGA -import eu.kanade.tachiyomi.data.backup.models.Backup.TRACK -import eu.kanade.tachiyomi.data.backup.models.DHistory -import eu.kanade.tachiyomi.data.backup.serializer.CategoryTypeAdapter -import eu.kanade.tachiyomi.data.backup.serializer.ChapterTypeAdapter -import eu.kanade.tachiyomi.data.backup.serializer.HistoryTypeAdapter -import eu.kanade.tachiyomi.data.backup.serializer.MangaTypeAdapter -import eu.kanade.tachiyomi.data.backup.serializer.TrackTypeAdapter -import eu.kanade.tachiyomi.data.database.DatabaseHelper +import eu.kanade.tachiyomi.data.backup.legacy.models.Backup +import eu.kanade.tachiyomi.data.backup.legacy.models.Backup.CATEGORIES +import eu.kanade.tachiyomi.data.backup.legacy.models.Backup.CHAPTERS +import eu.kanade.tachiyomi.data.backup.legacy.models.Backup.CURRENT_VERSION +import eu.kanade.tachiyomi.data.backup.legacy.models.Backup.EXTENSIONS +import eu.kanade.tachiyomi.data.backup.legacy.models.Backup.HISTORY +import eu.kanade.tachiyomi.data.backup.legacy.models.Backup.MANGA +import eu.kanade.tachiyomi.data.backup.legacy.models.Backup.TRACK +import eu.kanade.tachiyomi.data.backup.legacy.models.DHistory +import eu.kanade.tachiyomi.data.backup.legacy.serializer.CategoryTypeAdapter +import eu.kanade.tachiyomi.data.backup.legacy.serializer.ChapterTypeAdapter +import eu.kanade.tachiyomi.data.backup.legacy.serializer.HistoryTypeAdapter +import eu.kanade.tachiyomi.data.backup.legacy.serializer.MangaTypeAdapter +import eu.kanade.tachiyomi.data.backup.legacy.serializer.TrackTypeAdapter +import eu.kanade.tachiyomi.data.backup.models.AbstractBackupManager import eu.kanade.tachiyomi.data.database.models.CategoryImpl import eu.kanade.tachiyomi.data.database.models.Chapter import eu.kanade.tachiyomi.data.database.models.ChapterImpl @@ -44,24 +44,14 @@ import eu.kanade.tachiyomi.data.database.models.MangaCategory import eu.kanade.tachiyomi.data.database.models.MangaImpl import eu.kanade.tachiyomi.data.database.models.Track import eu.kanade.tachiyomi.data.database.models.TrackImpl -import eu.kanade.tachiyomi.data.preference.PreferencesHelper -import eu.kanade.tachiyomi.data.track.TrackManager import eu.kanade.tachiyomi.source.LocalSource import eu.kanade.tachiyomi.source.Source -import eu.kanade.tachiyomi.source.SourceManager import eu.kanade.tachiyomi.util.chapter.syncChaptersWithSource import rx.Observable import timber.log.Timber -import uy.kohesive.injekt.injectLazy import kotlin.math.max -class BackupManager(val context: Context, version: Int = CURRENT_VERSION) { - - internal val databaseHelper: DatabaseHelper by injectLazy() - internal val sourceManager: SourceManager by injectLazy() - internal val trackManager: TrackManager by injectLazy() - private val preferences: PreferencesHelper by injectLazy() - +class LegacyBackupManager(context: Context, version: Int = CURRENT_VERSION) : AbstractBackupManager(context) { /** * Version of parser */ @@ -101,7 +91,7 @@ class BackupManager(val context: Context, version: Int = CURRENT_VERSION) { * @param uri path of Uri * @param isJob backup called from job */ - fun createBackup(uri: Uri, flags: Int, isJob: Boolean): String? { + override fun createBackup(uri: Uri, flags: Int, isJob: Boolean): String? { // Create root object val root = JsonObject() @@ -302,7 +292,7 @@ class BackupManager(val context: Context, version: Int = CURRENT_VERSION) { .doOnNext { pair -> if (pair.first.isNotEmpty()) { chapters.forEach { it.manga_id = manga.id } - insertChapters(chapters) + updateChapters(chapters) } } } @@ -469,45 +459,7 @@ class BackupManager(val context: Context, version: Int = CURRENT_VERSION) { chapters.filter { it.id != null } chapters.map { it.manga_id = manga.id } - insertChapters(chapters) + updateChapters(chapters) return true } - - /** - * Returns manga - * - * @return [Manga], null if not found - */ - internal fun getMangaFromDatabase(manga: Manga): Manga? = - databaseHelper.getManga(manga.url, manga.source).executeAsBlocking() - - /** - * Returns list containing manga from library - * - * @return [Manga] from library - */ - internal fun getFavoriteManga(): List = - databaseHelper.getFavoriteMangas().executeAsBlocking() - - /** - * Inserts manga and returns id - * - * @return id of [Manga], null if not found - */ - internal fun insertManga(manga: Manga): Long? = - databaseHelper.insertManga(manga).executeAsBlocking().insertedId() - - /** - * Inserts list of chapters - */ - private fun insertChapters(chapters: List) { - databaseHelper.updateChaptersBackup(chapters).executeAsBlocking() - } - - /** - * Return number of backups. - * - * @return number of backups selected by user - */ - fun numberOfBackups(): Int = preferences.numberOfBackups().get() } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/legacy/LegacyBackupRestore.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/legacy/LegacyBackupRestore.kt new file mode 100644 index 0000000000..0efea6220b --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/backup/legacy/LegacyBackupRestore.kt @@ -0,0 +1,292 @@ +package eu.kanade.tachiyomi.data.backup.legacy + +import android.content.Context +import android.net.Uri +import com.github.salomonbrys.kotson.fromJson +import com.google.gson.JsonArray +import com.google.gson.JsonElement +import com.google.gson.JsonObject +import com.google.gson.JsonParser +import com.google.gson.stream.JsonReader +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.data.backup.BackupConst +import eu.kanade.tachiyomi.data.backup.BackupNotifier +import eu.kanade.tachiyomi.data.backup.legacy.models.Backup +import eu.kanade.tachiyomi.data.backup.legacy.models.Backup.MANGAS +import eu.kanade.tachiyomi.data.backup.legacy.models.DHistory +import eu.kanade.tachiyomi.data.backup.models.AbstractBackupRestore +import eu.kanade.tachiyomi.data.database.models.Chapter +import eu.kanade.tachiyomi.data.database.models.ChapterImpl +import eu.kanade.tachiyomi.data.database.models.Manga +import eu.kanade.tachiyomi.data.database.models.MangaImpl +import eu.kanade.tachiyomi.data.database.models.Track +import eu.kanade.tachiyomi.data.database.models.TrackImpl +import eu.kanade.tachiyomi.source.Source +import eu.kanade.tachiyomi.util.chapter.NoChaptersException +import rx.Observable +import java.util.Date + +class LegacyBackupRestore(context: Context, notifier: BackupNotifier) : AbstractBackupRestore(context, notifier) { + + private lateinit var backupManager: LegacyBackupManager + + /** + * Restores data from backup file. + * + * @param uri backup file to restore + */ + override fun restoreBackup(uri: Uri): Boolean { + val startTime = System.currentTimeMillis() + + val reader = JsonReader(context.contentResolver.openInputStream(uri)!!.bufferedReader()) + val json = JsonParser.parseReader(reader).asJsonObject + + // Get parser version + val version = json.get(Backup.VERSION)?.asInt ?: 1 + + // Initialize manager + backupManager = LegacyBackupManager(context, version) + + val mangasJson = json.get(MANGAS).asJsonArray + + restoreAmount = mangasJson.size() + 3 // +1 for categories, +1 for saved searches, +1 for merged manga references + restoreProgress = 0 + errors.clear() + + // Restore categories + json.get(Backup.CATEGORIES)?.let { restoreCategories(it) } + + // Store source mapping for error messages + sourceMapping = LegacyBackupRestoreValidator.getSourceMapping(json) + + // Restore individual manga + mangasJson.forEach { + if (job?.isActive != true) { + return false + } + + restoreManga(it.asJsonObject) + } + + val endTime = System.currentTimeMillis() + val time = endTime - startTime + + val logFile = writeErrorLog() + + notifier.showRestoreComplete(time, errors.size, logFile.parent, logFile.name) + return true + } + + private fun restoreCategories(categoriesJson: JsonElement) { + db.inTransaction { + backupManager.restoreCategories(categoriesJson.asJsonArray) + } + + restoreProgress += 1 + showRestoreProgress(restoreProgress, restoreAmount, context.getString(R.string.categories)) + } + + private fun restoreManga(mangaJson: JsonObject) { + val manga = backupManager.parser.fromJson( + mangaJson.get( + Backup.MANGA + ) + ) + val chapters = backupManager.parser.fromJson>( + mangaJson.get(Backup.CHAPTERS) + ?: JsonArray() + ) + val categories = backupManager.parser.fromJson>( + mangaJson.get(Backup.CATEGORIES) + ?: JsonArray() + ) + val history = backupManager.parser.fromJson>( + mangaJson.get(Backup.HISTORY) + ?: JsonArray() + ) + val tracks = backupManager.parser.fromJson>( + mangaJson.get(Backup.TRACK) + ?: JsonArray() + ) + + try { + val source = backupManager.sourceManager.get(manga.source) + if (source != null) { + restoreMangaData(manga, source, chapters, categories, history, tracks) + } else { + val sourceName = sourceMapping[manga.source] ?: manga.source.toString() + errors.add(Date() to "${manga.title} - ${context.getString(R.string.source_not_found_name, sourceName)}") + } + } catch (e: Exception) { + errors.add(Date() to "${manga.title} - ${e.message}") + } + + restoreProgress += 1 + showRestoreProgress(restoreProgress, restoreAmount, manga.title) + } + + /** + * Returns a manga restore observable + * + * @param manga manga data from json + * @param source source to get manga data from + * @param chapters chapters data from json + * @param categories categories data from json + * @param history history data from json + * @param tracks tracking data from json + */ + private fun restoreMangaData( + manga: Manga, + source: Source, + chapters: List, + categories: List, + history: List, + tracks: List + ) { + val dbManga = backupManager.getMangaFromDatabase(manga) + + db.inTransaction { + if (dbManga == null) { + // Manga not in database + restoreMangaFetch(source, manga, chapters, categories, history, tracks) + } else { // Manga in database + // Copy information from manga already in database + backupManager.restoreMangaNoFetch(manga, dbManga) + // Fetch rest of manga information + restoreMangaNoFetch(source, manga, chapters, categories, history, tracks) + } + } + } + + /** + * [Observable] that fetches manga information + * + * @param manga manga that needs updating + * @param chapters chapters of manga that needs updating + * @param categories categories that need updating + */ + private fun restoreMangaFetch( + source: Source, + manga: Manga, + chapters: List, + categories: List, + history: List, + tracks: List + ) { + backupManager.restoreMangaFetchObservable(source, manga) + .onErrorReturn { + errors.add(Date() to "${manga.title} - ${it.message}") + manga + } + .filter { it.id != null } + .flatMap { + chapterFetchObservable(source, it, chapters) + // Convert to the manga that contains new chapters. + .map { manga } + } + .doOnNext { + restoreExtraForManga(it, categories, history, tracks) + } + .flatMap { + trackingFetchObservable(it, tracks) + } + .subscribe() + } + + private fun restoreMangaNoFetch( + source: Source, + backupManga: Manga, + chapters: List, + categories: List, + history: List, + tracks: List + ) { + Observable.just(backupManga) + .flatMap { manga -> + if (!backupManager.restoreChaptersForManga(manga, chapters)) { + chapterFetchObservable(source, manga, chapters) + .map { manga } + } else { + Observable.just(manga) + } + } + .doOnNext { + restoreExtraForManga(it, categories, history, tracks) + } + .flatMap { manga -> + trackingFetchObservable(manga, tracks) + } + .subscribe() + } + + private fun restoreExtraForManga(manga: Manga, categories: List, history: List, tracks: List) { + // Restore categories + backupManager.restoreCategoriesForManga(manga, categories) + + // Restore history + backupManager.restoreHistoryForManga(history) + + // Restore tracking + backupManager.restoreTrackForManga(manga, tracks) + } + + /** + * [Observable] that fetches chapter information + * + * @param source source of manga + * @param manga manga that needs updating + * @return [Observable] that contains manga + */ + private fun chapterFetchObservable(source: Source, manga: Manga, chapters: List): Observable, List>> { + return backupManager.restoreChapterFetchObservable(source, manga, chapters) + // If there's any error, return empty update and continue. + .onErrorReturn { + val errorMessage = if (it is NoChaptersException) { + context.getString(R.string.no_chapters_error) + } else { + it.message + } + errors.add(Date() to "${manga.title} - $errorMessage") + Pair(emptyList(), emptyList()) + } + } + + /** + * [Observable] that refreshes tracking information + * @param manga manga that needs updating. + * @param tracks list containing tracks from restore file. + * @return [Observable] that contains updated track item + */ + private fun trackingFetchObservable(manga: Manga, tracks: List): Observable { + return Observable.from(tracks) + .flatMap { track -> + val service = trackManager.getService(track.sync_id) + if (service != null && service.isLogged) { + service.refresh(track) + .doOnNext { db.insertTrack(it).executeAsBlocking() } + .onErrorReturn { + errors.add(Date() to "${manga.title} - ${it.message}") + track + } + } else { + errors.add(Date() to "${manga.title} - ${context.getString(R.string.tracker_not_logged_in, service?.name)}") + Observable.empty() + } + } + } + + /** + * Called to update dialog in [BackupConst] + * + * @param progress restore progress + * @param amount total restoreAmount of manga + * @param title title of restored manga + */ + private fun showRestoreProgress( + progress: Int, + amount: Int, + title: String + ) { + notifier.showRestoreProgress(title, progress, amount) + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupRestoreValidator.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/legacy/LegacyBackupRestoreValidator.kt similarity index 65% rename from app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupRestoreValidator.kt rename to app/src/main/java/eu/kanade/tachiyomi/data/backup/legacy/LegacyBackupRestoreValidator.kt index 81bff9382d..70f05aa306 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupRestoreValidator.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/backup/legacy/LegacyBackupRestoreValidator.kt @@ -1,4 +1,4 @@ -package eu.kanade.tachiyomi.data.backup +package eu.kanade.tachiyomi.data.backup.legacy import android.content.Context import android.net.Uri @@ -6,23 +6,17 @@ import com.google.gson.JsonObject import com.google.gson.JsonParser import com.google.gson.stream.JsonReader import eu.kanade.tachiyomi.R -import eu.kanade.tachiyomi.data.backup.models.Backup -import eu.kanade.tachiyomi.data.track.TrackManager -import eu.kanade.tachiyomi.source.SourceManager -import uy.kohesive.injekt.injectLazy - -object BackupRestoreValidator { - - private val sourceManager: SourceManager by injectLazy() - private val trackManager: TrackManager by injectLazy() +import eu.kanade.tachiyomi.data.backup.legacy.models.Backup +import eu.kanade.tachiyomi.data.backup.models.AbstractBackupRestoreValidator +class LegacyBackupRestoreValidator : AbstractBackupRestoreValidator() { /** * Checks for critical backup file data. * * @throws Exception if version or manga cannot be found. * @return List of missing sources or missing trackers. */ - fun validate(context: Context, uri: Uri): Results { + override fun validate(context: Context, uri: Uri): Results { val reader = JsonReader(context.contentResolver.openInputStream(uri)!!.bufferedReader()) val json = JsonParser.parseReader(reader).asJsonObject @@ -57,16 +51,16 @@ object BackupRestoreValidator { return Results(missingSources, missingTrackers) } - fun getSourceMapping(json: JsonObject): Map { - val extensionsMapping = json.get(Backup.EXTENSIONS) ?: return emptyMap() + companion object { + fun getSourceMapping(json: JsonObject): Map { + val extensionsMapping = json.get(Backup.EXTENSIONS) ?: return emptyMap() - return extensionsMapping.asJsonArray - .map { - val items = it.asString.split(":") - items[0].toLong() to items[1] - } - .toMap() + return extensionsMapping.asJsonArray + .map { + val items = it.asString.split(":") + items[0].toLong() to items[1] + } + .toMap() + } } - - data class Results(val missingSources: List, val missingTrackers: List) } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/models/Backup.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/legacy/models/Backup.kt similarity index 91% rename from app/src/main/java/eu/kanade/tachiyomi/data/backup/models/Backup.kt rename to app/src/main/java/eu/kanade/tachiyomi/data/backup/legacy/models/Backup.kt index 917f27754a..32dfa9245c 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/backup/models/Backup.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/backup/legacy/models/Backup.kt @@ -1,4 +1,4 @@ -package eu.kanade.tachiyomi.data.backup.models +package eu.kanade.tachiyomi.data.backup.legacy.models import java.text.SimpleDateFormat import java.util.Date diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/models/DHistory.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/legacy/models/DHistory.kt similarity index 51% rename from app/src/main/java/eu/kanade/tachiyomi/data/backup/models/DHistory.kt rename to app/src/main/java/eu/kanade/tachiyomi/data/backup/legacy/models/DHistory.kt index a5e1c1a0f3..9a0ea06609 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/backup/models/DHistory.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/backup/legacy/models/DHistory.kt @@ -1,3 +1,3 @@ -package eu.kanade.tachiyomi.data.backup.models +package eu.kanade.tachiyomi.data.backup.legacy.models data class DHistory(val url: String, val lastRead: Long) diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/serializer/CategoryTypeAdapter.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/legacy/serializer/CategoryTypeAdapter.kt similarity index 92% rename from app/src/main/java/eu/kanade/tachiyomi/data/backup/serializer/CategoryTypeAdapter.kt rename to app/src/main/java/eu/kanade/tachiyomi/data/backup/legacy/serializer/CategoryTypeAdapter.kt index 1beb5d9798..d346af19cf 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/backup/serializer/CategoryTypeAdapter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/backup/legacy/serializer/CategoryTypeAdapter.kt @@ -1,4 +1,4 @@ -package eu.kanade.tachiyomi.data.backup.serializer +package eu.kanade.tachiyomi.data.backup.legacy.serializer import com.github.salomonbrys.kotson.typeAdapter import com.google.gson.TypeAdapter diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/serializer/ChapterTypeAdapter.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/legacy/serializer/ChapterTypeAdapter.kt similarity index 96% rename from app/src/main/java/eu/kanade/tachiyomi/data/backup/serializer/ChapterTypeAdapter.kt rename to app/src/main/java/eu/kanade/tachiyomi/data/backup/legacy/serializer/ChapterTypeAdapter.kt index 9bd6e8e1e6..cacc8cb25b 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/backup/serializer/ChapterTypeAdapter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/backup/legacy/serializer/ChapterTypeAdapter.kt @@ -1,4 +1,4 @@ -package eu.kanade.tachiyomi.data.backup.serializer +package eu.kanade.tachiyomi.data.backup.legacy.serializer import com.github.salomonbrys.kotson.typeAdapter import com.google.gson.TypeAdapter diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/serializer/HistoryTypeAdapter.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/legacy/serializer/HistoryTypeAdapter.kt similarity index 85% rename from app/src/main/java/eu/kanade/tachiyomi/data/backup/serializer/HistoryTypeAdapter.kt rename to app/src/main/java/eu/kanade/tachiyomi/data/backup/legacy/serializer/HistoryTypeAdapter.kt index 863a1a1f30..4f7d5d9ff1 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/backup/serializer/HistoryTypeAdapter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/backup/legacy/serializer/HistoryTypeAdapter.kt @@ -1,8 +1,8 @@ -package eu.kanade.tachiyomi.data.backup.serializer +package eu.kanade.tachiyomi.data.backup.legacy.serializer import com.github.salomonbrys.kotson.typeAdapter import com.google.gson.TypeAdapter -import eu.kanade.tachiyomi.data.backup.models.DHistory +import eu.kanade.tachiyomi.data.backup.legacy.models.DHistory /** * JSON Serializer used to write / read [DHistory] to / from json diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/serializer/MangaTypeAdapter.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/legacy/serializer/MangaTypeAdapter.kt similarity index 94% rename from app/src/main/java/eu/kanade/tachiyomi/data/backup/serializer/MangaTypeAdapter.kt rename to app/src/main/java/eu/kanade/tachiyomi/data/backup/legacy/serializer/MangaTypeAdapter.kt index a18b98f1da..b902cbb5b7 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/backup/serializer/MangaTypeAdapter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/backup/legacy/serializer/MangaTypeAdapter.kt @@ -1,4 +1,4 @@ -package eu.kanade.tachiyomi.data.backup.serializer +package eu.kanade.tachiyomi.data.backup.legacy.serializer import com.github.salomonbrys.kotson.typeAdapter import com.google.gson.TypeAdapter diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/serializer/TrackTypeAdapter.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/legacy/serializer/TrackTypeAdapter.kt similarity index 96% rename from app/src/main/java/eu/kanade/tachiyomi/data/backup/serializer/TrackTypeAdapter.kt rename to app/src/main/java/eu/kanade/tachiyomi/data/backup/legacy/serializer/TrackTypeAdapter.kt index de78b8c115..84c0cd829d 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/backup/serializer/TrackTypeAdapter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/backup/legacy/serializer/TrackTypeAdapter.kt @@ -1,4 +1,4 @@ -package eu.kanade.tachiyomi.data.backup.serializer +package eu.kanade.tachiyomi.data.backup.legacy.serializer import com.github.salomonbrys.kotson.typeAdapter import com.google.gson.TypeAdapter diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/models/AbstractBackupManager.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/models/AbstractBackupManager.kt new file mode 100644 index 0000000000..b1de48b66a --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/backup/models/AbstractBackupManager.kt @@ -0,0 +1,65 @@ +package eu.kanade.tachiyomi.data.backup.models + +import android.content.Context +import android.net.Uri +import eu.kanade.tachiyomi.data.database.DatabaseHelper +import eu.kanade.tachiyomi.data.database.models.Chapter +import eu.kanade.tachiyomi.data.database.models.Manga +import eu.kanade.tachiyomi.data.preference.PreferencesHelper +import eu.kanade.tachiyomi.data.track.TrackManager +import eu.kanade.tachiyomi.source.SourceManager +import uy.kohesive.injekt.injectLazy + +abstract class AbstractBackupManager(protected val context: Context) { + internal val databaseHelper: DatabaseHelper by injectLazy() + internal val sourceManager: SourceManager by injectLazy() + internal val trackManager: TrackManager by injectLazy() + protected val preferences: PreferencesHelper by injectLazy() + + abstract fun createBackup(uri: Uri, flags: Int, isJob: Boolean): String? + + /** + * Returns manga + * + * @return [Manga], null if not found + */ + internal fun getMangaFromDatabase(manga: Manga): Manga? = + databaseHelper.getManga(manga.url, manga.source).executeAsBlocking() + + /** + * Returns list containing manga from library + * + * @return [Manga] from library + */ + protected fun getFavoriteManga(): List = + databaseHelper.getFavoriteMangas().executeAsBlocking() + + /** + * Inserts manga and returns id + * + * @return id of [Manga], null if not found + */ + internal fun insertManga(manga: Manga): Long? = + databaseHelper.insertManga(manga).executeAsBlocking().insertedId() + + /** + * Inserts list of chapters + */ + protected fun insertChapters(chapters: List) { + databaseHelper.insertChapters(chapters).executeAsBlocking() + } + + /** + * Updates a list of chapters + */ + protected fun updateChapters(chapters: List) { + databaseHelper.updateChaptersBackup(chapters).executeAsBlocking() + } + + /** + * Return number of backups. + * + * @return number of backups selected by user + */ + protected fun numberOfBackups(): Int = preferences.numberOfBackups().get() +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/models/AbstractBackupRestore.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/models/AbstractBackupRestore.kt new file mode 100644 index 0000000000..6609178afd --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/backup/models/AbstractBackupRestore.kt @@ -0,0 +1,65 @@ +package eu.kanade.tachiyomi.data.backup.models + +import android.content.Context +import android.net.Uri +import eu.kanade.tachiyomi.data.backup.BackupNotifier +import eu.kanade.tachiyomi.data.database.DatabaseHelper +import eu.kanade.tachiyomi.data.track.TrackManager +import kotlinx.coroutines.Job +import uy.kohesive.injekt.injectLazy +import java.io.File +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +abstract class AbstractBackupRestore(protected val context: Context, protected val notifier: BackupNotifier) { + protected val db: DatabaseHelper by injectLazy() + + protected val trackManager: TrackManager by injectLazy() + + var job: Job? = null + + /** + * The progress of a backup restore + */ + protected var restoreProgress = 0 + + /** + * Amount of manga in Json file (needed for restore) + */ + protected var restoreAmount = 0 + + /** + * Mapping of source ID to source name from backup data + */ + protected var sourceMapping: Map = emptyMap() + + /** + * List containing errors + */ + protected val errors = mutableListOf>() + + abstract fun restoreBackup(uri: Uri): Boolean + + /** + * Write errors to error log + */ + fun writeErrorLog(): File { + try { + if (errors.isNotEmpty()) { + val destFile = File(context.externalCacheDir, "tachiyomi_restore.txt") + val sdf = SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS", Locale.getDefault()) + + destFile.bufferedWriter().use { out -> + errors.forEach { (date, message) -> + out.write("[${sdf.format(date)}] $message\n") + } + } + return destFile + } + } catch (e: Exception) { + // Empty + } + return File("") + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/models/AbstractBackupRestoreValidator.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/models/AbstractBackupRestoreValidator.kt new file mode 100644 index 0000000000..5e6b422982 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/backup/models/AbstractBackupRestoreValidator.kt @@ -0,0 +1,16 @@ +package eu.kanade.tachiyomi.data.backup.models + +import android.content.Context +import android.net.Uri +import eu.kanade.tachiyomi.data.track.TrackManager +import eu.kanade.tachiyomi.source.SourceManager +import uy.kohesive.injekt.injectLazy + +abstract class AbstractBackupRestoreValidator { + protected val sourceManager: SourceManager by injectLazy() + protected val trackManager: TrackManager by injectLazy() + + abstract fun validate(context: Context, uri: Uri): Results + + data class Results(val missingSources: List, val missingTrackers: List) +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferenceKeys.kt b/app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferenceKeys.kt index 3cc5bb837e..a4739ebcda 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferenceKeys.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferenceKeys.kt @@ -181,6 +181,8 @@ object PreferenceKeys { const val incognitoMode = "incognito_mode" + const val createLegacyBackup = "create_legacy_backup" + fun trackUsername(syncId: Int) = "pref_mangasync_username_$syncId" fun trackPassword(syncId: Int) = "pref_mangasync_password_$syncId" diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferencesHelper.kt b/app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferencesHelper.kt index 37cd602be1..6f846df47e 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferencesHelper.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferencesHelper.kt @@ -271,6 +271,8 @@ class PreferencesHelper(val context: Context) { fun incognitoMode() = flowPrefs.getBoolean(Keys.incognitoMode, false) + fun createLegacyBackup() = flowPrefs.getBoolean(Keys.createLegacyBackup, false) + fun setChapterSettingsDefault(manga: Manga) { prefs.edit { putInt(Keys.defaultChapterFilterByRead, manga.readFilter) diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsBackupController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsBackupController.kt index 3087f25d4d..e452463eda 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsBackupController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsBackupController.kt @@ -4,6 +4,7 @@ import android.Manifest.permission.WRITE_EXTERNAL_STORAGE import android.app.Activity import android.app.Dialog import android.content.ActivityNotFoundException +import android.content.Context import android.content.Intent import android.net.Uri import android.os.Bundle @@ -13,13 +14,17 @@ import androidx.core.os.bundleOf import androidx.preference.PreferenceScreen import com.afollestad.materialdialogs.MaterialDialog import com.afollestad.materialdialogs.list.listItemsMultiChoice +import com.afollestad.materialdialogs.list.listItemsSingleChoice import com.hippo.unifile.UniFile import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.data.backup.BackupConst import eu.kanade.tachiyomi.data.backup.BackupCreateService import eu.kanade.tachiyomi.data.backup.BackupCreatorJob import eu.kanade.tachiyomi.data.backup.BackupRestoreService -import eu.kanade.tachiyomi.data.backup.BackupRestoreValidator -import eu.kanade.tachiyomi.data.backup.models.Backup +import eu.kanade.tachiyomi.data.backup.full.FullBackupRestoreValidator +import eu.kanade.tachiyomi.data.backup.full.models.BackupFull +import eu.kanade.tachiyomi.data.backup.legacy.LegacyBackupRestoreValidator +import eu.kanade.tachiyomi.data.backup.legacy.models.Backup import eu.kanade.tachiyomi.data.preference.asImmediateFlow import eu.kanade.tachiyomi.ui.base.controller.DialogController import eu.kanade.tachiyomi.ui.base.controller.requestPermissionsSafe @@ -31,6 +36,7 @@ import eu.kanade.tachiyomi.util.preference.onClick import eu.kanade.tachiyomi.util.preference.preference import eu.kanade.tachiyomi.util.preference.preferenceCategory import eu.kanade.tachiyomi.util.preference.summaryRes +import eu.kanade.tachiyomi.util.preference.switchPreference import eu.kanade.tachiyomi.util.preference.titleRes import eu.kanade.tachiyomi.util.system.getFilePicker import eu.kanade.tachiyomi.util.system.toast @@ -53,36 +59,47 @@ class SettingsBackupController : SettingsController() { override fun setupPreferenceScreen(screen: PreferenceScreen) = screen.apply { titleRes = R.string.backup - preference { - key = "pref_create_backup" - titleRes = R.string.pref_create_backup - summaryRes = R.string.pref_create_backup_summ + preferenceCategory { + titleRes = R.string.backup - onClick { - if (!BackupCreateService.isRunning(context)) { - val ctrl = CreateBackupDialog() - ctrl.targetController = this@SettingsBackupController - ctrl.showDialog(router) - } else { - context.toast(R.string.backup_in_progress) + preference { + key = "pref_create_full_backup" + titleRes = R.string.pref_create_full_backup + summaryRes = R.string.pref_create_full_backup_summary + + onClick { + backupClick(context, BackupConst.BACKUP_TYPE_FULL) + } + } + preference { + key = "pref_restore_full_backup" + titleRes = R.string.pref_restore_full_backup + summaryRes = R.string.pref_restore_full_backup_summary + + onClick { + restoreClick(context, CODE_FULL_BACKUP_RESTORE) } } } - preference { - key = "pref_restore_backup" - titleRes = R.string.pref_restore_backup - summaryRes = R.string.pref_restore_backup_summ + preferenceCategory { + titleRes = R.string.legacy_backup - onClick { - if (!BackupRestoreService.isRunning(context)) { - val intent = Intent(Intent.ACTION_GET_CONTENT) - intent.addCategory(Intent.CATEGORY_OPENABLE) - intent.type = "application/*" - val title = resources?.getString(R.string.file_select_backup) - val chooser = Intent.createChooser(intent, title) - startActivityForResult(chooser, CODE_BACKUP_RESTORE) - } else { - context.toast(R.string.restore_in_progress) + preference { + key = "pref_create_legacy_backup" + titleRes = R.string.pref_create_backup + summaryRes = R.string.pref_create_backup_summ + + onClick { + backupClick(context, BackupConst.BACKUP_TYPE_LEGACY) + } + } + preference { + key = "pref_restore_legacy_backup" + titleRes = R.string.pref_restore_backup + summaryRes = R.string.pref_restore_backup_summ + + onClick { + restoreClick(context, CODE_LEGACY_BACKUP_RESTORE) } } } @@ -143,6 +160,15 @@ class SettingsBackupController : SettingsController() { defaultValue = "1" summary = "%s" + preferences.backupInterval().asImmediateFlow { isVisible = it > 0 } + .launchIn(scope) + } + switchPreference { + key = Keys.createLegacyBackup + titleRes = R.string.pref_backup_auto_create_legacy + summaryRes = R.string.pref_backup_auto_create_legacy_summary + defaultValue = false + preferences.backupInterval().asImmediateFlow { isVisible = it > 0 } .launchIn(scope) } @@ -167,7 +193,7 @@ class SettingsBackupController : SettingsController() { // Set backup Uri preferences.backupsDirectory().set(uri.toString()) } - CODE_BACKUP_CREATE -> if (data != null && resultCode == Activity.RESULT_OK) { + CODE_LEGACY_BACKUP_CREATE -> if (data != null && resultCode == Activity.RESULT_OK) { val activity = activity ?: return val uri = data.data @@ -182,39 +208,111 @@ class SettingsBackupController : SettingsController() { activity.toast(R.string.creating_backup) - BackupCreateService.start(activity, file.uri, backupFlags) + BackupCreateService.start(activity, file.uri, backupFlags, BackupConst.BACKUP_TYPE_LEGACY) } - CODE_BACKUP_RESTORE -> if (data != null && resultCode == Activity.RESULT_OK) { + CODE_LEGACY_BACKUP_RESTORE -> if (data != null && resultCode == Activity.RESULT_OK) { val uri = data.data if (uri != null) { - RestoreBackupDialog(uri).showDialog(router) + RestoreBackupDialog(uri, BackupConst.BACKUP_TYPE_LEGACY, isOnline = true).showDialog(router) + } + } + CODE_FULL_BACKUP_CREATE -> if (data != null && resultCode == Activity.RESULT_OK) { + val activity = activity ?: return + + val uri = data.data + val flags = Intent.FLAG_GRANT_READ_URI_PERMISSION or + Intent.FLAG_GRANT_WRITE_URI_PERMISSION + + if (uri != null) { + activity.contentResolver.takePersistableUriPermission(uri, flags) + } + + val file = UniFile.fromUri(activity, uri) + + activity.toast(R.string.creating_backup) + + BackupCreateService.start(activity, file.uri, backupFlags, BackupConst.BACKUP_TYPE_FULL) + } + CODE_FULL_BACKUP_RESTORE -> if (data != null && resultCode == Activity.RESULT_OK) { + val uri = data.data + if (uri != null) { + val options = arrayOf( + R.string.full_restore_offline, + R.string.full_restore_online + ) + .map { activity!!.getString(it) } + MaterialDialog(activity!!) + .title(R.string.full_restore_mode) + .listItemsSingleChoice( + items = options, + initialSelection = 0 + ) { _, index, _ -> + RestoreBackupDialog( + uri, + BackupConst.BACKUP_TYPE_FULL, + isOnline = index != 0 + ).showDialog(router) + } + .positiveButton(R.string.action_restore) + .show() } } } } - fun createBackup(flags: Int) { + private fun backupClick(context: Context, type: Int) { + if (!BackupCreateService.isRunning(context)) { + val ctrl = CreateBackupDialog(type) + ctrl.targetController = this@SettingsBackupController + ctrl.showDialog(router) + } else { + context.toast(R.string.backup_in_progress) + } + } + + private fun restoreClick(context: Context, type: Int) { + if (!BackupRestoreService.isRunning(context)) { + val intent = Intent(Intent.ACTION_GET_CONTENT) + intent.addCategory(Intent.CATEGORY_OPENABLE) + intent.type = "application/*" + val title = resources?.getString(R.string.file_select_backup) + val chooser = Intent.createChooser(intent, title) + startActivityForResult(chooser, type) + } else { + context.toast(R.string.restore_in_progress) + } + } + + fun createBackup(flags: Int, type: Int) { backupFlags = flags // Get dirs val currentDir = preferences.backupsDirectory().get() try { + val fileName = if (type == BackupConst.BACKUP_TYPE_FULL) BackupFull.getDefaultFilename() else Backup.getDefaultFilename() // Use Android's built-in file creator val intent = Intent(Intent.ACTION_CREATE_DOCUMENT) .addCategory(Intent.CATEGORY_OPENABLE) .setType("application/*") - .putExtra(Intent.EXTRA_TITLE, Backup.getDefaultFilename()) + .putExtra(Intent.EXTRA_TITLE, fileName) - startActivityForResult(intent, CODE_BACKUP_CREATE) + startActivityForResult(intent, if (type == BackupConst.BACKUP_TYPE_FULL) CODE_FULL_BACKUP_CREATE else CODE_LEGACY_BACKUP_CREATE) } catch (e: ActivityNotFoundException) { // Handle errors where the android ROM doesn't support the built in picker - startActivityForResult(preferences.context.getFilePicker(currentDir), CODE_BACKUP_CREATE) + startActivityForResult(preferences.context.getFilePicker(currentDir), if (type == BackupConst.BACKUP_TYPE_FULL) CODE_FULL_BACKUP_CREATE else CODE_LEGACY_BACKUP_CREATE) } } - class CreateBackupDialog : DialogController() { + class CreateBackupDialog(bundle: Bundle? = null) : DialogController(bundle) { + constructor(type: Int) : this( + bundleOf( + KEY_TYPE to type + ) + ) + override fun onCreateDialog(savedViewState: Bundle?): Dialog { + val type = args.getInt(KEY_TYPE) val activity = activity!! val options = arrayOf( R.string.manga, @@ -226,7 +324,7 @@ class SettingsBackupController : SettingsController() { .map { activity.getString(it) } return MaterialDialog(activity) - .title(R.string.pref_create_backup) + .title(R.string.create_backup) .message(R.string.backup_choice) .listItemsMultiChoice( items = options, @@ -243,26 +341,38 @@ class SettingsBackupController : SettingsController() { } } - (targetController as? SettingsBackupController)?.createBackup(flags) + (targetController as? SettingsBackupController)?.createBackup(flags, type) } .positiveButton(R.string.action_create) .negativeButton(android.R.string.cancel) } + + private companion object { + const val KEY_TYPE = "CreateBackupDialog.type" + } } class RestoreBackupDialog(bundle: Bundle? = null) : DialogController(bundle) { - constructor(uri: Uri) : this( - bundleOf(KEY_URI to uri) + constructor(uri: Uri, type: Int, isOnline: Boolean) : this( + bundleOf( + KEY_URI to uri, + KEY_TYPE to type, + KEY_MODE to isOnline + ) ) override fun onCreateDialog(savedViewState: Bundle?): Dialog { val activity = activity!! val uri: Uri = args.getParcelable(KEY_URI)!! + val type: Int = args.getInt(KEY_TYPE) + val isOnline: Boolean = args.getBoolean(KEY_MODE, true) return try { var message = activity.getString(R.string.backup_restore_content) - val results = BackupRestoreValidator.validate(activity, uri) + val validator = if (type == BackupConst.BACKUP_TYPE_FULL) FullBackupRestoreValidator() else LegacyBackupRestoreValidator() + + val results = validator.validate(activity, uri) if (results.missingSources.isNotEmpty()) { message += "\n\n${activity.getString(R.string.backup_restore_missing_sources)}\n${results.missingSources.joinToString("\n") { "- $it" }}" } @@ -271,10 +381,10 @@ class SettingsBackupController : SettingsController() { } MaterialDialog(activity) - .title(R.string.pref_restore_backup) + .title(R.string.restore_backup) .message(text = message) .positiveButton(R.string.action_restore) { - BackupRestoreService.start(activity, uri) + BackupRestoreService.start(activity, uri, type, isOnline) } } catch (e: Exception) { MaterialDialog(activity) @@ -286,12 +396,16 @@ class SettingsBackupController : SettingsController() { private companion object { const val KEY_URI = "RestoreBackupDialog.uri" + const val KEY_TYPE = "RestoreBackupDialog.type" + const val KEY_MODE = "RestoreBackupDialog.mode" } } private companion object { - const val CODE_BACKUP_CREATE = 501 - const val CODE_BACKUP_RESTORE = 502 + const val CODE_LEGACY_BACKUP_CREATE = 501 + const val CODE_LEGACY_BACKUP_RESTORE = 502 const val CODE_BACKUP_DIR = 503 + const val CODE_FULL_BACKUP_CREATE = 504 + const val CODE_FULL_BACKUP_RESTORE = 505 } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index d0431d08da..15c78f858e 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -347,14 +347,26 @@ Backup - Create backup - Can be used to restore current library - Restore backup - Restore library from backup file + Legacy Backup + Create full backup + Can be used to restore current library + Restore full backup + Restore library from backup file, only use this if your backup is a full type backup, this can be restored offline as well as online + Create legacy backup + Can be used to restore current library in older versions of Tachiyomi + Restore legacy backup + Restore library from a legacy backup file + Create legacy backup + Creates a legacy backup alongside the full backup Backup location Automatic backups Backup frequency Maximum backups + Network Mode + Restore online, much slower but gives you more updated info and chapters + Restore offline, finishes quickly but contains only what your backup has + Create backup + Restore backup Source not found: %1$s Not logged in: %1$s Backup created diff --git a/app/src/test/java/eu/kanade/tachiyomi/data/backup/BackupTest.kt b/app/src/test/java/eu/kanade/tachiyomi/data/backup/BackupTest.kt index 5a41c08efe..d550718169 100644 --- a/app/src/test/java/eu/kanade/tachiyomi/data/backup/BackupTest.kt +++ b/app/src/test/java/eu/kanade/tachiyomi/data/backup/BackupTest.kt @@ -8,8 +8,9 @@ import com.google.gson.JsonArray import com.google.gson.JsonObject import eu.kanade.tachiyomi.BuildConfig import eu.kanade.tachiyomi.CustomRobolectricGradleTestRunner -import eu.kanade.tachiyomi.data.backup.models.Backup -import eu.kanade.tachiyomi.data.backup.models.DHistory +import eu.kanade.tachiyomi.data.backup.legacy.LegacyBackupManager +import eu.kanade.tachiyomi.data.backup.legacy.models.Backup +import eu.kanade.tachiyomi.data.backup.legacy.models.DHistory import eu.kanade.tachiyomi.data.database.DatabaseHelper import eu.kanade.tachiyomi.data.database.models.Category import eu.kanade.tachiyomi.data.database.models.Chapter @@ -37,7 +38,7 @@ import uy.kohesive.injekt.api.InjektRegistrar import uy.kohesive.injekt.api.addSingleton /** - * Test class for the [BackupManager]. + * Test class for the [LegacyBackupManager]. * Note that this does not include the backup create/restore services. */ @Config(constants = BuildConfig::class, sdk = [Build.VERSION_CODES.LOLLIPOP]) @@ -59,7 +60,7 @@ class BackupTest { lateinit var context: Context lateinit var source: HttpSource - lateinit var backupManager: BackupManager + lateinit var legacyBackupManager: LegacyBackupManager lateinit var db: DatabaseHelper @@ -67,8 +68,8 @@ class BackupTest { fun setup() { app = RuntimeEnvironment.application context = app.applicationContext - backupManager = BackupManager(context) - db = backupManager.databaseHelper + legacyBackupManager = LegacyBackupManager(context) + db = legacyBackupManager.databaseHelper // Mock the source manager val module = object : InjektModule { @@ -79,7 +80,7 @@ class BackupTest { Injekt.importModule(module) source = mock(HttpSource::class.java) - `when`(backupManager.sourceManager.get(anyLong())).thenReturn(source) + `when`(legacyBackupManager.sourceManager.get(anyLong())).thenReturn(source) root.add(Backup.MANGAS, mangaEntries) root.add(Backup.CATEGORIES, categoryEntries) @@ -94,10 +95,10 @@ class BackupTest { initializeJsonTest(2) // Create backup of empty database - backupManager.backupCategories(categoryEntries) + legacyBackupManager.backupCategories(categoryEntries) // Restore Json - backupManager.restoreCategories(categoryEntries) + legacyBackupManager.restoreCategories(categoryEntries) // Check if empty val dbCats = db.getCategories().executeAsBlocking() @@ -116,10 +117,10 @@ class BackupTest { val category = addSingleCategory("category") // Restore Json - backupManager.restoreCategories(categoryEntries) + legacyBackupManager.restoreCategories(categoryEntries) // Check if successful - val dbCats = backupManager.databaseHelper.getCategories().executeAsBlocking() + val dbCats = legacyBackupManager.databaseHelper.getCategories().executeAsBlocking() assertThat(dbCats).hasSize(1) assertThat(dbCats[0].name).isEqualTo(category.name) } @@ -143,10 +144,10 @@ class BackupTest { db.insertCategory(category).executeAsBlocking() // Restore Json - backupManager.restoreCategories(categoryEntries) + legacyBackupManager.restoreCategories(categoryEntries) // Check if successful - val dbCats = backupManager.databaseHelper.getCategories().executeAsBlocking() + val dbCats = legacyBackupManager.databaseHelper.getCategories().executeAsBlocking() assertThat(dbCats).hasSize(5) assertThat(dbCats[0].name).isEqualTo(category.name) assertThat(dbCats[1].name).isEqualTo(category2.name) @@ -168,27 +169,27 @@ class BackupTest { manga.viewer = 3 manga.id = db.insertManga(manga).executeAsBlocking().insertedId() - var favoriteManga = backupManager.databaseHelper.getFavoriteMangas().executeAsBlocking() + var favoriteManga = legacyBackupManager.databaseHelper.getFavoriteMangas().executeAsBlocking() assertThat(favoriteManga).hasSize(1) assertThat(favoriteManga[0].viewer).isEqualTo(3) // Update json with all options enabled - mangaEntries.add(backupManager.backupMangaObject(manga, 1)) + mangaEntries.add(legacyBackupManager.backupMangaObject(manga, 1)) // Change manga in database to default values val dbManga = getSingleManga("One Piece") dbManga.id = manga.id db.insertManga(dbManga).executeAsBlocking() - favoriteManga = backupManager.databaseHelper.getFavoriteMangas().executeAsBlocking() + favoriteManga = legacyBackupManager.databaseHelper.getFavoriteMangas().executeAsBlocking() assertThat(favoriteManga).hasSize(1) assertThat(favoriteManga[0].viewer).isEqualTo(0) // Restore local manga - backupManager.restoreMangaNoFetch(manga, dbManga) + legacyBackupManager.restoreMangaNoFetch(manga, dbManga) // Test if restore successful - favoriteManga = backupManager.databaseHelper.getFavoriteMangas().executeAsBlocking() + favoriteManga = legacyBackupManager.databaseHelper.getFavoriteMangas().executeAsBlocking() assertThat(favoriteManga).hasSize(1) assertThat(favoriteManga[0].viewer).isEqualTo(3) @@ -196,28 +197,28 @@ class BackupTest { clearDatabase() // Test if successful - favoriteManga = backupManager.databaseHelper.getFavoriteMangas().executeAsBlocking() + favoriteManga = legacyBackupManager.databaseHelper.getFavoriteMangas().executeAsBlocking() assertThat(favoriteManga).hasSize(0) // Restore Json // Create JSON from manga to test parser - val json = backupManager.parser.toJsonTree(manga) + val json = legacyBackupManager.parser.toJsonTree(manga) // Restore JSON from manga to test parser - val jsonManga = backupManager.parser.fromJson(json) + val jsonManga = legacyBackupManager.parser.fromJson(json) // Restore manga with fetch observable val networkManga = getSingleManga("One Piece") networkManga.description = "This is a description" `when`(source.fetchMangaDetails(jsonManga)).thenReturn(Observable.just(networkManga)) - val obs = backupManager.restoreMangaFetchObservable(source, jsonManga) + val obs = legacyBackupManager.restoreMangaFetchObservable(source, jsonManga) val testSubscriber = TestSubscriber() obs.subscribe(testSubscriber) testSubscriber.assertNoErrors() // Check if restore successful - val dbCats = backupManager.databaseHelper.getFavoriteMangas().executeAsBlocking() + val dbCats = legacyBackupManager.databaseHelper.getFavoriteMangas().executeAsBlocking() assertThat(dbCats).hasSize(1) assertThat(dbCats[0].viewer).isEqualTo(3) assertThat(dbCats[0].description).isEqualTo("This is a description") @@ -233,7 +234,7 @@ class BackupTest { // Insert manga val manga = getSingleManga("One Piece") - manga.id = backupManager.databaseHelper.insertManga(manga).executeAsBlocking().insertedId() + manga.id = legacyBackupManager.databaseHelper.insertManga(manga).executeAsBlocking().insertedId() // Create restore list val chapters = mutableListOf() @@ -244,8 +245,8 @@ class BackupTest { } // Check parser - val chaptersJson = backupManager.parser.toJsonTree(chapters) - val restoredChapters = backupManager.parser.fromJson>(chaptersJson) + val chaptersJson = legacyBackupManager.parser.toJsonTree(chapters) + val restoredChapters = legacyBackupManager.parser.fromJson>(chaptersJson) // Fetch chapters from upstream // Create list @@ -254,13 +255,13 @@ class BackupTest { `when`(source.fetchChapterList(manga)).thenReturn(Observable.just(chaptersRemote)) // Call restoreChapterFetchObservable - val obs = backupManager.restoreChapterFetchObservable(source, manga, restoredChapters) + val obs = legacyBackupManager.restoreChapterFetchObservable(source, manga, restoredChapters) val testSubscriber = TestSubscriber, List>>() obs.subscribe(testSubscriber) testSubscriber.assertNoErrors() - val dbCats = backupManager.databaseHelper.getChapters(manga).executeAsBlocking() + val dbCats = legacyBackupManager.databaseHelper.getChapters(manga).executeAsBlocking() assertThat(dbCats).hasSize(10) assertThat(dbCats[0].read).isEqualTo(true) } @@ -274,13 +275,13 @@ class BackupTest { initializeJsonTest(2) val manga = getSingleManga("One Piece") - manga.id = backupManager.databaseHelper.insertManga(manga).executeAsBlocking().insertedId() + manga.id = legacyBackupManager.databaseHelper.insertManga(manga).executeAsBlocking().insertedId() // Create chapter val chapter = getSingleChapter("Chapter 1") chapter.manga_id = manga.id chapter.read = true - chapter.id = backupManager.databaseHelper.insertChapter(chapter).executeAsBlocking().insertedId() + chapter.id = legacyBackupManager.databaseHelper.insertChapter(chapter).executeAsBlocking().insertedId() val historyJson = getSingleHistory(chapter) @@ -288,13 +289,13 @@ class BackupTest { historyList.add(historyJson) // Check parser - val historyListJson = backupManager.parser.toJsonTree(historyList) - val history = backupManager.parser.fromJson>(historyListJson) + val historyListJson = legacyBackupManager.parser.toJsonTree(historyList) + val history = legacyBackupManager.parser.fromJson>(historyListJson) // Restore categories - backupManager.restoreHistoryForManga(history) + legacyBackupManager.restoreHistoryForManga(history) - val historyDB = backupManager.databaseHelper.getHistoryByMangaId(manga.id!!).executeAsBlocking() + val historyDB = legacyBackupManager.databaseHelper.getHistoryByMangaId(manga.id!!).executeAsBlocking() assertThat(historyDB).hasSize(1) assertThat(historyDB[0].last_read).isEqualTo(1000) } @@ -310,15 +311,15 @@ class BackupTest { // Create mangas val manga = getSingleManga("One Piece") val manga2 = getSingleManga("Bleach") - manga.id = backupManager.databaseHelper.insertManga(manga).executeAsBlocking().insertedId() - manga2.id = backupManager.databaseHelper.insertManga(manga2).executeAsBlocking().insertedId() + manga.id = legacyBackupManager.databaseHelper.insertManga(manga).executeAsBlocking().insertedId() + manga2.id = legacyBackupManager.databaseHelper.insertManga(manga2).executeAsBlocking().insertedId() // Create track and add it to database // This tests duplicate errors. val track = getSingleTrack(manga) track.last_chapter_read = 5 - backupManager.databaseHelper.insertTrack(track).executeAsBlocking() - var trackDB = backupManager.databaseHelper.getTracks(manga).executeAsBlocking() + legacyBackupManager.databaseHelper.insertTrack(track).executeAsBlocking() + var trackDB = legacyBackupManager.databaseHelper.getTracks(manga).executeAsBlocking() assertThat(trackDB).hasSize(1) assertThat(trackDB[0].last_chapter_read).isEqualTo(5) track.last_chapter_read = 7 @@ -330,22 +331,22 @@ class BackupTest { // Check parser and restore already in database var trackList = listOf(track) // Check parser - var trackListJson = backupManager.parser.toJsonTree(trackList) - var trackListRestore = backupManager.parser.fromJson>(trackListJson) - backupManager.restoreTrackForManga(manga, trackListRestore) + var trackListJson = legacyBackupManager.parser.toJsonTree(trackList) + var trackListRestore = legacyBackupManager.parser.fromJson>(trackListJson) + legacyBackupManager.restoreTrackForManga(manga, trackListRestore) // Assert if restore works. - trackDB = backupManager.databaseHelper.getTracks(manga).executeAsBlocking() + trackDB = legacyBackupManager.databaseHelper.getTracks(manga).executeAsBlocking() assertThat(trackDB).hasSize(1) assertThat(trackDB[0].last_chapter_read).isEqualTo(7) // Check parser and restore already in database with lower chapter_read track.last_chapter_read = 5 trackList = listOf(track) - backupManager.restoreTrackForManga(manga, trackList) + legacyBackupManager.restoreTrackForManga(manga, trackList) // Assert if restore works. - trackDB = backupManager.databaseHelper.getTracks(manga).executeAsBlocking() + trackDB = legacyBackupManager.databaseHelper.getTracks(manga).executeAsBlocking() assertThat(trackDB).hasSize(1) assertThat(trackDB[0].last_chapter_read).isEqualTo(7) @@ -353,12 +354,12 @@ class BackupTest { trackList = listOf(track2) // Check parser - trackListJson = backupManager.parser.toJsonTree(trackList) - trackListRestore = backupManager.parser.fromJson>(trackListJson) - backupManager.restoreTrackForManga(manga2, trackListRestore) + trackListJson = legacyBackupManager.parser.toJsonTree(trackList) + trackListRestore = legacyBackupManager.parser.fromJson>(trackListJson) + legacyBackupManager.restoreTrackForManga(manga2, trackListRestore) // Assert if restore works. - trackDB = backupManager.databaseHelper.getTracks(manga2).executeAsBlocking() + trackDB = legacyBackupManager.databaseHelper.getTracks(manga2).executeAsBlocking() assertThat(trackDB).hasSize(1) assertThat(trackDB[0].last_chapter_read).isEqualTo(10) } @@ -372,12 +373,12 @@ class BackupTest { fun initializeJsonTest(version: Int) { clearJson() - backupManager.setVersion(version) + legacyBackupManager.setVersion(version) } fun addSingleCategory(name: String): Category { val category = Category.create(name) - val catJson = backupManager.parser.toJsonTree(category) + val catJson = legacyBackupManager.parser.toJsonTree(category) categoryEntries.add(catJson) return category }