浏览代码

Make a protobuf based backup system (#3936)

* Make a protobuf based backup system

* Cleanup

* More cleanup

* Fix restores always loading the full backup restore, even when legacy restore was used

* Make offline the default

(cherry picked from commit f6fd8a8ddb90869f3e28fd8fcd81a2125f8e0527)

* Find chapter based on the url

(cherry picked from commit 326dc2700944a60da381d82cd9782c5f0d335902)

* Dont break after finding one chapter

(cherry picked from commit f91d1af37398619cf371e4920b60f6d309799c74)

* Also apply changes to online restore

(cherry picked from commit e7c16cd0d14ea5d50ce4a9a3dfa8ca768be702f2)

* Rewrite backup categories

(cherry picked from commit f4200e2146a9c540675767206ed4664894aa1216)

* Dedupe some code, move over read and bookmarks properly

(cherry picked from commit d9ce86aca66945c831670a1523d8bc69966312df)

* Move some functions to the abstract backup manager

(cherry picked from commit b0c658741a2f506bc31823f1f0347772bc119d2e)

# Conflicts:
#	app/src/main/java/eu/kanade/tachiyomi/data/backup/full/FullBackupManager.kt
#	app/src/main/java/eu/kanade/tachiyomi/data/backup/legacy/LegacyBackupManager.kt

* Fix some backup duplication issues

(cherry picked from commit a4a1c2827c4537d2d07a0cb589dc1c3be1d65185)

# Conflicts:
#	app/src/main/java/eu/kanade/tachiyomi/data/backup/legacy/LegacyBackupManager.kt

* Fix a missed bundleOf

* So glad this wasnt merged before now, everything should be working with this commit
jobobby04 4 年之前
父节点
当前提交
682fae12b6
共有 36 个文件被更改,包括 1846 次插入547 次删除
  1. 3 1
      app/build.gradle
  2. 5 0
      app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupConst.kt
  3. 8 3
      app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupCreateService.kt
  4. 5 1
      app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupCreatorJob.kt
  5. 1 1
      app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupNotifier.kt
  6. 17 343
      app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupRestoreService.kt
  7. 442 0
      app/src/main/java/eu/kanade/tachiyomi/data/backup/full/FullBackupManager.kt
  8. 283 0
      app/src/main/java/eu/kanade/tachiyomi/data/backup/full/FullBackupRestore.kt
  9. 49 0
      app/src/main/java/eu/kanade/tachiyomi/data/backup/full/FullBackupRestoreValidator.kt
  10. 17 0
      app/src/main/java/eu/kanade/tachiyomi/data/backup/full/models/Backup.kt
  11. 35 0
      app/src/main/java/eu/kanade/tachiyomi/data/backup/full/models/BackupCategory.kt
  12. 58 0
      app/src/main/java/eu/kanade/tachiyomi/data/backup/full/models/BackupChapter.kt
  13. 12 0
      app/src/main/java/eu/kanade/tachiyomi/data/backup/full/models/BackupFull.kt
  14. 12 0
      app/src/main/java/eu/kanade/tachiyomi/data/backup/full/models/BackupHistory.kt
  15. 89 0
      app/src/main/java/eu/kanade/tachiyomi/data/backup/full/models/BackupManga.kt
  16. 8 0
      app/src/main/java/eu/kanade/tachiyomi/data/backup/full/models/BackupSerializer.kt
  17. 22 0
      app/src/main/java/eu/kanade/tachiyomi/data/backup/full/models/BackupSource.kt
  18. 67 0
      app/src/main/java/eu/kanade/tachiyomi/data/backup/full/models/BackupTracking.kt
  19. 20 68
      app/src/main/java/eu/kanade/tachiyomi/data/backup/legacy/LegacyBackupManager.kt
  20. 292 0
      app/src/main/java/eu/kanade/tachiyomi/data/backup/legacy/LegacyBackupRestore.kt
  21. 15 21
      app/src/main/java/eu/kanade/tachiyomi/data/backup/legacy/LegacyBackupRestoreValidator.kt
  22. 1 1
      app/src/main/java/eu/kanade/tachiyomi/data/backup/legacy/models/Backup.kt
  23. 1 1
      app/src/main/java/eu/kanade/tachiyomi/data/backup/legacy/models/DHistory.kt
  24. 1 1
      app/src/main/java/eu/kanade/tachiyomi/data/backup/legacy/serializer/CategoryTypeAdapter.kt
  25. 1 1
      app/src/main/java/eu/kanade/tachiyomi/data/backup/legacy/serializer/ChapterTypeAdapter.kt
  26. 2 2
      app/src/main/java/eu/kanade/tachiyomi/data/backup/legacy/serializer/HistoryTypeAdapter.kt
  27. 1 1
      app/src/main/java/eu/kanade/tachiyomi/data/backup/legacy/serializer/MangaTypeAdapter.kt
  28. 1 1
      app/src/main/java/eu/kanade/tachiyomi/data/backup/legacy/serializer/TrackTypeAdapter.kt
  29. 65 0
      app/src/main/java/eu/kanade/tachiyomi/data/backup/models/AbstractBackupManager.kt
  30. 65 0
      app/src/main/java/eu/kanade/tachiyomi/data/backup/models/AbstractBackupRestore.kt
  31. 16 0
      app/src/main/java/eu/kanade/tachiyomi/data/backup/models/AbstractBackupRestoreValidator.kt
  32. 2 0
      app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferenceKeys.kt
  33. 2 0
      app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferencesHelper.kt
  34. 161 47
      app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsBackupController.kt
  35. 16 4
      app/src/main/res/values/strings.xml
  36. 51 50
      app/src/test/java/eu/kanade/tachiyomi/data/backup/BackupTest.kt

+ 3 - 1
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'
 

+ 5 - 0
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
 }

+ 8 - 3
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<Uri>(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)

+ 5 - 1
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<PreferencesHelper>()
-        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()

+ 1 - 1
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()
 

+ 17 - 343
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<Long, String> = emptyMap()
-
-    /**
-     * List containing errors
-     */
-    private val errors = mutableListOf<Pair<Date, String>>()
-
-    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<Uri>(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<MangaImpl>(mangaJson.get(MANGA))
-        val chapters = backupManager.parser.fromJson<List<ChapterImpl>>(
-            mangaJson.get(CHAPTERS)
-                ?: JsonArray()
-        )
-        val categories = backupManager.parser.fromJson<List<String>>(
-            mangaJson.get(CATEGORIES)
-                ?: JsonArray()
-        )
-        val history = backupManager.parser.fromJson<List<DHistory>>(
-            mangaJson.get(HISTORY)
-                ?: JsonArray()
-        )
-        val tracks = backupManager.parser.fromJson<List<TrackImpl>>(
-            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<Chapter>,
-        categories: List<String>,
-        history: List<DHistory>,
-        tracks: List<Track>
-    ) {
-        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<Chapter>,
-        categories: List<String>,
-        history: List<DHistory>,
-        tracks: List<Track>
-    ) {
-        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<Chapter>,
-        categories: List<String>,
-        history: List<DHistory>,
-        tracks: List<Track>
-    ) {
-        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<String>, history: List<DHistory>, tracks: List<Track>) {
-        // 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<Chapter>): Observable<Pair<List<Chapter>, List<Chapter>>> {
-        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<Track>): Observable<Track> {
-        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("")
-    }
 }

+ 442 - 0
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<Manga>, flags: Int): List<BackupManga> {
+        return mangas.map {
+            backupMangaObject(it, flags)
+        }
+    }
+
+    private fun backupExtensionInfo(mangas: List<Manga>): List<BackupSource> {
+        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<BackupCategory> {
+        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<Manga> {
+        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<Chapter>): Observable<Pair<List<Chapter>, List<Chapter>>> {
+        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<BackupCategory>) {
+        // 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<Int>, backupCategories: List<BackupCategory>) {
+        val dbCategories = databaseHelper.getCategories().executeAsBlocking()
+        val mangaCategoriesToUpdate = mutableListOf<MangaCategory>()
+        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<BackupHistory>) {
+        // List containing history to be updated
+        val historyToBeUpdated = mutableListOf<History>()
+        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<Track>) {
+        // 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<Track>()
+
+        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<Chapter>): 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<Chapter>) {
+        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 })
+    }
+}

+ 283 - 0
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<BackupCategory>) {
+        db.inTransaction {
+            fullBackupManager.restoreCategories(backupCategories)
+        }
+
+        restoreProgress += 1
+        showRestoreProgress(restoreProgress, restoreAmount, context.getString(R.string.categories))
+    }
+
+    private fun restoreManga(backupManga: BackupManga, backupCategories: List<BackupCategory>, 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<Chapter>,
+        categories: List<Int>,
+        history: List<BackupHistory>,
+        tracks: List<Track>,
+        backupCategories: List<BackupCategory>,
+        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<Chapter>,
+        categories: List<Int>,
+        history: List<BackupHistory>,
+        tracks: List<Track>,
+        backupCategories: List<BackupCategory>,
+        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<Chapter>,
+        categories: List<Int>,
+        history: List<BackupHistory>,
+        tracks: List<Track>,
+        backupCategories: List<BackupCategory>,
+        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<Int>, history: List<BackupHistory>, tracks: List<Track>, backupCategories: List<BackupCategory>) {
+        // 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<Chapter>): Observable<Pair<List<Chapter>, List<Chapter>>> {
+        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<Track>): Observable<Track> {
+        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)
+    }
+}

+ 49 - 0
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)
+    }
+}

+ 17 - 0
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<BackupManga>,
+    @ProtoNumber(2) var backupCategories: List<BackupCategory> = emptyList(),
+    // Bump by 100 to specify this is a 0.x value
+    @ProtoNumber(100) var backupSources: List<BackupSource> = emptyList(),
+)

+ 35 - 0
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 = [email protected]
+            flags = [email protected]
+            order = [email protected]
+        }
+    }
+
+    companion object {
+        fun copyFrom(category: Category): BackupCategory {
+            return BackupCategory(
+                name = category.name,
+                order = category.order,
+                flags = category.flags
+            )
+        }
+    }
+}

+ 58 - 0
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 = [email protected]
+            name = [email protected]
+            chapter_number = [email protected]
+            scanlator = [email protected]
+            read = [email protected]
+            bookmark = [email protected]
+            last_page_read = [email protected]
+            date_fetch = [email protected]
+            date_upload = [email protected]
+            source_order = [email protected]
+        }
+    }
+
+    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
+            )
+        }
+    }
+}

+ 12 - 0
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"
+    }
+}

+ 12 - 0
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
+)

+ 89 - 0
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<String> = 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<BackupChapter> = emptyList(),
+    @ProtoNumber(17) var categories: List<Int> = emptyList(),
+    @ProtoNumber(18) var tracking: List<BackupTracking> = 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<BackupHistory> = emptyList(),
+) {
+    fun getMangaImpl(): MangaImpl {
+        return MangaImpl().apply {
+            url = [email protected]
+            title = [email protected]
+            artist = [email protected]
+            author = [email protected]
+            description = [email protected]
+            genre = [email protected]()
+            status = [email protected]
+            thumbnail_url = [email protected]
+            favorite = [email protected]
+            source = [email protected]
+            date_added = [email protected]
+            viewer = [email protected]
+            chapter_flags = [email protected]
+        }
+    }
+
+    fun getChaptersImpl(): List<ChapterImpl> {
+        return chapters.map {
+            it.toChapterImpl()
+        }
+    }
+
+    fun getTrackingImpl(): List<TrackImpl> {
+        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
+            )
+        }
+    }
+}

+ 8 - 0
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

+ 22 - 0
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
+            )
+        }
+    }
+}

+ 67 - 0
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 = [email protected]
+            media_id = [email protected]
+            library_id = [email protected]
+            title = [email protected]
+            // convert from float to int because of 1.x types
+            last_chapter_read = [email protected]()
+            total_chapters = [email protected]
+            score = [email protected]
+            status = [email protected]
+            started_reading_date = [email protected]
+            finished_reading_date = [email protected]
+            tracking_url = [email protected]
+        }
+    }
+
+    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
+            )
+        }
+    }
+}

+ 20 - 68
app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupManager.kt → 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<Manga> =
-        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<Chapter>) {
-        databaseHelper.updateChaptersBackup(chapters).executeAsBlocking()
-    }
-
-    /**
-     * Return number of backups.
-     *
-     * @return number of backups selected by user
-     */
-    fun numberOfBackups(): Int = preferences.numberOfBackups().get()
 }

+ 292 - 0
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<MangaImpl>(
+            mangaJson.get(
+                Backup.MANGA
+            )
+        )
+        val chapters = backupManager.parser.fromJson<List<ChapterImpl>>(
+            mangaJson.get(Backup.CHAPTERS)
+                ?: JsonArray()
+        )
+        val categories = backupManager.parser.fromJson<List<String>>(
+            mangaJson.get(Backup.CATEGORIES)
+                ?: JsonArray()
+        )
+        val history = backupManager.parser.fromJson<List<DHistory>>(
+            mangaJson.get(Backup.HISTORY)
+                ?: JsonArray()
+        )
+        val tracks = backupManager.parser.fromJson<List<TrackImpl>>(
+            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<Chapter>,
+        categories: List<String>,
+        history: List<DHistory>,
+        tracks: List<Track>
+    ) {
+        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<Chapter>,
+        categories: List<String>,
+        history: List<DHistory>,
+        tracks: List<Track>
+    ) {
+        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<Chapter>,
+        categories: List<String>,
+        history: List<DHistory>,
+        tracks: List<Track>
+    ) {
+        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<String>, history: List<DHistory>, tracks: List<Track>) {
+        // 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<Chapter>): Observable<Pair<List<Chapter>, List<Chapter>>> {
+        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<Track>): Observable<Track> {
+        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)
+    }
+}

+ 15 - 21
app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupRestoreValidator.kt → 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<Long, String> {
-        val extensionsMapping = json.get(Backup.EXTENSIONS) ?: return emptyMap()
+    companion object {
+        fun getSourceMapping(json: JsonObject): Map<Long, String> {
+            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<String>, val missingTrackers: List<String>)
 }

+ 1 - 1
app/src/main/java/eu/kanade/tachiyomi/data/backup/models/Backup.kt → 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

+ 1 - 1
app/src/main/java/eu/kanade/tachiyomi/data/backup/models/DHistory.kt → 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)

+ 1 - 1
app/src/main/java/eu/kanade/tachiyomi/data/backup/serializer/CategoryTypeAdapter.kt → 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

+ 1 - 1
app/src/main/java/eu/kanade/tachiyomi/data/backup/serializer/ChapterTypeAdapter.kt → 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

+ 2 - 2
app/src/main/java/eu/kanade/tachiyomi/data/backup/serializer/HistoryTypeAdapter.kt → 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

+ 1 - 1
app/src/main/java/eu/kanade/tachiyomi/data/backup/serializer/MangaTypeAdapter.kt → 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

+ 1 - 1
app/src/main/java/eu/kanade/tachiyomi/data/backup/serializer/TrackTypeAdapter.kt → 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

+ 65 - 0
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<Manga> =
+        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<Chapter>) {
+        databaseHelper.insertChapters(chapters).executeAsBlocking()
+    }
+
+    /**
+     * Updates a list of chapters
+     */
+    protected fun updateChapters(chapters: List<Chapter>) {
+        databaseHelper.updateChaptersBackup(chapters).executeAsBlocking()
+    }
+
+    /**
+     * Return number of backups.
+     *
+     * @return number of backups selected by user
+     */
+    protected fun numberOfBackups(): Int = preferences.numberOfBackups().get()
+}

+ 65 - 0
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<Long, String> = emptyMap()
+
+    /**
+     * List containing errors
+     */
+    protected val errors = mutableListOf<Pair<Date, String>>()
+
+    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("")
+    }
+}

+ 16 - 0
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<String>, val missingTrackers: List<String>)
+}

+ 2 - 0
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"

+ 2 - 0
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)

+ 161 - 47
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
-
-            onClick {
-                if (!BackupCreateService.isRunning(context)) {
-                    val ctrl = CreateBackupDialog()
-                    ctrl.targetController = this@SettingsBackupController
-                    ctrl.showDialog(router)
-                } else {
-                    context.toast(R.string.backup_in_progress)
+        preferenceCategory {
+            titleRes = R.string.backup
+
+            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
-
-            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)
+        preferenceCategory {
+            titleRes = R.string.legacy_backup
+
+            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,30 @@ 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
+                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_LEGACY)
+            }
+            CODE_LEGACY_BACKUP_RESTORE -> if (data != null && resultCode == Activity.RESULT_OK) {
+                val uri = data.data
+                if (uri != null) {
+                    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
@@ -182,39 +231,88 @@ class SettingsBackupController : SettingsController() {
 
                 activity.toast(R.string.creating_backup)
 
-                BackupCreateService.start(activity, file.uri, backupFlags)
+                BackupCreateService.start(activity, file.uri, backupFlags, BackupConst.BACKUP_TYPE_FULL)
             }
-            CODE_BACKUP_RESTORE -> if (data != null && resultCode == Activity.RESULT_OK) {
+            CODE_FULL_BACKUP_RESTORE -> if (data != null && resultCode == Activity.RESULT_OK) {
                 val uri = data.data
                 if (uri != null) {
-                    RestoreBackupDialog(uri).showDialog(router)
+                    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
     }
 }

+ 16 - 4
app/src/main/res/values/strings.xml

@@ -347,14 +347,26 @@
 
       <!-- Backup section -->
     <string name="backup">Backup</string>
-    <string name="pref_create_backup">Create backup</string>
-    <string name="pref_create_backup_summ">Can be used to restore current library</string>
-    <string name="pref_restore_backup">Restore backup</string>
-    <string name="pref_restore_backup_summ">Restore library from backup file</string>
+    <string name="legacy_backup">Legacy Backup</string>
+    <string name="pref_create_full_backup">Create full backup</string>
+    <string name="pref_create_full_backup_summary">Can be used to restore current library</string>
+    <string name="pref_restore_full_backup">Restore full backup</string>
+    <string name="pref_restore_full_backup_summary">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</string>
+    <string name="pref_create_backup">Create legacy backup</string>
+    <string name="pref_create_backup_summ">Can be used to restore current library in older versions of Tachiyomi</string>
+    <string name="pref_restore_backup">Restore legacy backup</string>
+    <string name="pref_restore_backup_summ">Restore library from a legacy backup file</string>
+    <string name="pref_backup_auto_create_legacy">Create legacy backup</string>
+    <string name="pref_backup_auto_create_legacy_summary">Creates a legacy backup alongside the full backup</string>
     <string name="pref_backup_directory">Backup location</string>
     <string name="pref_backup_service_category">Automatic backups</string>
     <string name="pref_backup_interval">Backup frequency</string>
     <string name="pref_backup_slots">Maximum backups</string>
+    <string name="full_restore_mode">Network Mode</string>
+    <string name="full_restore_online">Restore online, much slower but gives you more updated info and chapters</string>
+    <string name="full_restore_offline">Restore offline, finishes quickly but contains only what your backup has</string>
+    <string name="create_backup">Create backup</string>
+    <string name="restore_backup">Restore backup</string>
     <string name="source_not_found_name">Source not found: %1$s</string>
     <string name="tracker_not_logged_in">Not logged in: %1$s</string>
     <string name="backup_created">Backup created</string>

+ 51 - 50
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<MangaImpl>(json)
+        val jsonManga = legacyBackupManager.parser.fromJson<MangaImpl>(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<Manga>()
         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<Chapter>()
@@ -244,8 +245,8 @@ class BackupTest {
         }
 
         // Check parser
-        val chaptersJson = backupManager.parser.toJsonTree(chapters)
-        val restoredChapters = backupManager.parser.fromJson<List<ChapterImpl>>(chaptersJson)
+        val chaptersJson = legacyBackupManager.parser.toJsonTree(chapters)
+        val restoredChapters = legacyBackupManager.parser.fromJson<List<ChapterImpl>>(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<Pair<List<Chapter>, List<Chapter>>>()
         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<List<DHistory>>(historyListJson)
+        val historyListJson = legacyBackupManager.parser.toJsonTree(historyList)
+        val history = legacyBackupManager.parser.fromJson<List<DHistory>>(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<List<TrackImpl>>(trackListJson)
-        backupManager.restoreTrackForManga(manga, trackListRestore)
+        var trackListJson = legacyBackupManager.parser.toJsonTree(trackList)
+        var trackListRestore = legacyBackupManager.parser.fromJson<List<TrackImpl>>(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<List<TrackImpl>>(trackListJson)
-        backupManager.restoreTrackForManga(manga2, trackListRestore)
+        trackListJson = legacyBackupManager.parser.toJsonTree(trackList)
+        trackListRestore = legacyBackupManager.parser.fromJson<List<TrackImpl>>(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
     }