浏览代码

Rewrote Backup (#650)

* Rewrote Backup

* Save automatic backups with datetime

* Minor improvements

* Remove suggested directories for backup and hardcoded strings. Rename JSON -> Backup

* Bugfix

* Fix tests

* Run restore inside a transaction, use external cache dir for log and other minor changes
Bram van de Kerkhof 8 年之前
父节点
当前提交
0642889b64
共有 39 个文件被更改,包括 2121 次插入1104 次删除
  1. 9 1
      app/src/main/AndroidManifest.xml
  2. 2 0
      app/src/main/java/eu/kanade/tachiyomi/App.kt
  3. 166 0
      app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupCreateService.kt
  4. 41 0
      app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupCreatorJob.kt
  5. 284 250
      app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupManager.kt
  6. 413 0
      app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupRestoreService.kt
  7. 23 0
      app/src/main/java/eu/kanade/tachiyomi/data/backup/models/Backup.kt
  8. 3 0
      app/src/main/java/eu/kanade/tachiyomi/data/backup/models/DHistory.kt
  9. 0 16
      app/src/main/java/eu/kanade/tachiyomi/data/backup/serializer/BooleanSerializer.kt
  10. 31 0
      app/src/main/java/eu/kanade/tachiyomi/data/backup/serializer/CategoryTypeAdapter.kt
  11. 61 0
      app/src/main/java/eu/kanade/tachiyomi/data/backup/serializer/ChapterTypeAdapter.kt
  12. 32 0
      app/src/main/java/eu/kanade/tachiyomi/data/backup/serializer/HistoryTypeAdapter.kt
  13. 0 27
      app/src/main/java/eu/kanade/tachiyomi/data/backup/serializer/IdExclusion.kt
  14. 0 17
      app/src/main/java/eu/kanade/tachiyomi/data/backup/serializer/IntegerSerializer.kt
  15. 0 16
      app/src/main/java/eu/kanade/tachiyomi/data/backup/serializer/LongSerializer.kt
  16. 37 0
      app/src/main/java/eu/kanade/tachiyomi/data/backup/serializer/MangaTypeAdapter.kt
  17. 53 0
      app/src/main/java/eu/kanade/tachiyomi/data/backup/serializer/TrackTypeAdapter.kt
  18. 2 0
      app/src/main/java/eu/kanade/tachiyomi/data/database/DatabaseHelper.kt
  19. 6 0
      app/src/main/java/eu/kanade/tachiyomi/data/database/queries/ChapterQueries.kt
  20. 15 0
      app/src/main/java/eu/kanade/tachiyomi/data/database/queries/HistoryQueries.kt
  21. 35 0
      app/src/main/java/eu/kanade/tachiyomi/data/database/resolvers/ChapterBackupPutResolver.kt
  22. 6 0
      app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferenceKeys.kt
  23. 10 0
      app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferencesHelper.kt
  24. 0 163
      app/src/main/java/eu/kanade/tachiyomi/ui/backup/BackupFragment.kt
  25. 0 94
      app/src/main/java/eu/kanade/tachiyomi/ui/backup/BackupPresenter.kt
  26. 0 2
      app/src/main/java/eu/kanade/tachiyomi/ui/main/MainActivity.kt
  27. 1 0
      app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsActivity.kt
  28. 1 0
      app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsAdvancedFragment.kt
  29. 413 0
      app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsBackupFragment.kt
  30. 1 29
      app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsDownloadsFragment.kt
  31. 1 0
      app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsFragment.kt
  32. 0 12
      app/src/main/java/eu/kanade/tachiyomi/util/FileExtensions.kt
  33. 33 0
      app/src/main/java/eu/kanade/tachiyomi/widget/CustomLayoutPicker.kt
  34. 0 4
      app/src/main/res/menu/menu_navigation.xml
  35. 42 0
      app/src/main/res/values/arrays.xml
  36. 7 1
      app/src/main/res/values/keys.xml
  37. 37 8
      app/src/main/res/values/strings.xml
  38. 48 0
      app/src/main/res/xml/pref_backup.xml
  39. 308 464
      app/src/test/java/eu/kanade/tachiyomi/data/backup/BackupTest.kt

+ 9 - 1
app/src/main/AndroidManifest.xml

@@ -45,7 +45,7 @@
             android:label="@string/label_categories"
             android:parentActivityName=".ui.main.MainActivity" />
         <activity
-            android:name=".ui.setting.SettingsDownloadsFragment$CustomLayoutPickerActivity"
+            android:name=".widget.CustomLayoutPickerActivity"
             android:label="@string/app_name"
             android:theme="@style/FilePickerTheme" />
         <activity
@@ -102,6 +102,14 @@
             android:name=".data.updater.UpdateDownloaderService"
             android:exported="false" />
 
+        <service
+            android:name=".data.backup.BackupCreateService"
+            android:exported="false"/>
+
+        <service
+            android:name=".data.backup.BackupRestoreService"
+            android:exported="false"/>
+
         <meta-data
             android:name="eu.kanade.tachiyomi.data.glide.AppGlideModule"
             android:value="GlideModule" />

+ 2 - 0
app/src/main/java/eu/kanade/tachiyomi/App.kt

@@ -5,6 +5,7 @@ import android.content.Context
 import android.content.res.Configuration
 import android.support.multidex.MultiDex
 import com.evernote.android.job.JobManager
+import eu.kanade.tachiyomi.data.backup.BackupCreatorJob
 import eu.kanade.tachiyomi.data.library.LibraryUpdateJob
 import eu.kanade.tachiyomi.data.updater.UpdateCheckerJob
 import eu.kanade.tachiyomi.util.LocaleHelper
@@ -58,6 +59,7 @@ open class App : Application() {
             when (tag) {
                 LibraryUpdateJob.TAG -> LibraryUpdateJob()
                 UpdateCheckerJob.TAG -> UpdateCheckerJob()
+                BackupCreatorJob.TAG -> BackupCreatorJob()
                 else -> null
             }
         }

+ 166 - 0
app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupCreateService.kt

@@ -0,0 +1,166 @@
+package eu.kanade.tachiyomi.data.backup
+
+import android.app.IntentService
+import android.content.Context
+import android.content.Intent
+import android.net.Uri
+import com.github.salomonbrys.kotson.set
+import com.google.gson.JsonArray
+import com.google.gson.JsonObject
+import com.hippo.unifile.UniFile
+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.MANGAS
+import eu.kanade.tachiyomi.data.backup.models.Backup.VERSION
+import eu.kanade.tachiyomi.data.database.models.Manga
+import eu.kanade.tachiyomi.ui.setting.SettingsBackupFragment
+import eu.kanade.tachiyomi.util.sendLocalBroadcast
+import timber.log.Timber
+import eu.kanade.tachiyomi.BuildConfig.APPLICATION_ID as ID
+
+/**
+ * [IntentService] used to backup [Manga] information to [JsonArray]
+ */
+class BackupCreateService : IntentService(NAME) {
+
+    companion object {
+        // Name of class
+        private const val NAME = "BackupCreateService"
+
+        // Uri as string
+        private const val EXTRA_URI = "$ID.$NAME.EXTRA_URI"
+        // Backup called from job
+        private const val EXTRA_IS_JOB = "$ID.$NAME.EXTRA_IS_JOB"
+        // Options for backup
+        private const val EXTRA_FLAGS = "$ID.$NAME.EXTRA_FLAGS"
+
+        // Filter options
+        internal const val BACKUP_CATEGORY = 0x1
+        internal const val BACKUP_CATEGORY_MASK = 0x1
+        internal const val BACKUP_CHAPTER = 0x2
+        internal const val BACKUP_CHAPTER_MASK = 0x2
+        internal const val BACKUP_HISTORY = 0x4
+        internal const val BACKUP_HISTORY_MASK = 0x4
+        internal const val BACKUP_TRACK = 0x8
+        internal const val BACKUP_TRACK_MASK = 0x8
+        internal const val BACKUP_ALL = 0xF
+
+        /**
+         * Make a backup from library
+         *
+         * @param context context of application
+         * @param path path of Uri
+         * @param flags determines what to backup
+         * @param isJob backup called from job
+         */
+        fun makeBackup(context: Context, path: String, flags: Int, isJob: Boolean = false) {
+            val intent = Intent(context, BackupCreateService::class.java).apply {
+                putExtra(EXTRA_URI, path)
+                putExtra(EXTRA_IS_JOB, isJob)
+                putExtra(EXTRA_FLAGS, flags)
+            }
+            context.startService(intent)
+        }
+    }
+
+    private val backupManager by lazy { BackupManager(this) }
+
+    override fun onHandleIntent(intent: Intent?) {
+        if (intent == null) return
+
+        // Get values
+        val uri = intent.getStringExtra(EXTRA_URI)
+        val isJob = intent.getBooleanExtra(EXTRA_IS_JOB, false)
+        val flags = intent.getIntExtra(EXTRA_FLAGS, 0)
+        // Create backup
+        createBackupFromApp(Uri.parse(uri), flags, isJob)
+    }
+
+    /**
+     * Create backup Json file from database
+     *
+     * @param uri path of Uri
+     * @param isJob backup called from job
+     */
+    fun createBackupFromApp(uri: Uri, flags: Int, isJob: Boolean) {
+        // Create root object
+        val root = JsonObject()
+
+        // Create information object
+        val information = JsonObject()
+
+        // Create manga array
+        val mangaEntries = JsonArray()
+
+        // Create category array
+        val categoryEntries = JsonArray()
+
+        // Add value's to root
+        root[VERSION] = Backup.CURRENT_VERSION
+        root[MANGAS] = mangaEntries
+        root[CATEGORIES] = categoryEntries
+
+        backupManager.databaseHelper.inTransaction {
+            // Get manga from database
+            val mangas = backupManager.getFavoriteManga()
+
+            // Backup library manga and its dependencies
+            mangas.forEach { manga ->
+                mangaEntries.add(backupManager.backupMangaObject(manga, flags))
+            }
+
+            // Backup categories
+            if ((flags and BACKUP_CATEGORY_MASK) == BACKUP_CATEGORY) {
+                backupManager.backupCategories(categoryEntries)
+            }
+        }
+
+        try {
+            // When BackupCreatorJob
+            if (isJob) {
+                // Get dir of file
+                val dir = UniFile.fromUri(this, uri)
+
+                // Delete older backups
+                val numberOfBackups = backupManager.numberOfBackups()
+                val backupRegex = Regex("""tachiyomi_\d+-\d+-\d+_\d+-\d+.json""")
+                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(Backup.getDefaultFilename())
+                        ?: throw Exception("Couldn't create backup file")
+
+                newFile.openOutputStream().bufferedWriter().use {
+                    backupManager.parser.toJson(root, it)
+                }
+            } else {
+                val file = UniFile.fromUri(this, uri)
+                        ?: throw Exception("Couldn't create backup file")
+                file.openOutputStream().bufferedWriter().use {
+                    backupManager.parser.toJson(root, it)
+                }
+
+                // Show completed dialog
+                val intent = Intent(SettingsBackupFragment.INTENT_FILTER).apply {
+                    putExtra(SettingsBackupFragment.ACTION, SettingsBackupFragment.ACTION_BACKUP_COMPLETED_DIALOG)
+                    putExtra(SettingsBackupFragment.EXTRA_URI, file.uri.toString())
+                }
+                sendLocalBroadcast(intent)
+            }
+        } catch (e: Exception) {
+            Timber.e(e)
+            if (!isJob) {
+                // Show error dialog
+                val intent = Intent(SettingsBackupFragment.INTENT_FILTER).apply {
+                    putExtra(SettingsBackupFragment.ACTION, SettingsBackupFragment.ACTION_ERROR_BACKUP_DIALOG)
+                    putExtra(SettingsBackupFragment.EXTRA_ERROR_MESSAGE, e.message)
+                }
+                sendLocalBroadcast(intent)
+            }
+        }
+    }
+}

+ 41 - 0
app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupCreatorJob.kt

@@ -0,0 +1,41 @@
+package eu.kanade.tachiyomi.data.backup
+
+import com.evernote.android.job.Job
+import com.evernote.android.job.JobManager
+import com.evernote.android.job.JobRequest
+import eu.kanade.tachiyomi.data.preference.PreferencesHelper
+import eu.kanade.tachiyomi.data.preference.getOrDefault
+import uy.kohesive.injekt.Injekt
+import uy.kohesive.injekt.api.get
+
+class BackupCreatorJob : Job() {
+
+    override fun onRunJob(params: Params): Result {
+        val preferences = Injekt.get<PreferencesHelper>()
+        val path = preferences.backupsDirectory().getOrDefault()
+        val flags = BackupCreateService.BACKUP_ALL
+        BackupCreateService.makeBackup(context,path,flags,true)
+        return Result.SUCCESS
+    }
+
+    companion object {
+        const val TAG = "BackupCreator"
+
+        fun setupTask(prefInterval: Int? = null) {
+            val preferences = Injekt.get<PreferencesHelper>()
+            val interval = prefInterval ?: preferences.backupInterval().getOrDefault()
+            if (interval > 0) {
+                JobRequest.Builder(TAG)
+                        .setPeriodic(interval * 60 * 60 * 1000L, 10 * 60 * 1000)
+                        .setPersisted(true)
+                        .setUpdateCurrent(true)
+                        .build()
+                        .schedule()
+            }
+        }
+
+        fun cancelTask() {
+            JobManager.instance().cancelAllForTag(TAG)
+        }
+    }
+}

+ 284 - 250
app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupManager.kt

@@ -1,203 +1,213 @@
 package eu.kanade.tachiyomi.data.backup
 
-import com.github.salomonbrys.kotson.fromJson
+import android.content.Context
+import com.github.salomonbrys.kotson.*
 import com.google.gson.*
-import com.google.gson.stream.JsonReader
-import eu.kanade.tachiyomi.data.backup.serializer.BooleanSerializer
-import eu.kanade.tachiyomi.data.backup.serializer.IdExclusion
-import eu.kanade.tachiyomi.data.backup.serializer.IntegerSerializer
-import eu.kanade.tachiyomi.data.backup.serializer.LongSerializer
+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.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.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.*
 import eu.kanade.tachiyomi.data.database.DatabaseHelper
 import eu.kanade.tachiyomi.data.database.models.*
-import java.io.*
+import eu.kanade.tachiyomi.data.preference.PreferencesHelper
+import eu.kanade.tachiyomi.data.preference.getOrDefault
+import eu.kanade.tachiyomi.source.Source
+import eu.kanade.tachiyomi.source.SourceManager
+import eu.kanade.tachiyomi.util.syncChaptersWithSource
+import rx.Observable
+import uy.kohesive.injekt.injectLazy
 import java.util.*
 
-/**
- * This class provides the necessary methods to create and restore backups for the data of the
- * application. The backup follows a JSON structure, with the following scheme:
- *
- * {
- *     "mangas": [
- *         {
- *             "manga": {"id": 1, ...},
- *             "chapters": [{"id": 1, ...}, {...}],
- *             "sync": [{"id": 1, ...}, {...}],
- *             "categories": ["cat1", "cat2", ...]
- *         },
- *         { ... }
- *     ],
- *     "categories": [
- *         {"id": 1, ...},
- *         {"id": 2, ...}
- *     ]
- * }
- *
- * @param db the database helper.
- */
-class BackupManager(private val db: DatabaseHelper) {
-
-    private val MANGA = "manga"
-    private val MANGAS = "mangas"
-    private val CHAPTERS = "chapters"
-    private val TRACK = "sync"
-    private val CATEGORIES = "categories"
-
-    @Suppress("PLATFORM_CLASS_MAPPED_TO_KOTLIN")
-    private val gson = GsonBuilder()
-            .registerTypeAdapter(java.lang.Integer::class.java, IntegerSerializer())
-            .registerTypeAdapter(java.lang.Boolean::class.java, BooleanSerializer())
-            .registerTypeAdapter(java.lang.Long::class.java, LongSerializer())
-            .setExclusionStrategies(IdExclusion())
-            .create()
+class BackupManager(val context: Context, version: Int = CURRENT_VERSION) {
 
     /**
-     * Backups the data of the application to a file.
-     *
-     * @param file the file where the backup will be saved.
-     * @throws IOException if there's any IO error.
+     * Database.
      */
-    @Throws(IOException::class)
-    fun backupToFile(file: File) {
-        val root = backupToJson()
+    internal val databaseHelper: DatabaseHelper by injectLazy()
 
-        FileWriter(file).use {
-            gson.toJson(root, it)
-        }
-    }
+    /**
+     * Source manager.
+     */
+    internal val sourceManager: SourceManager by injectLazy()
+
+    /**
+     * Version of parser
+     */
+    var version: Int = version
+        private set
 
     /**
-     * Creates a JSON object containing the backup of the app's data.
+     * Json Parser
+     */
+    var parser: Gson = initParser()
+
+    /**
+     * Preferences
+     */
+    private val preferences: PreferencesHelper by injectLazy()
+
+    /**
+     * Set version of parser
      *
-     * @return the backup as a JSON object.
+     * @param version version of parser
      */
-    fun backupToJson(): JsonObject {
-        val root = JsonObject()
-
-        // Backup library mangas and its dependencies
-        val mangaEntries = JsonArray()
-        root.add(MANGAS, mangaEntries)
-        for (manga in db.getFavoriteMangas().executeAsBlocking()) {
-            mangaEntries.add(backupManga(manga))
-        }
+    internal fun setVersion(version: Int) {
+        this.version = version
+        parser = initParser()
+    }
 
-        // Backup categories
-        val categoryEntries = JsonArray()
-        root.add(CATEGORIES, categoryEntries)
-        for (category in db.getCategories().executeAsBlocking()) {
-            categoryEntries.add(backupCategory(category))
+    private fun initParser(): Gson {
+        return when (version) {
+            1 -> GsonBuilder().create()
+            2 -> GsonBuilder()
+                    .registerTypeAdapter<MangaImpl>(MangaTypeAdapter.build())
+                    .registerTypeHierarchyAdapter<ChapterImpl>(ChapterTypeAdapter.build())
+                    .registerTypeAdapter<CategoryImpl>(CategoryTypeAdapter.build())
+                    .registerTypeAdapter<DHistory>(HistoryTypeAdapter.build())
+                    .registerTypeHierarchyAdapter<TrackImpl>(TrackTypeAdapter.build())
+                    .create()
+            else -> throw Exception("Json version unknown")
         }
+    }
 
-        return root
+    /**
+     * Backup the categories of library
+     *
+     * @param root root of categories json
+     */
+    internal fun backupCategories(root: JsonArray) {
+        val categories = databaseHelper.getCategories().executeAsBlocking()
+        categories.forEach { root.add(parser.toJsonTree(it)) }
     }
 
     /**
-     * Backups a manga and its related data (chapters, categories this manga is in, sync...).
+     * Convert a manga to Json
      *
-     * @param manga the manga to backup.
-     * @return a JSON object containing all the data of the manga.
+     * @param manga manga that gets converted
+     * @return [JsonElement] containing manga information
      */
-    private fun backupManga(manga: Manga): JsonObject {
+    internal fun backupMangaObject(manga: Manga, options: Int): JsonElement {
         // Entry for this manga
         val entry = JsonObject()
 
         // Backup manga fields
-        entry.add(MANGA, gson.toJsonTree(manga))
+        entry[MANGA] = parser.toJsonTree(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.isEmpty()) {
+                val chaptersJson = parser.toJsonTree(chapters)
+                if (chaptersJson.asJsonArray.size() > 0) {
+                    entry[CHAPTERS] = chaptersJson
+                }
+            }
+        }
 
-        // Backup all the chapters
-        val chapters = db.getChapters(manga).executeAsBlocking()
-        if (!chapters.isEmpty()) {
-            entry.add(CHAPTERS, gson.toJsonTree(chapters))
+        // 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.isEmpty()) {
+                val categoriesNames = categoriesForManga.map { it.name }
+                entry[CATEGORIES] = parser.toJsonTree(categoriesNames)
+            }
         }
 
-        // Backup tracks
-        val tracks = db.getTracks(manga).executeAsBlocking()
-        if (!tracks.isEmpty()) {
-            entry.add(TRACK, gson.toJsonTree(tracks))
+        // Check if user wants track information in backup
+        if (options and BACKUP_TRACK_MASK == BACKUP_TRACK) {
+            val tracks = databaseHelper.getTracks(manga).executeAsBlocking()
+            if (!tracks.isEmpty()) {
+                entry[TRACK] = parser.toJsonTree(tracks)
+            }
         }
 
-        // Backup categories for this manga
-        val categoriesForManga = db.getCategoriesForManga(manga).executeAsBlocking()
-        if (!categoriesForManga.isEmpty()) {
-            val categoriesNames = ArrayList<String>()
-            for (category in categoriesForManga) {
-                categoriesNames.add(category.name)
+        // 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.isEmpty()) {
+                val historyData = historyForManga.mapNotNull { history ->
+                    val url = databaseHelper.getChapter(history.chapter_id).executeAsBlocking()?.url
+                    url?.let { DHistory(url, history.last_read) }
+                }
+                val historyJson = parser.toJsonTree(historyData)
+                if (historyJson.asJsonArray.size() > 0) {
+                    entry[HISTORY] = historyJson
+                }
             }
-            entry.add(CATEGORIES, gson.toJsonTree(categoriesNames))
         }
 
         return entry
     }
 
-    /**
-     * Backups a category.
-     *
-     * @param category the category to backup.
-     * @return a JSON object containing the data of the category.
-     */
-    private fun backupCategory(category: Category): JsonElement {
-        return gson.toJsonTree(category)
-    }
-
-    /**
-     * Restores a backup from a file.
-     *
-     * @param file the file containing the backup.
-     * @throws IOException if there's any IO error.
-     */
-    @Throws(IOException::class)
-    fun restoreFromFile(file: File) {
-        JsonReader(FileReader(file)).use {
-            val root = JsonParser().parse(it).asJsonObject
-            restoreFromJson(root)
-        }
+    fun restoreMangaNoFetch(manga: Manga, dbManga: Manga) {
+        manga.id = dbManga.id
+        manga.copyFrom(dbManga)
+        manga.favorite = true
+        insertManga(manga)
     }
 
     /**
-     * Restores a backup from an input stream.
+     * [Observable] that fetches manga information
      *
-     * @param stream the stream containing the backup.
-     * @throws IOException if there's any IO error.
+     * @param source source of manga
+     * @param manga manga that needs updating
+     * @return [Observable] that contains manga
      */
-    @Throws(IOException::class)
-    fun restoreFromStream(stream: InputStream) {
-        JsonReader(InputStreamReader(stream)).use {
-            val root = JsonParser().parse(it).asJsonObject
-            restoreFromJson(root)
-        }
+    fun restoreMangaFetchObservable(source: Source, manga: Manga): Observable<Manga> {
+        return source.fetchMangaDetails(manga)
+                .map { networkManga ->
+                    manga.copyFrom(networkManga)
+                    manga.favorite = true
+                    manga.initialized = true
+                    manga.id = insertManga(manga)
+                    manga
+                }
     }
 
     /**
-     * Restores a backup from a JSON object. Everything executes in a single transaction so that
-     * nothing is modified if there's an error.
+     * [Observable] that fetches chapter information
      *
-     * @param root the root of the JSON.
+     * @param source source of manga
+     * @param manga manga that needs updating
+     * @return [Observable] that contains manga
      */
-    fun restoreFromJson(root: JsonObject) {
-        db.inTransaction {
-            // Restore categories
-            root.get(CATEGORIES)?.let {
-                restoreCategories(it.asJsonArray)
-            }
-
-            // Restore mangas
-            root.get(MANGAS)?.let {
-                restoreMangas(it.asJsonArray)
-            }
-        }
+    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 {
+                    if (it.first.isNotEmpty()) {
+                        chapters.forEach { it.manga_id = manga.id }
+                        insertChapters(chapters)
+                    }
+                }
     }
 
     /**
-     * Restores the categories.
+     * Restore the categories from Json
      *
-     * @param jsonCategories the categories of the json.
+     * @param jsonCategories array containing categories
      */
-    private fun restoreCategories(jsonCategories: JsonArray) {
+    internal fun restoreCategories(jsonCategories: JsonArray) {
         // Get categories from file and from db
-        val dbCategories = db.getCategories().executeAsBlocking()
-        val backupCategories = gson.fromJson<List<CategoryImpl>>(jsonCategories)
+        val dbCategories = databaseHelper.getCategories().executeAsBlocking()
+        val backupCategories = parser.fromJson<List<CategoryImpl>>(jsonCategories)
 
         // Iterate over them
-        for (category in backupCategories) {
+        backupCategories.forEach { category ->
             // Used to know if the category is already in the db
             var found = false
             for (dbCategory in dbCategories) {
@@ -214,102 +224,20 @@ class BackupManager(private val db: DatabaseHelper) {
             if (!found) {
                 // Let the db assign the id
                 category.id = null
-                val result = db.insertCategory(category).executeAsBlocking()
+                val result = databaseHelper.insertCategory(category).executeAsBlocking()
                 category.id = result.insertedId()?.toInt()
             }
         }
     }
 
-    /**
-     * Restores all the mangas and its related data.
-     *
-     * @param jsonMangas the mangas and its related data (chapters, sync, categories) from the json.
-     */
-    private fun restoreMangas(jsonMangas: JsonArray) {
-        for (backupManga in jsonMangas) {
-            // Map every entry to objects
-            val element = backupManga.asJsonObject
-            val manga = gson.fromJson(element.get(MANGA), MangaImpl::class.java)
-            val chapters = gson.fromJson<List<ChapterImpl>>(element.get(CHAPTERS) ?: JsonArray())
-            val tracks = gson.fromJson<List<TrackImpl>>(element.get(TRACK) ?: JsonArray())
-            val categories = gson.fromJson<List<String>>(element.get(CATEGORIES) ?: JsonArray())
-
-            // Restore everything related to this manga
-            restoreManga(manga)
-            restoreChaptersForManga(manga, chapters)
-            restoreSyncForManga(manga, tracks)
-            restoreCategoriesForManga(manga, categories)
-        }
-    }
-
-    /**
-     * Restores a manga.
-     *
-     * @param manga the manga to restore.
-     */
-    private fun restoreManga(manga: Manga) {
-        // Try to find existing manga in db
-        val dbManga = db.getManga(manga.url, manga.source).executeAsBlocking()
-        if (dbManga == null) {
-            // Let the db assign the id
-            manga.id = null
-            val result = db.insertManga(manga).executeAsBlocking()
-            manga.id = result.insertedId()
-        } else {
-            // If it exists already, we copy only the values related to the source from the db
-            // (they can be up to date). Local values (flags) are kept from the backup.
-            manga.id = dbManga.id
-            manga.copyFrom(dbManga)
-            manga.favorite = true
-            db.insertManga(manga).executeAsBlocking()
-        }
-    }
-
-    /**
-     * Restores the chapters of a manga.
-     *
-     * @param manga the manga whose chapters have to be restored.
-     * @param chapters the chapters to restore.
-     */
-    private fun restoreChaptersForManga(manga: Manga, chapters: List<Chapter>) {
-        // Fix foreign keys with the current manga id
-        for (chapter in chapters) {
-            chapter.manga_id = manga.id
-        }
-
-        val dbChapters = db.getChapters(manga).executeAsBlocking()
-        val chaptersToUpdate = ArrayList<Chapter>()
-        for (backupChapter in chapters) {
-            // Try to find existing chapter in db
-            val pos = dbChapters.indexOf(backupChapter)
-            if (pos != -1) {
-                // The chapter is already in the db, only update its fields
-                val dbChapter = dbChapters[pos]
-                // If one of them was read, the chapter will be marked as read
-                dbChapter.read = backupChapter.read || dbChapter.read
-                dbChapter.last_page_read = Math.max(backupChapter.last_page_read, dbChapter.last_page_read)
-                chaptersToUpdate.add(dbChapter)
-            } else {
-                // Insert new chapter. Let the db assign the id
-                backupChapter.id = null
-                chaptersToUpdate.add(backupChapter)
-            }
-        }
-
-        // Update database
-        if (!chaptersToUpdate.isEmpty()) {
-            db.insertChapters(chaptersToUpdate).executeAsBlocking()
-        }
-    }
-
     /**
      * Restores the categories a manga is in.
      *
      * @param manga the manga whose categories have to be restored.
      * @param categories the categories to restore.
      */
-    private fun restoreCategoriesForManga(manga: Manga, categories: List<String>) {
-        val dbCategories = db.getCategories().executeAsBlocking()
+    internal fun restoreCategoriesForManga(manga: Manga, categories: List<String>) {
+        val dbCategories = databaseHelper.getCategories().executeAsBlocking()
         val mangaCategoriesToUpdate = ArrayList<MangaCategory>()
         for (backupCategoryStr in categories) {
             for (dbCategory in dbCategories) {
@@ -324,9 +252,38 @@ class BackupManager(private val db: DatabaseHelper) {
         if (!mangaCategoriesToUpdate.isEmpty()) {
             val mangaAsList = ArrayList<Manga>()
             mangaAsList.add(manga)
-            db.deleteOldMangasCategories(mangaAsList).executeAsBlocking()
-            db.insertMangasCategories(mangaCategoriesToUpdate).executeAsBlocking()
+            databaseHelper.deleteOldMangasCategories(mangaAsList).executeAsBlocking()
+            databaseHelper.insertMangasCategories(mangaCategoriesToUpdate).executeAsBlocking()
+        }
+    }
+
+    /**
+     * Restore history from Json
+     *
+     * @param history list containing history to be restored
+     */
+    internal fun restoreHistoryForManga(history: List<DHistory>) {
+        // List containing history to be updated
+        val historyToBeUpdated = ArrayList<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 = Math.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()
     }
 
     /**
@@ -335,34 +292,111 @@ class BackupManager(private val db: DatabaseHelper) {
      * @param manga the manga whose sync have to be restored.
      * @param tracks the track list to restore.
      */
-    private fun restoreSyncForManga(manga: Manga, tracks: List<Track>) {
+    internal fun restoreTrackForManga(manga: Manga, tracks: List<Track>) {
         // Fix foreign keys with the current manga id
-        for (track in tracks) {
-            track.manga_id = manga.id!!
-        }
+        tracks.map { it.manga_id = manga.id!! }
 
-        val dbTracks = db.getTracks(manga).executeAsBlocking()
+        // Get tracks from database
+        val dbTracks = databaseHelper.getTracks(manga).executeAsBlocking()
         val trackToUpdate = ArrayList<Track>()
-        for (backupTrack in tracks) {
-            // Try to find existing chapter in db
-            val pos = dbTracks.indexOf(backupTrack)
-            if (pos != -1) {
-                // The sync is already in the db, only update its fields
-                val dbSync = dbTracks[pos]
-                // Mark the max chapter as read and nothing else
-                dbSync.last_chapter_read = Math.max(backupTrack.last_chapter_read, dbSync.last_chapter_read)
-                trackToUpdate.add(dbSync)
-            } else {
+
+        for (track in tracks) {
+            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.remote_id != dbTrack.remote_id) {
+                        dbTrack.remote_id = track.remote_id
+                    }
+                    dbTrack.last_chapter_read = Math.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
-                backupTrack.id = null
-                trackToUpdate.add(backupTrack)
+                track.id = null
+                trackToUpdate.add(track)
             }
         }
-
         // Update database
         if (!trackToUpdate.isEmpty()) {
-            db.insertTracks(trackToUpdate).executeAsBlocking()
+            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
+
+        for (chapter in chapters) {
+            val pos = dbChapters.indexOf(chapter)
+            if (pos != -1) {
+                val dbChapter = dbChapters[pos]
+                chapter.id = dbChapter.id
+                chapter.copyFrom(dbChapter)
+                break
+            }
         }
+        // Filter the chapters that couldn't be found.
+        chapters.filter { it.id != null }
+        chapters.map { it.manga_id = manga.id }
+
+        insertChapters(chapters)
+        return true
+    }
+
+    /**
+     * Returns manga
+     *
+     * @return [Manga], null if not found
+     */
+    internal fun getMangaFromDatabase(manga: Manga): Manga? {
+        return databaseHelper.getManga(manga.url, manga.source).executeAsBlocking()
+    }
+
+    /**
+     * Returns list containing manga from library
+     *
+     * @return [Manga] from library
+     */
+    internal fun getFavoriteManga(): List<Manga> {
+        return databaseHelper.getFavoriteMangas().executeAsBlocking()
+    }
+
+    /**
+     * Inserts manga and returns id
+     *
+     * @return id of [Manga], null if not found
+     */
+    internal fun insertManga(manga: Manga): Long? {
+        return databaseHelper.insertManga(manga).executeAsBlocking().insertedId()
     }
 
+    /**
+     * Inserts list of chapters
+     */
+    internal fun insertChapters(chapters: List<Chapter>) {
+        databaseHelper.updateChaptersBackup(chapters).executeAsBlocking()
+    }
+
+    /**
+     * Return number of backups.
+     *
+     * @return number of backups selected by user
+     */
+    fun numberOfBackups(): Int {
+        return preferences.numberOfBackups().getOrDefault()
+    }
 }

+ 413 - 0
app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupRestoreService.kt

@@ -0,0 +1,413 @@
+package eu.kanade.tachiyomi.data.backup
+
+import android.app.Service
+import android.content.Context
+import android.content.Intent
+import android.net.Uri
+import android.os.IBinder
+import android.os.PowerManager
+import com.github.salomonbrys.kotson.fromJson
+import com.google.gson.JsonArray
+import com.google.gson.JsonParser
+import com.google.gson.stream.JsonReader
+import com.hippo.unifile.UniFile
+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.*
+import eu.kanade.tachiyomi.source.Source
+import eu.kanade.tachiyomi.ui.setting.SettingsBackupFragment
+import eu.kanade.tachiyomi.util.AndroidComponentUtil
+import eu.kanade.tachiyomi.util.chop
+import eu.kanade.tachiyomi.util.sendLocalBroadcast
+import rx.Observable
+import rx.Subscription
+import rx.schedulers.Schedulers
+import timber.log.Timber
+import uy.kohesive.injekt.injectLazy
+import java.io.File
+import java.text.SimpleDateFormat
+import java.util.*
+import eu.kanade.tachiyomi.BuildConfig.APPLICATION_ID as ID
+
+/**
+ * Restores backup from json file
+ */
+class BackupRestoreService : Service() {
+
+    companion object {
+        // Name of service
+        private const val NAME = "BackupRestoreService"
+
+        // Uri as string
+        private const val EXTRA_URI = "$ID.$NAME.EXTRA_URI"
+
+        /**
+         * Returns the status of the service.
+         *
+         * @param context the application context.
+         * @return true if the service is running, false otherwise.
+         */
+        fun isRunning(context: Context): Boolean {
+            return AndroidComponentUtil.isServiceRunning(context, BackupRestoreService::class.java)
+        }
+
+        /**
+         * Starts a service to restore a backup from Json
+         *
+         * @param context context of application
+         * @param uri path of Uri
+         */
+        fun start(context: Context, uri: String) {
+            if (!isRunning(context)) {
+                val intent = Intent(context, BackupRestoreService::class.java).apply {
+                    putExtra(EXTRA_URI, uri)
+                }
+                context.startService(intent)
+            }
+        }
+
+        /**
+         * Stops the service.
+         *
+         * @param context the application context.
+         */
+        fun stop(context: Context) {
+            context.stopService(Intent(context, BackupRestoreService::class.java))
+        }
+    }
+
+    /**
+     * Wake lock that will be held until the service is destroyed.
+     */
+    private lateinit var wakeLock: PowerManager.WakeLock
+
+    /**
+     * Subscription where the update is done.
+     */
+    private var subscription: Subscription? = null
+
+    /**
+     * The progress of a backup restore
+     */
+    private var restoreProgress = 0
+
+    /**
+     * Amount of manga in Json file (needed for restore)
+     */
+    private var restoreAmount = 0
+
+    /**
+     * List containing errors
+     */
+    private val errors = mutableListOf<Pair<Date, String>>()
+
+    /**
+     * Backup manager
+     */
+    private lateinit var backupManager: BackupManager
+
+    /**
+     * Database
+     */
+    private val db: DatabaseHelper by injectLazy()
+
+    /**
+     * Method called when the service is created. It injects dependencies and acquire the wake lock.
+     */
+    override fun onCreate() {
+        super.onCreate()
+        wakeLock = (getSystemService(Context.POWER_SERVICE) as PowerManager).newWakeLock(
+                PowerManager.PARTIAL_WAKE_LOCK, "BackupRestoreService:WakeLock")
+        wakeLock.acquire()
+    }
+
+    /**
+     * Method called when the service is destroyed. It destroys the running subscription and
+     * releases the wake lock.
+     */
+    override fun onDestroy() {
+        subscription?.unsubscribe()
+        if (wakeLock.isHeld) {
+            wakeLock.release()
+        }
+        super.onDestroy()
+    }
+
+    /**
+     * This method needs to be implemented, but it's not used/needed.
+     */
+    override fun onBind(intent: Intent): IBinder? {
+        return null
+    }
+
+    /**
+     * Method called when the service receives an intent.
+     *
+     * @param intent the start intent from.
+     * @param flags the flags of the command.
+     * @param startId the start id of this command.
+     * @return the start value of the command.
+     */
+    override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
+        if (intent == null) return Service.START_NOT_STICKY
+
+        // Unsubscribe from any previous subscription if needed.
+        subscription?.unsubscribe()
+
+        val startTime = System.currentTimeMillis()
+        subscription = Observable.defer {
+            // Get URI
+            val uri = Uri.parse(intent.getStringExtra(EXTRA_URI))
+            // Get file from Uri
+            val file = UniFile.fromUri(this, uri)
+
+            // Clear errors
+            errors.clear()
+
+            // Reset progress
+            restoreProgress = 0
+
+            db.lowLevel().beginTransaction()
+            getRestoreObservable(file)
+        }
+        .subscribeOn(Schedulers.io())
+        .subscribe({
+        }, { error ->
+            db.lowLevel().endTransaction()
+            Timber.e(error)
+            writeErrorLog()
+            val errorIntent = Intent(SettingsBackupFragment.INTENT_FILTER).apply {
+                putExtra(SettingsBackupFragment.ACTION, SettingsBackupFragment.ACTION_ERROR_RESTORE_DIALOG)
+                putExtra(SettingsBackupFragment.EXTRA_ERROR_MESSAGE, error.message)
+            }
+            sendLocalBroadcast(errorIntent)
+            stopSelf(startId)
+        }, {
+            db.lowLevel().setTransactionSuccessful()
+            db.lowLevel().endTransaction()
+            val endTime = System.currentTimeMillis()
+            val time = endTime - startTime
+            val file = writeErrorLog()
+            val completeIntent = Intent(SettingsBackupFragment.INTENT_FILTER).apply {
+                putExtra(SettingsBackupFragment.EXTRA_TIME, time)
+                putExtra(SettingsBackupFragment.EXTRA_ERRORS, errors.size)
+                putExtra(SettingsBackupFragment.EXTRA_ERROR_FILE_PATH, file.parent)
+                putExtra(SettingsBackupFragment.EXTRA_ERROR_FILE, file.name)
+                putExtra(SettingsBackupFragment.ACTION, SettingsBackupFragment.ACTION_RESTORE_COMPLETED_DIALOG)
+            }
+            sendLocalBroadcast(completeIntent)
+            stopSelf(startId)
+        })
+        return Service.START_NOT_STICKY
+    }
+
+    /**
+     * Returns an [Observable] containing restore process.
+     *
+     * @param file restore file
+     * @return [Observable<Manga>]
+     */
+    private fun getRestoreObservable(file: UniFile): Observable<Manga> {
+        val reader = JsonReader(file.openInputStream().bufferedReader())
+        val json = JsonParser().parse(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
+
+        // Restore categories
+        json.get(CATEGORIES)?.let {
+            backupManager.restoreCategories(it.asJsonArray)
+            restoreProgress += 1
+            showRestoreProgress(restoreProgress, restoreAmount, "Categories added", errors.size)
+        }
+
+        return Observable.from(mangasJson)
+                .concatMap {
+                    val obj = it.asJsonObject
+                    val manga = backupManager.parser.fromJson<MangaImpl>(obj.get(MANGA))
+                    val chapters = backupManager.parser.fromJson<List<ChapterImpl>>(obj.get(CHAPTERS) ?: JsonArray())
+                    val categories = backupManager.parser.fromJson<List<String>>(obj.get(CATEGORIES) ?: JsonArray())
+                    val history = backupManager.parser.fromJson<List<DHistory>>(obj.get(HISTORY) ?: JsonArray())
+                    val tracks = backupManager.parser.fromJson<List<TrackImpl>>(obj.get(TRACK) ?: JsonArray())
+
+                    val observable = getMangaRestoreObservable(manga, chapters, categories, history, tracks)
+                    if (observable != null) {
+                        observable
+                    } else {
+                        errors.add(Date() to "${manga.title} - ${getString(R.string.source_not_found)}")
+                        restoreProgress += 1
+                        val content = getString(R.string.dialog_restoring_source_not_found, manga.title.chop(15))
+                        showRestoreProgress(restoreProgress, restoreAmount, manga.title, errors.size, content)
+                        Observable.just(manga)
+                    }
+                }
+    }
+
+    /**
+     * Write errors to error log
+     */
+    private fun writeErrorLog(): File {
+        try {
+            if (errors.isNotEmpty()) {
+                val destFile = File(externalCacheDir, "tachiyomi_restore.log")
+                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("")
+    }
+
+    /**
+     * Returns a manga restore observable
+     *
+     * @param manga manga data from json
+     * @param chapters chapters data from json
+     * @param categories categories data from json
+     * @param history history data from json
+     * @param tracks tracking data from json
+     * @return [Observable] containing manga restore information
+     */
+    private fun getMangaRestoreObservable(manga: Manga, chapters: List<Chapter>,
+                                          categories: List<String>, history: List<DHistory>,
+                                          tracks: List<Track>): Observable<Manga>? {
+        // Get source
+        val source = backupManager.sourceManager.get(manga.source) ?: return null
+        val dbManga = backupManager.getMangaFromDatabase(manga)
+
+        if (dbManga == null) {
+            // Manga not in database
+            return mangaFetchObservable(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
+            return mangaNoFetchObservable(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 mangaFetchObservable(source: Source, manga: Manga, chapters: List<Chapter>,
+                                     categories: List<String>, history: List<DHistory>,
+                                     tracks: List<Track>): Observable<Manga> {
+        return backupManager.restoreMangaFetchObservable(source, manga)
+                .onErrorReturn {
+                    errors.add(Date() to "${manga.title} - ${it.message}")
+                    manga
+                }
+                .filter { it.id != null }
+                .flatMap { manga ->
+                    chapterFetchObservable(source, manga, chapters)
+                            // Convert to the manga that contains new chapters.
+                            .map { manga }
+                }
+                .doOnNext {
+                    // Restore categories
+                    backupManager.restoreCategoriesForManga(it, categories)
+
+                    // Restore history
+                    backupManager.restoreHistoryForManga(history)
+
+                    // Restore tracking
+                    backupManager.restoreTrackForManga(it, tracks)
+                }
+                .doOnCompleted {
+                    restoreProgress += 1
+                    showRestoreProgress(restoreProgress, restoreAmount, manga.title, errors.size)
+                }
+    }
+
+    private fun mangaNoFetchObservable(source: Source, backupManga: Manga, chapters: List<Chapter>,
+                                       categories: List<String>, history: List<DHistory>,
+                                       tracks: List<Track>): Observable<Manga> {
+
+        return Observable.just(backupManga)
+                .flatMap { manga ->
+                    if (!backupManager.restoreChaptersForManga(manga, chapters)) {
+                        chapterFetchObservable(source, manga, chapters)
+                                .map { manga }
+                    } else {
+                        Observable.just(manga)
+                    }
+                }
+                .doOnNext {
+                    // Restore categories
+                    backupManager.restoreCategoriesForManga(it, categories)
+
+                    // Restore history
+                    backupManager.restoreHistoryForManga(history)
+
+                    // Restore tracking
+                    backupManager.restoreTrackForManga(it, tracks)
+                }
+                .doOnCompleted {
+                    restoreProgress += 1
+                    showRestoreProgress(restoreProgress, restoreAmount, backupManga.title, errors.size)
+                }
+    }
+
+    /**
+     * [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 {
+                    errors.add(Date() to "${manga.title} - ${it.message}")
+                    Pair(emptyList<Chapter>(), emptyList<Chapter>())
+                }
+    }
+
+
+    /**
+     * Called to update dialog in [SettingsBackupFragment]
+     *
+     * @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, errors: Int,
+                                    content: String = getString(R.string.dialog_restoring_backup, title.chop(15))) {
+        val intent = Intent(SettingsBackupFragment.INTENT_FILTER).apply {
+            putExtra(SettingsBackupFragment.EXTRA_PROGRESS, progress)
+            putExtra(SettingsBackupFragment.EXTRA_AMOUNT, amount)
+            putExtra(SettingsBackupFragment.EXTRA_CONTENT, content)
+            putExtra(SettingsBackupFragment.EXTRA_ERRORS, errors)
+            putExtra(SettingsBackupFragment.ACTION, SettingsBackupFragment.ACTION_SET_PROGRESS_DIALOG)
+        }
+        sendLocalBroadcast(intent)
+    }
+
+}

+ 23 - 0
app/src/main/java/eu/kanade/tachiyomi/data/backup/models/Backup.kt

@@ -0,0 +1,23 @@
+package eu.kanade.tachiyomi.data.backup.models
+
+import java.text.SimpleDateFormat
+import java.util.*
+
+/**
+ * Json values
+ */
+object Backup {
+    const val CURRENT_VERSION = 2
+    const val MANGA = "manga"
+    const val MANGAS = "mangas"
+    const val TRACK = "track"
+    const val CHAPTERS = "chapters"
+    const val CATEGORIES = "categories"
+    const val HISTORY = "history"
+    const val VERSION = "version"
+
+    fun getDefaultFilename(): String {
+        val date = SimpleDateFormat("yyyy-MM-dd_HH-mm", Locale.getDefault()).format(Date())
+        return "tachiyomi_$date.json"
+    }
+}

+ 3 - 0
app/src/main/java/eu/kanade/tachiyomi/data/backup/models/DHistory.kt

@@ -0,0 +1,3 @@
+package eu.kanade.tachiyomi.data.backup.models
+
+data class DHistory(val url: String,val lastRead: Long)

+ 0 - 16
app/src/main/java/eu/kanade/tachiyomi/data/backup/serializer/BooleanSerializer.kt

@@ -1,16 +0,0 @@
-package eu.kanade.tachiyomi.data.backup.serializer
-
-import com.google.gson.JsonElement
-import com.google.gson.JsonPrimitive
-import com.google.gson.JsonSerializationContext
-import com.google.gson.JsonSerializer
-import java.lang.reflect.Type
-
-class BooleanSerializer : JsonSerializer<Boolean> {
-
-    override fun serialize(value: Boolean?, type: Type, context: JsonSerializationContext): JsonElement? {
-        if (value != null && value != false)
-            return JsonPrimitive(value)
-        return null
-    }
-}

+ 31 - 0
app/src/main/java/eu/kanade/tachiyomi/data/backup/serializer/CategoryTypeAdapter.kt

@@ -0,0 +1,31 @@
+package eu.kanade.tachiyomi.data.backup.serializer
+
+import com.github.salomonbrys.kotson.typeAdapter
+import com.google.gson.TypeAdapter
+import eu.kanade.tachiyomi.data.database.models.CategoryImpl
+
+/**
+ * JSON Serializer used to write / read [CategoryImpl] to / from json
+ */
+object CategoryTypeAdapter {
+
+    fun build(): TypeAdapter<CategoryImpl> {
+        return typeAdapter {
+            write {
+                beginArray()
+                value(it.name)
+                value(it.order)
+                endArray()
+            }
+
+            read {
+                beginArray()
+                val category = CategoryImpl()
+                category.name = nextString()
+                category.order = nextInt()
+                endArray()
+                category
+            }
+        }
+    }
+}

+ 61 - 0
app/src/main/java/eu/kanade/tachiyomi/data/backup/serializer/ChapterTypeAdapter.kt

@@ -0,0 +1,61 @@
+package eu.kanade.tachiyomi.data.backup.serializer
+
+import com.github.salomonbrys.kotson.typeAdapter
+import com.google.gson.TypeAdapter
+import com.google.gson.stream.JsonToken
+import eu.kanade.tachiyomi.data.database.models.ChapterImpl
+
+/**
+ * JSON Serializer used to write / read [ChapterImpl] to / from json
+ */
+object ChapterTypeAdapter {
+
+    private const val URL = "u"
+    private const val READ = "r"
+    private const val BOOKMARK = "b"
+    private const val LAST_READ = "l"
+
+    fun build(): TypeAdapter<ChapterImpl> {
+        return typeAdapter {
+            write {
+                if (it.read || it.bookmark || it.last_page_read != 0) {
+                    beginObject()
+                    name(URL)
+                    value(it.url)
+                    if (it.read) {
+                        name(READ)
+                        value(1)
+                    }
+                    if (it.bookmark) {
+                        name(BOOKMARK)
+                        value(1)
+                    }
+                    if (it.last_page_read != 0) {
+                        name(LAST_READ)
+                        value(it.last_page_read)
+                    }
+                    endObject()
+                }
+            }
+
+            read {
+                val chapter = ChapterImpl()
+                beginObject()
+                while (hasNext()) {
+                    if (peek() == JsonToken.NAME) {
+                        val name = nextName()
+
+                        when (name) {
+                            URL -> chapter.url = nextString()
+                            READ -> chapter.read = nextInt() == 1
+                            BOOKMARK -> chapter.bookmark = nextInt() == 1
+                            LAST_READ -> chapter.last_page_read = nextInt()
+                        }
+                    }
+                }
+                endObject()
+                chapter
+            }
+        }
+    }
+}

+ 32 - 0
app/src/main/java/eu/kanade/tachiyomi/data/backup/serializer/HistoryTypeAdapter.kt

@@ -0,0 +1,32 @@
+package eu.kanade.tachiyomi.data.backup.serializer
+
+import com.github.salomonbrys.kotson.typeAdapter
+import com.google.gson.TypeAdapter
+import eu.kanade.tachiyomi.data.backup.models.DHistory
+
+/**
+ * JSON Serializer used to write / read [DHistory] to / from json
+ */
+object HistoryTypeAdapter {
+
+    fun build(): TypeAdapter<DHistory> {
+        return typeAdapter {
+            write {
+                if (it.lastRead != 0L) {
+                    beginArray()
+                    value(it.url)
+                    value(it.lastRead)
+                    endArray()
+                }
+            }
+
+            read {
+                beginArray()
+                val url = nextString()
+                val lastRead = nextLong()
+                endArray()
+                DHistory(url, lastRead)
+            }
+        }
+    }
+}

+ 0 - 27
app/src/main/java/eu/kanade/tachiyomi/data/backup/serializer/IdExclusion.kt

@@ -1,27 +0,0 @@
-package eu.kanade.tachiyomi.data.backup.serializer
-
-import com.google.gson.ExclusionStrategy
-import com.google.gson.FieldAttributes
-import eu.kanade.tachiyomi.data.database.models.CategoryImpl
-import eu.kanade.tachiyomi.data.database.models.ChapterImpl
-import eu.kanade.tachiyomi.data.database.models.MangaImpl
-import eu.kanade.tachiyomi.data.database.models.TrackImpl
-
-class IdExclusion : ExclusionStrategy {
-
-    private val categoryExclusions = listOf("id")
-    private val mangaExclusions = listOf("id")
-    private val chapterExclusions = listOf("id", "manga_id")
-    private val syncExclusions = listOf("id", "manga_id", "update")
-
-    override fun shouldSkipField(f: FieldAttributes) = when (f.declaringClass) {
-        MangaImpl::class.java -> mangaExclusions.contains(f.name)
-        ChapterImpl::class.java -> chapterExclusions.contains(f.name)
-        TrackImpl::class.java -> syncExclusions.contains(f.name)
-        CategoryImpl::class.java -> categoryExclusions.contains(f.name)
-        else -> false
-    }
-
-    override fun shouldSkipClass(clazz: Class<*>) = false
-
-}

+ 0 - 17
app/src/main/java/eu/kanade/tachiyomi/data/backup/serializer/IntegerSerializer.kt

@@ -1,17 +0,0 @@
-package eu.kanade.tachiyomi.data.backup.serializer
-
-import com.google.gson.JsonElement
-import com.google.gson.JsonPrimitive
-import com.google.gson.JsonSerializationContext
-import com.google.gson.JsonSerializer
-
-import java.lang.reflect.Type
-
-class IntegerSerializer : JsonSerializer<Int> {
-
-    override fun serialize(value: Int?, type: Type, context: JsonSerializationContext): JsonElement? {
-        if (value != null && value !== 0)
-            return JsonPrimitive(value)
-        return null
-    }
-}

+ 0 - 16
app/src/main/java/eu/kanade/tachiyomi/data/backup/serializer/LongSerializer.kt

@@ -1,16 +0,0 @@
-package eu.kanade.tachiyomi.data.backup.serializer
-
-import com.google.gson.JsonElement
-import com.google.gson.JsonPrimitive
-import com.google.gson.JsonSerializationContext
-import com.google.gson.JsonSerializer
-import java.lang.reflect.Type
-
-class LongSerializer : JsonSerializer<Long> {
-
-    override fun serialize(value: Long?, type: Type, context: JsonSerializationContext): JsonElement? {
-        if (value != null && value !== 0L)
-            return JsonPrimitive(value)
-        return null
-    }
-}

+ 37 - 0
app/src/main/java/eu/kanade/tachiyomi/data/backup/serializer/MangaTypeAdapter.kt

@@ -0,0 +1,37 @@
+package eu.kanade.tachiyomi.data.backup.serializer
+
+import com.github.salomonbrys.kotson.typeAdapter
+import com.google.gson.TypeAdapter
+import eu.kanade.tachiyomi.data.database.models.MangaImpl
+
+/**
+ * JSON Serializer used to write / read [MangaImpl] to / from json
+ */
+object MangaTypeAdapter {
+
+    fun build(): TypeAdapter<MangaImpl> {
+        return typeAdapter {
+            write {
+                beginArray()
+                value(it.url)
+                value(it.title)
+                value(it.source)
+                value(it.viewer)
+                value(it.chapter_flags)
+                endArray()
+            }
+
+            read {
+                beginArray()
+                val manga = MangaImpl()
+                manga.url = nextString()
+                manga.title = nextString()
+                manga.source = nextLong()
+                manga.viewer = nextInt()
+                manga.chapter_flags = nextInt()
+                endArray()
+                manga
+            }
+        }
+    }
+}

+ 53 - 0
app/src/main/java/eu/kanade/tachiyomi/data/backup/serializer/TrackTypeAdapter.kt

@@ -0,0 +1,53 @@
+package eu.kanade.tachiyomi.data.backup.serializer
+
+import com.github.salomonbrys.kotson.typeAdapter
+import com.google.gson.TypeAdapter
+import com.google.gson.stream.JsonToken
+import eu.kanade.tachiyomi.data.database.models.TrackImpl
+
+/**
+ * JSON Serializer used to write / read [TrackImpl] to / from json
+ */
+object TrackTypeAdapter {
+
+    private const val SYNC = "s"
+    private const val REMOTE = "r"
+    private const val TITLE = "t"
+    private const val LAST_READ = "l"
+
+    fun build(): TypeAdapter<TrackImpl> {
+        return typeAdapter {
+            write {
+                beginObject()
+                name(TITLE)
+                value(it.title)
+                name(SYNC)
+                value(it.sync_id)
+                name(REMOTE)
+                value(it.remote_id)
+                name(LAST_READ)
+                value(it.last_chapter_read)
+                endObject()
+            }
+
+            read {
+                val track = TrackImpl()
+                beginObject()
+                while (hasNext()) {
+                    if (peek() == JsonToken.NAME) {
+                        val name = nextName()
+
+                        when (name) {
+                            TITLE -> track.title = nextString()
+                            SYNC -> track.sync_id = nextInt()
+                            REMOTE -> track.remote_id = nextInt()
+                            LAST_READ -> track.last_chapter_read = nextInt()
+                        }
+                    }
+                }
+                endObject()
+                track
+            }
+        }
+    }
+}

+ 2 - 0
app/src/main/java/eu/kanade/tachiyomi/data/database/DatabaseHelper.kt

@@ -24,4 +24,6 @@ open class DatabaseHelper(context: Context)
 
     inline fun inTransaction(block: () -> Unit) = db.inTransaction(block)
 
+    fun lowLevel() = db.lowLevel()
+
 }

+ 6 - 0
app/src/main/java/eu/kanade/tachiyomi/data/database/queries/ChapterQueries.kt

@@ -6,6 +6,7 @@ import eu.kanade.tachiyomi.data.database.DbProvider
 import eu.kanade.tachiyomi.data.database.models.Chapter
 import eu.kanade.tachiyomi.data.database.models.Manga
 import eu.kanade.tachiyomi.data.database.models.MangaChapter
+import eu.kanade.tachiyomi.data.database.resolvers.ChapterBackupPutResolver
 import eu.kanade.tachiyomi.data.database.resolvers.ChapterProgressPutResolver
 import eu.kanade.tachiyomi.data.database.resolvers.ChapterSourceOrderPutResolver
 import eu.kanade.tachiyomi.data.database.resolvers.MangaChapterGetResolver
@@ -60,6 +61,11 @@ interface ChapterQueries : DbProvider {
 
     fun deleteChapters(chapters: List<Chapter>) = db.delete().objects(chapters).prepare()
 
+    fun updateChaptersBackup(chapters: List<Chapter>) = db.put()
+            .objects(chapters)
+            .withPutResolver(ChapterBackupPutResolver())
+            .prepare()
+
     fun updateChapterProgress(chapter: Chapter) = db.put()
             .`object`(chapter)
             .withPutResolver(ChapterProgressPutResolver())

+ 15 - 0
app/src/main/java/eu/kanade/tachiyomi/data/database/queries/HistoryQueries.kt

@@ -1,5 +1,6 @@
 package eu.kanade.tachiyomi.data.database.queries
 
+import com.pushtorefresh.storio.sqlite.queries.DeleteQuery
 import com.pushtorefresh.storio.sqlite.queries.RawQuery
 import eu.kanade.tachiyomi.data.database.DbProvider
 import eu.kanade.tachiyomi.data.database.models.History
@@ -68,4 +69,18 @@ interface HistoryQueries : DbProvider {
             .objects(historyList)
             .withPutResolver(HistoryLastReadPutResolver())
             .prepare()
+
+    fun deleteHistory() = db.delete()
+            .byQuery(DeleteQuery.builder()
+                    .table(HistoryTable.TABLE)
+                    .build())
+            .prepare()
+
+    fun deleteHistoryNoLastRead() = db.delete()
+            .byQuery(DeleteQuery.builder()
+                    .table(HistoryTable.TABLE)
+                    .where("${HistoryTable.COL_LAST_READ} = ?")
+                    .whereArgs(0)
+                    .build())
+            .prepare()
 }

+ 35 - 0
app/src/main/java/eu/kanade/tachiyomi/data/database/resolvers/ChapterBackupPutResolver.kt

@@ -0,0 +1,35 @@
+package eu.kanade.tachiyomi.data.database.resolvers
+
+import android.content.ContentValues
+import com.pushtorefresh.storio.sqlite.StorIOSQLite
+import com.pushtorefresh.storio.sqlite.operations.put.PutResolver
+import com.pushtorefresh.storio.sqlite.operations.put.PutResult
+import com.pushtorefresh.storio.sqlite.queries.UpdateQuery
+import eu.kanade.tachiyomi.data.database.inTransactionReturn
+import eu.kanade.tachiyomi.data.database.models.Chapter
+import eu.kanade.tachiyomi.data.database.tables.ChapterTable
+
+class ChapterBackupPutResolver : PutResolver<Chapter>() {
+
+    override fun performPut(db: StorIOSQLite, chapter: Chapter) = db.inTransactionReturn {
+        val updateQuery = mapToUpdateQuery(chapter)
+        val contentValues = mapToContentValues(chapter)
+
+        val numberOfRowsUpdated = db.lowLevel().update(updateQuery, contentValues)
+        PutResult.newUpdateResult(numberOfRowsUpdated, updateQuery.table())
+    }
+
+    fun mapToUpdateQuery(chapter: Chapter) = UpdateQuery.builder()
+            .table(ChapterTable.TABLE)
+            .where("${ChapterTable.COL_URL} = ?")
+            .whereArgs(chapter.url)
+            .build()
+
+    fun mapToContentValues(chapter: Chapter) = ContentValues(3).apply {
+        put(ChapterTable.COL_READ, chapter.read)
+        put(ChapterTable.COL_BOOKMARK, chapter.bookmark)
+        put(ChapterTable.COL_LAST_PAGE_READ, chapter.last_page_read)
+    }
+
+}
+

+ 6 - 0
app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferenceKeys.kt

@@ -65,12 +65,18 @@ class PreferenceKeys(context: Context) {
 
     val enabledLanguages = context.getString(R.string.pref_source_languages)
 
+    val backupDirectory = context.getString(R.string.pref_backup_directory_key)
+
     val downloadsDirectory = context.getString(R.string.pref_download_directory_key)
 
     val downloadThreads = context.getString(R.string.pref_download_slots_key)
 
     val downloadOnlyOverWifi = context.getString(R.string.pref_download_only_over_wifi_key)
 
+    val numberOfBackups = context.getString(R.string.pref_backup_slots_key)
+
+    val backupInterval = context.getString(R.string.pref_backup_interval_key)
+
     val removeAfterReadSlots = context.getString(R.string.pref_remove_after_read_slots_key)
 
     val removeAfterMarkedAsRead = context.getString(R.string.pref_remove_after_marked_as_read_key)

+ 10 - 0
app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferencesHelper.kt

@@ -26,6 +26,10 @@ class PreferencesHelper(val context: Context) {
             File(Environment.getExternalStorageDirectory().absolutePath + File.separator +
                     context.getString(R.string.app_name), "downloads"))
 
+    private val defaultBackupDir = Uri.fromFile(
+            File(Environment.getExternalStorageDirectory().absolutePath + File.separator +
+                    context.getString(R.string.app_name), "backup"))
+
     fun startScreen() = prefs.getInt(keys.startScreen, 1)
 
     fun clear() = prefs.edit().clear().apply()
@@ -112,12 +116,18 @@ class PreferencesHelper(val context: Context) {
 
     fun anilistScoreType() = rxPrefs.getInteger("anilist_score_type", 0)
 
+    fun backupsDirectory() = rxPrefs.getString(keys.backupDirectory, defaultBackupDir.toString())
+
     fun downloadsDirectory() = rxPrefs.getString(keys.downloadsDirectory, defaultDownloadsDir.toString())
 
     fun downloadThreads() = rxPrefs.getInteger(keys.downloadThreads, 1)
 
     fun downloadOnlyOverWifi() = prefs.getBoolean(keys.downloadOnlyOverWifi, true)
 
+    fun numberOfBackups() = rxPrefs.getInteger(keys.numberOfBackups, 1)
+
+    fun backupInterval() = rxPrefs.getInteger(keys.backupInterval, 0)
+
     fun removeAfterReadSlots() = prefs.getInt(keys.removeAfterReadSlots, -1)
 
     fun removeAfterMarkedAsRead() = prefs.getBoolean(keys.removeAfterMarkedAsRead, false)

+ 0 - 163
app/src/main/java/eu/kanade/tachiyomi/ui/backup/BackupFragment.kt

@@ -1,163 +0,0 @@
-package eu.kanade.tachiyomi.ui.backup
-
-import android.app.Activity
-import android.app.Dialog
-import android.content.Intent
-import android.net.Uri
-import android.os.Bundle
-import android.view.LayoutInflater
-import android.view.View
-import android.view.ViewGroup
-import com.afollestad.materialdialogs.MaterialDialog
-import eu.kanade.tachiyomi.R
-import eu.kanade.tachiyomi.ui.base.activity.ActivityMixin
-import eu.kanade.tachiyomi.ui.base.fragment.BaseRxFragment
-import eu.kanade.tachiyomi.util.toast
-import kotlinx.android.synthetic.main.fragment_backup.*
-import nucleus.factory.RequiresPresenter
-import rx.Observable
-import rx.android.schedulers.AndroidSchedulers
-import rx.internal.util.SubscriptionList
-import rx.schedulers.Schedulers
-import timber.log.Timber
-import java.io.File
-import java.text.SimpleDateFormat
-import java.util.*
-
-/**
- * Fragment to create and restore backups of the application's data.
- * Uses R.layout.fragment_backup.
- */
-@RequiresPresenter(BackupPresenter::class)
-class BackupFragment : BaseRxFragment<BackupPresenter>() {
-
-    private var backupDialog: Dialog? = null
-    private var restoreDialog: Dialog? = null
-
-    private lateinit var subscriptions: SubscriptionList
-
-    override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedState: Bundle?): View {
-        return inflater.inflate(R.layout.fragment_backup, container, false)
-    }
-
-    override fun onViewCreated(view: View, savedState: Bundle?) {
-        setToolbarTitle(getString(R.string.label_backup))
-
-        (activity as ActivityMixin).requestPermissionsOnMarshmallow()
-        subscriptions = SubscriptionList()
-
-        backup_button.setOnClickListener {
-            val today = SimpleDateFormat("yyyy-MM-dd").format(Date())
-            val file = File(activity.externalCacheDir, "tachiyomi-$today.json")
-            presenter.createBackup(file)
-
-            backupDialog = MaterialDialog.Builder(activity)
-                    .content(R.string.backup_please_wait)
-                    .progress(true, 0)
-                    .show()
-        }
-
-        restore_button.setOnClickListener {
-            val intent = Intent(Intent.ACTION_GET_CONTENT)
-            intent.addCategory(Intent.CATEGORY_OPENABLE)
-            intent.type = "application/*"
-            val chooser = Intent.createChooser(intent, getString(R.string.file_select_backup))
-            startActivityForResult(chooser, REQUEST_BACKUP_OPEN)
-        }
-    }
-
-    override fun onDestroyView() {
-        subscriptions.unsubscribe()
-        super.onDestroyView()
-    }
-
-    /**
-     * Called from the presenter when the backup is completed.
-     *
-     * @param file the file where the backup is saved.
-     */
-    fun onBackupCompleted(file: File) {
-        dismissBackupDialog()
-        val intent = Intent(Intent.ACTION_SEND)
-        intent.type = "application/json"
-        intent.putExtra(Intent.EXTRA_STREAM, Uri.parse("file://" + file))
-        startActivity(Intent.createChooser(intent, ""))
-    }
-
-    /**
-     * Called from the presenter when the restore is completed.
-     */
-    fun onRestoreCompleted() {
-        dismissRestoreDialog()
-        context.toast(R.string.backup_completed)
-    }
-
-    /**
-     * Called from the presenter when there's an error doing the backup.
-     * @param error the exception thrown.
-     */
-    fun onBackupError(error: Throwable) {
-        dismissBackupDialog()
-        context.toast(error.message)
-    }
-
-    /**
-     * Called from the presenter when there's an error restoring the backup.
-     * @param error the exception thrown.
-     */
-    fun onRestoreError(error: Throwable) {
-        dismissRestoreDialog()
-        context.toast(error.message)
-    }
-
-    override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
-        if (data != null && resultCode == Activity.RESULT_OK && requestCode == REQUEST_BACKUP_OPEN) {
-            restoreDialog = MaterialDialog.Builder(activity)
-                    .content(R.string.restore_please_wait)
-                    .progress(true, 0)
-                    .show()
-
-            // When using cloud services, we have to open the input stream in a background thread.
-            Observable.fromCallable { context.contentResolver.openInputStream(data.data) }
-                    .subscribeOn(Schedulers.io())
-                    .observeOn(AndroidSchedulers.mainThread())
-                    .subscribe({
-                        presenter.restoreBackup(it)
-                    }, { error ->
-                        context.toast(error.message)
-                        Timber.e(error)
-                    })
-                    .apply { subscriptions.add(this) }
-
-        }
-    }
-
-    /**
-     * Dismisses the backup dialog.
-     */
-    fun dismissBackupDialog() {
-        backupDialog?.let {
-            it.dismiss()
-            backupDialog = null
-        }
-    }
-
-    /**
-     * Dismisses the restore dialog.
-     */
-    fun dismissRestoreDialog() {
-        restoreDialog?.let {
-            it.dismiss()
-            restoreDialog = null
-        }
-    }
-
-    companion object {
-
-        private val REQUEST_BACKUP_OPEN = 102
-
-        fun newInstance(): BackupFragment {
-            return BackupFragment()
-        }
-    }
-}

+ 0 - 94
app/src/main/java/eu/kanade/tachiyomi/ui/backup/BackupPresenter.kt

@@ -1,94 +0,0 @@
-package eu.kanade.tachiyomi.ui.backup
-
-import android.os.Bundle
-import eu.kanade.tachiyomi.data.backup.BackupManager
-import eu.kanade.tachiyomi.data.database.DatabaseHelper
-import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter
-import eu.kanade.tachiyomi.util.isNullOrUnsubscribed
-import rx.Observable
-import rx.Subscription
-import rx.android.schedulers.AndroidSchedulers
-import rx.schedulers.Schedulers
-import uy.kohesive.injekt.injectLazy
-import java.io.File
-import java.io.InputStream
-
-/**
- * Presenter of [BackupFragment].
- */
-class BackupPresenter : BasePresenter<BackupFragment>() {
-
-    /**
-     * Database.
-     */
-    val db: DatabaseHelper by injectLazy()
-
-    /**
-     * Backup manager.
-     */
-    private lateinit var backupManager: BackupManager
-
-    /**
-     * Subscription where the backup is restored.
-     */
-    private var restoreSubscription: Subscription? = null
-
-    /**
-     * Subscription where the backup is created.
-     */
-    private var backupSubscription: Subscription? = null
-
-    override fun onCreate(savedState: Bundle?) {
-        super.onCreate(savedState)
-        backupManager = BackupManager(db)
-    }
-
-    /**
-     * Creates a backup and saves it to a file.
-     *
-     * @param file the path where the file will be saved.
-     */
-    fun createBackup(file: File) {
-        if (backupSubscription.isNullOrUnsubscribed()) {
-            backupSubscription = getBackupObservable(file)
-                    .subscribeOn(Schedulers.io())
-                    .observeOn(AndroidSchedulers.mainThread())
-                    .subscribeFirst(
-                            { view, result -> view.onBackupCompleted(file) },
-                            BackupFragment::onBackupError)
-        }
-    }
-
-    /**
-     * Restores a backup from a stream.
-     *
-     * @param stream the input stream of the backup file.
-     */
-    fun restoreBackup(stream: InputStream) {
-        if (restoreSubscription.isNullOrUnsubscribed()) {
-            restoreSubscription = getRestoreObservable(stream)
-                    .subscribeOn(Schedulers.io())
-                    .observeOn(AndroidSchedulers.mainThread())
-                    .subscribeFirst(
-                            { view, result -> view.onRestoreCompleted() },
-                            BackupFragment::onRestoreError)
-        }
-    }
-
-    /**
-     * Returns the observable to save a backup.
-     */
-    private fun getBackupObservable(file: File) = Observable.fromCallable {
-        backupManager.backupToFile(file)
-        true
-    }
-
-    /**
-     * Returns the observable to restore a backup.
-     */
-    private fun getRestoreObservable(stream: InputStream) = Observable.fromCallable {
-        backupManager.restoreFromStream(stream)
-        true
-    }
-
-}

+ 0 - 2
app/src/main/java/eu/kanade/tachiyomi/ui/main/MainActivity.kt

@@ -8,7 +8,6 @@ import android.support.v4.view.GravityCompat
 import android.view.MenuItem
 import eu.kanade.tachiyomi.R
 import eu.kanade.tachiyomi.data.preference.PreferencesHelper
-import eu.kanade.tachiyomi.ui.backup.BackupFragment
 import eu.kanade.tachiyomi.ui.base.activity.BaseActivity
 import eu.kanade.tachiyomi.ui.catalogue.CatalogueFragment
 import eu.kanade.tachiyomi.ui.download.DownloadActivity
@@ -71,7 +70,6 @@ class MainActivity : BaseActivity() {
                         val intent = Intent(this, SettingsActivity::class.java)
                         startActivityForResult(intent, REQUEST_OPEN_SETTINGS)
                     }
-                    R.id.nav_drawer_backup -> setFragment(BackupFragment.newInstance(), id)
                 }
             }
             drawer.closeDrawer(GravityCompat.START)

+ 1 - 0
app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsActivity.kt

@@ -65,6 +65,7 @@ class SettingsActivity : BaseActivity(),
             "downloads_screen" -> SettingsDownloadsFragment.newInstance(key)
             "sources_screen" -> SettingsSourcesFragment.newInstance(key)
             "tracking_screen" -> SettingsTrackingFragment.newInstance(key)
+            "backup_screen" -> SettingsBackupFragment.newInstance(key)
             "advanced_screen" -> SettingsAdvancedFragment.newInstance(key)
             "about_screen" -> SettingsAboutFragment.newInstance(key)
             else -> SettingsFragment.newInstance(key)

+ 1 - 0
app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsAdvancedFragment.kt

@@ -108,6 +108,7 @@ class SettingsAdvancedFragment : SettingsFragment() {
                 .onPositive { dialog, which ->
                     (activity as SettingsActivity).parentFlags = SettingsActivity.FLAG_DATABASE_CLEARED
                     db.deleteMangasNotInLibrary().executeAsBlocking()
+                    db.deleteHistoryNoLastRead().executeAsBlocking()
                     activity.toast(R.string.clear_database_completed)
                 }
                 .show()

+ 413 - 0
app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsBackupFragment.kt

@@ -0,0 +1,413 @@
+package eu.kanade.tachiyomi.ui.setting
+
+import android.app.Activity
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import android.content.IntentFilter
+import android.net.Uri
+import android.os.Build
+import android.os.Bundle
+import android.support.v7.preference.XpPreferenceFragment
+import android.view.View
+import com.afollestad.materialdialogs.MaterialDialog
+import com.hippo.unifile.UniFile
+import com.nononsenseapps.filepicker.FilePickerActivity
+import eu.kanade.tachiyomi.R
+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.models.Backup
+import eu.kanade.tachiyomi.data.preference.PreferencesHelper
+import eu.kanade.tachiyomi.data.preference.getOrDefault
+import eu.kanade.tachiyomi.ui.base.activity.BaseActivity
+import eu.kanade.tachiyomi.util.*
+import eu.kanade.tachiyomi.widget.CustomLayoutPickerActivity
+import eu.kanade.tachiyomi.widget.preference.IntListPreference
+import net.xpece.android.support.preference.Preference
+import uy.kohesive.injekt.injectLazy
+import java.io.File
+import java.util.concurrent.TimeUnit
+import eu.kanade.tachiyomi.BuildConfig.APPLICATION_ID as ID
+
+/**
+ * Settings for [BackupCreateService] and [BackupRestoreService]
+ */
+class SettingsBackupFragment : SettingsFragment() {
+
+    companion object {
+        const val INTENT_FILTER = "SettingsBackupFragment"
+        const val ACTION_BACKUP_COMPLETED_DIALOG = "$ID.$INTENT_FILTER.ACTION_BACKUP_COMPLETED_DIALOG"
+        const val ACTION_SET_PROGRESS_DIALOG = "$ID.$INTENT_FILTER.ACTION_SET_PROGRESS_DIALOG"
+        const val ACTION_ERROR_BACKUP_DIALOG = "$ID.$INTENT_FILTER.ACTION_ERROR_BACKUP_DIALOG"
+        const val ACTION_ERROR_RESTORE_DIALOG = "$ID.$INTENT_FILTER.ACTION_ERROR_RESTORE_DIALOG"
+        const val ACTION_RESTORE_COMPLETED_DIALOG = "$ID.$INTENT_FILTER.ACTION_RESTORE_COMPLETED_DIALOG"
+        const val ACTION = "$ID.$INTENT_FILTER.ACTION"
+        const val EXTRA_PROGRESS = "$ID.$INTENT_FILTER.EXTRA_PROGRESS"
+        const val EXTRA_AMOUNT = "$ID.$INTENT_FILTER.EXTRA_AMOUNT"
+        const val EXTRA_ERRORS = "$ID.$INTENT_FILTER.EXTRA_ERRORS"
+        const val EXTRA_CONTENT = "$ID.$INTENT_FILTER.EXTRA_CONTENT"
+        const val EXTRA_ERROR_MESSAGE = "$ID.$INTENT_FILTER.EXTRA_ERROR_MESSAGE"
+        const val EXTRA_URI = "$ID.$INTENT_FILTER.EXTRA_URI"
+        const val EXTRA_TIME = "$ID.$INTENT_FILTER.EXTRA_TIME"
+        const val EXTRA_ERROR_FILE_PATH = "$ID.$INTENT_FILTER.EXTRA_ERROR_FILE_PATH"
+        const val EXTRA_ERROR_FILE = "$ID.$INTENT_FILTER.EXTRA_ERROR_FILE"
+
+        private const val BACKUP_CREATE = 201
+        private const val BACKUP_RESTORE = 202
+        private const val BACKUP_DIR = 203
+
+        fun newInstance(rootKey: String): SettingsBackupFragment {
+            val args = Bundle()
+            args.putString(XpPreferenceFragment.ARG_PREFERENCE_ROOT, rootKey)
+            return SettingsBackupFragment().apply { arguments = args }
+        }
+    }
+
+    /**
+     * Preference selected to create backup
+     */
+    private val createBackup: Preference by bindPref(R.string.pref_create_local_backup_key)
+
+    /**
+     * Preference selected to restore backup
+     */
+    private val restoreBackup: Preference by bindPref(R.string.pref_restore_local_backup_key)
+
+    /**
+     * Preference which determines the frequency of automatic backups.
+     */
+    private val automaticBackup: IntListPreference by bindPref(R.string.pref_backup_interval_key)
+
+    /**
+     * Preference containing number of automatic backups
+     */
+    private val backupSlots: IntListPreference by bindPref(R.string.pref_backup_slots_key)
+
+    /**
+     * Preference containing interval of automatic backups
+     */
+    private val backupDirPref: Preference by bindPref(R.string.pref_backup_directory_key)
+
+    /**
+     * Preferences
+     */
+    private val preferences: PreferencesHelper by injectLazy()
+
+    /**
+     * Value containing information on what to backup
+     */
+    private var backup_flags = 0
+
+    /**
+     * The root directory for backups..
+     */
+    private var backupDir = preferences.backupsDirectory().getOrDefault().let {
+        UniFile.fromUri(context, Uri.parse(it))
+    }
+
+    val restoreDialog: MaterialDialog by lazy {
+        MaterialDialog.Builder(context)
+                .title(R.string.backup)
+                .content(R.string.restoring_backup)
+                .progress(false, 100, true)
+                .cancelable(false)
+                .negativeText(R.string.action_stop)
+                .onNegative { materialDialog, _ ->
+                    BackupRestoreService.stop(context)
+                    materialDialog.dismiss()
+                }
+                .build()
+    }
+
+    val backupDialog: MaterialDialog by lazy {
+        MaterialDialog.Builder(context)
+                .title(R.string.backup)
+                .content(R.string.creating_backup)
+                .progress(true, 0)
+                .cancelable(false)
+                .build()
+    }
+
+    private val receiver = object : BroadcastReceiver() {
+
+        override fun onReceive(context: Context, intent: Intent) {
+            when (intent.getStringExtra(ACTION)) {
+                ACTION_BACKUP_COMPLETED_DIALOG -> {
+                    backupDialog.dismiss()
+                    val uri = Uri.parse(intent.getStringExtra(EXTRA_URI))
+                    val file = UniFile.fromUri(context, uri)
+                    MaterialDialog.Builder([email protected])
+                            .title(getString(R.string.backup_created))
+                            .content(getString(R.string.file_saved, file.filePath))
+                            .positiveText(getString(R.string.action_close))
+                            .negativeText(getString(R.string.action_export))
+                            .onPositive { materialDialog, _ -> materialDialog.dismiss() }
+                            .onNegative { _, _ ->
+                                val sendIntent = Intent(Intent.ACTION_SEND)
+                                sendIntent.type = "application/json"
+                                sendIntent.putExtra(Intent.EXTRA_STREAM, file.uri)
+                                startActivity(Intent.createChooser(sendIntent, ""))
+                            }
+                            .show()
+
+                }
+                ACTION_SET_PROGRESS_DIALOG -> {
+                    val progress = intent.getIntExtra(EXTRA_PROGRESS, 0)
+                    val amount = intent.getIntExtra(EXTRA_AMOUNT, 0)
+                    val content = intent.getStringExtra(EXTRA_CONTENT)
+                    restoreDialog.setContent(content)
+                    restoreDialog.setProgress(progress)
+                    restoreDialog.maxProgress = amount
+                }
+                ACTION_RESTORE_COMPLETED_DIALOG -> {
+                    restoreDialog.dismiss()
+                    val time = intent.getLongExtra(EXTRA_TIME, 0)
+                    val errors = intent.getIntExtra(EXTRA_ERRORS, 0)
+                    val path = intent.getStringExtra(EXTRA_ERROR_FILE_PATH)
+                    val file = intent.getStringExtra(EXTRA_ERROR_FILE)
+                    val timeString = String.format("%02d min, %02d sec",
+                            TimeUnit.MILLISECONDS.toMinutes(time),
+                            TimeUnit.MILLISECONDS.toSeconds(time) -
+                                    TimeUnit.MINUTES.toSeconds(TimeUnit.MILLISECONDS.toMinutes(time))
+                    )
+
+                    if (errors > 0) {
+                        MaterialDialog.Builder([email protected])
+                                .title(getString(R.string.restore_completed))
+                                .content(getString(R.string.restore_completed_content, timeString,
+                                        if (errors > 0) "$errors" else getString(android.R.string.no)))
+                                .positiveText(getString(R.string.action_close))
+                                .negativeText(getString(R.string.action_open_log))
+                                .onPositive { materialDialog, _ -> materialDialog.dismiss() }
+                                .onNegative { materialDialog, _ ->
+                                    if (!path.isEmpty()) {
+                                        val destFile = File(path, file)
+                                        val uri = destFile.getUriCompat(context)
+                                        val sendIntent = Intent(Intent.ACTION_VIEW).apply {
+                                            setDataAndType(uri, "text/plain")
+                                            flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_GRANT_READ_URI_PERMISSION
+                                        }
+                                        startActivity(sendIntent)
+                                    } else {
+                                        context.toast(getString(R.string.error_opening_log))
+                                    }
+                                    materialDialog.dismiss()
+                                }
+                                .show()
+                    }
+                }
+                ACTION_ERROR_BACKUP_DIALOG -> {
+                    context.toast(intent.getStringExtra(EXTRA_ERROR_MESSAGE))
+                    backupDialog.dismiss()
+                }
+                ACTION_ERROR_RESTORE_DIALOG -> {
+                    context.toast(intent.getStringExtra(EXTRA_ERROR_MESSAGE))
+                    restoreDialog.dismiss()
+                }
+            }
+        }
+
+    }
+
+    override fun onPause() {
+        context.unregisterLocalReceiver(receiver)
+        super.onPause()
+    }
+
+    override fun onStart() {
+        super.onStart()
+        context.registerLocalReceiver(receiver, IntentFilter(INTENT_FILTER))
+    }
+
+    override fun onViewCreated(view: View, savedState: Bundle?) {
+        super.onViewCreated(view, savedState)
+
+        (activity as BaseActivity).requestPermissionsOnMarshmallow()
+
+        // Set onClickListeners
+        createBackup.setOnPreferenceClickListener {
+            MaterialDialog.Builder(context)
+                    .title(R.string.pref_create_backup)
+                    .content(R.string.backup_choice)
+                    .items(R.array.backup_options)
+                    .itemsCallbackMultiChoice(arrayOf(0, 1, 2, 3, 4 /*todo not hard code*/)) { _, positions, _ ->
+                        // TODO not very happy with global value, but putExtra doesn't work
+                        backup_flags = 0
+                        for (i in 1..positions.size - 1) {
+                            when (positions[i]) {
+                                1 -> backup_flags = backup_flags or BackupCreateService.BACKUP_CATEGORY
+                                2 -> backup_flags = backup_flags or BackupCreateService.BACKUP_CHAPTER
+                                3 -> backup_flags = backup_flags or BackupCreateService.BACKUP_TRACK
+                                4 -> backup_flags = backup_flags or BackupCreateService.BACKUP_HISTORY
+                            }
+                        }
+                        // If API lower as KitKat use custom dir picker
+                        if (Build.VERSION.SDK_INT < Build.VERSION_CODES.KITKAT) {
+                            // Get dirs
+                            val currentDir = preferences.backupsDirectory().getOrDefault()
+
+                            val i = Intent(activity, CustomLayoutPickerActivity::class.java)
+                            i.putExtra(FilePickerActivity.EXTRA_ALLOW_MULTIPLE, false)
+                            i.putExtra(FilePickerActivity.EXTRA_ALLOW_CREATE_DIR, true)
+                            i.putExtra(FilePickerActivity.EXTRA_MODE, FilePickerActivity.MODE_DIR)
+                            i.putExtra(FilePickerActivity.EXTRA_START_PATH, currentDir)
+                            startActivityForResult(i, BACKUP_CREATE)
+                        } else {
+                            // Use Androids build in file creator
+                            val intent = Intent(Intent.ACTION_CREATE_DOCUMENT)
+                            intent.addCategory(Intent.CATEGORY_OPENABLE)
+
+                            // TODO create custom MIME data type? Will make older backups deprecated
+                            intent.type = "application/*"
+                            intent.putExtra(Intent.EXTRA_TITLE, Backup.getDefaultFilename())
+                            startActivityForResult(intent, BACKUP_CREATE)
+                        }
+                        true
+                    }
+                    .itemsDisabledIndices(0)
+                    .positiveText(getString(R.string.action_create))
+                    .negativeText(android.R.string.cancel)
+                    .show()
+            true
+        }
+
+        restoreBackup.setOnPreferenceClickListener {
+            if (Build.VERSION.SDK_INT < Build.VERSION_CODES.KITKAT) {
+                val intent = Intent()
+                intent.type = "application/*"
+                intent.action = Intent.ACTION_GET_CONTENT
+                startActivityForResult(Intent.createChooser(intent, getString(R.string.file_select_backup)), BACKUP_RESTORE)
+            } else {
+                val intent = Intent(Intent.ACTION_OPEN_DOCUMENT)
+                intent.addCategory(Intent.CATEGORY_OPENABLE)
+                intent.type = "application/*"
+                startActivityForResult(intent, BACKUP_RESTORE)
+            }
+            true
+        }
+
+        automaticBackup.setOnPreferenceChangeListener { _, newValue ->
+            // Always cancel the previous task, it seems that sometimes they are not updated.
+            BackupCreatorJob.cancelTask()
+
+            val interval = (newValue as String).toInt()
+            if (interval > 0) {
+                BackupCreatorJob.setupTask(interval)
+            }
+            true
+        }
+
+        backupSlots.setOnPreferenceChangeListener { preference, newValue ->
+            preferences.numberOfBackups().set((newValue as String).toInt())
+            preference.summary = newValue
+            true
+        }
+
+        backupDirPref.setOnPreferenceClickListener {
+            val currentDir = preferences.backupsDirectory().getOrDefault()
+
+            if (Build.VERSION.SDK_INT < 21) {
+                // Custom dir selected, open directory selector
+                val i = Intent(activity, CustomLayoutPickerActivity::class.java)
+                i.putExtra(FilePickerActivity.EXTRA_ALLOW_MULTIPLE, false)
+                i.putExtra(FilePickerActivity.EXTRA_ALLOW_CREATE_DIR, true)
+                i.putExtra(FilePickerActivity.EXTRA_MODE, FilePickerActivity.MODE_DIR)
+                i.putExtra(FilePickerActivity.EXTRA_START_PATH, currentDir)
+
+                startActivityForResult(i, BACKUP_DIR)
+            } else {
+                val i = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE)
+                startActivityForResult(i, BACKUP_DIR)
+            }
+
+            true
+        }
+
+        subscriptions += preferences.backupsDirectory().asObservable()
+                .subscribe { path ->
+                    backupDir = UniFile.fromUri(context, Uri.parse(path))
+                    backupDirPref.summary = backupDir.filePath ?: path
+                }
+
+        subscriptions += preferences.backupInterval().asObservable()
+                .subscribe {
+                    backupDirPref.isVisible = it > 0
+                    backupSlots.isVisible = it > 0
+                }
+    }
+
+    override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
+        when (requestCode) {
+            BACKUP_DIR -> if (data != null && resultCode == Activity.RESULT_OK) {
+                if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) {
+                    val uri = Uri.fromFile(File(data.data.path))
+                    preferences.backupsDirectory().set(uri.toString())
+                } else {
+                    val uri = data.data
+                    val flags = Intent.FLAG_GRANT_READ_URI_PERMISSION or
+                            Intent.FLAG_GRANT_WRITE_URI_PERMISSION
+
+                    context.contentResolver.takePersistableUriPermission(uri, flags)
+
+                    val file = UniFile.fromUri(context, uri)
+                    preferences.backupsDirectory().set(file.uri.toString())
+                }
+            }
+            BACKUP_CREATE -> if (data != null && resultCode == Activity.RESULT_OK) {
+                if (Build.VERSION.SDK_INT < Build.VERSION_CODES.KITKAT) {
+                    val dir = data.data.path
+                    val file = File(dir, Backup.getDefaultFilename())
+
+                    backupDialog.show()
+                    BackupCreateService.makeBackup(context, file.toURI().toString(), backup_flags)
+                } else {
+                    val uri = data.data
+                    val flags = Intent.FLAG_GRANT_READ_URI_PERMISSION or
+                            Intent.FLAG_GRANT_WRITE_URI_PERMISSION
+
+                    context.contentResolver.takePersistableUriPermission(uri, flags)
+                    val file = UniFile.fromUri(context, uri)
+
+                    backupDialog.show()
+                    BackupCreateService.makeBackup(context, file.uri.toString(), backup_flags)
+                }
+            }
+            BACKUP_RESTORE -> if (data != null && resultCode == Activity.RESULT_OK) {
+                if (Build.VERSION.SDK_INT < Build.VERSION_CODES.KITKAT) {
+                    val uri = Uri.fromFile(File(data.data.path))
+
+                    MaterialDialog.Builder(context)
+                            .title(getString(R.string.pref_restore_backup))
+                            .content(getString(R.string.backup_restore_content))
+                            .positiveText(getString(R.string.action_restore))
+                            .onPositive { materialDialog, _ ->
+                                materialDialog.dismiss()
+                                restoreDialog.show()
+                                BackupRestoreService.start(context, uri.toString())
+                            }
+                            .show()
+                } else {
+                    val uri = data.data
+                    val flags = Intent.FLAG_GRANT_READ_URI_PERMISSION or
+                            Intent.FLAG_GRANT_WRITE_URI_PERMISSION
+
+                    context.contentResolver.takePersistableUriPermission(uri, flags)
+                    val file = UniFile.fromUri(context, uri)
+
+                    MaterialDialog.Builder(context)
+                            .title(getString(R.string.pref_restore_backup))
+                            .content(getString(R.string.backup_restore_content))
+                            .positiveText(getString(R.string.action_restore))
+                            .onPositive { materialDialog, _ ->
+                                materialDialog.dismiss()
+                                restoreDialog.show()
+                                BackupRestoreService.start(context, file.uri.toString())
+                            }
+                            .show()
+                }
+            }
+        }
+    }
+
+}

+ 1 - 29
app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsDownloadsFragment.kt

@@ -9,21 +9,16 @@ import android.os.Environment
 import android.support.v4.content.ContextCompat
 import android.support.v7.preference.Preference
 import android.support.v7.preference.XpPreferenceFragment
-import android.support.v7.widget.RecyclerView
 import android.view.View
-import android.view.ViewGroup
 import com.afollestad.materialdialogs.MaterialDialog
 import com.hippo.unifile.UniFile
-import com.nononsenseapps.filepicker.AbstractFilePickerFragment
 import com.nononsenseapps.filepicker.FilePickerActivity
-import com.nononsenseapps.filepicker.FilePickerFragment
-import com.nononsenseapps.filepicker.LogicHandler
 import eu.kanade.tachiyomi.R
 import eu.kanade.tachiyomi.data.database.DatabaseHelper
 import eu.kanade.tachiyomi.data.preference.PreferencesHelper
 import eu.kanade.tachiyomi.data.preference.getOrDefault
-import eu.kanade.tachiyomi.util.inflate
 import eu.kanade.tachiyomi.util.plusAssign
+import eu.kanade.tachiyomi.widget.CustomLayoutPickerActivity
 import net.xpece.android.support.preference.MultiSelectListPreference
 import uy.kohesive.injekt.injectLazy
 import java.io.File
@@ -151,27 +146,4 @@ class SettingsDownloadsFragment : SettingsFragment() {
             }
         }
     }
-
-    class CustomLayoutPickerActivity : FilePickerActivity() {
-
-        override fun getFragment(startPath: String?, mode: Int, allowMultiple: Boolean, allowCreateDir: Boolean):
-                AbstractFilePickerFragment<File> {
-            val fragment = CustomLayoutFilePickerFragment()
-            fragment.setArgs(startPath, mode, allowMultiple, allowCreateDir)
-            return fragment
-        }
-    }
-
-    class CustomLayoutFilePickerFragment : FilePickerFragment() {
-        override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
-            when (viewType) {
-                LogicHandler.VIEWTYPE_DIR -> {
-                    val view = parent.inflate(R.layout.listitem_dir)
-                    return DirViewHolder(view)
-                }
-                else -> return super.onCreateViewHolder(parent, viewType)
-            }
-        }
-    }
-
 }

+ 1 - 0
app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsFragment.kt

@@ -29,6 +29,7 @@ open class SettingsFragment : XpPreferenceFragment() {
         addPreferencesFromResource(R.xml.pref_downloads)
         addPreferencesFromResource(R.xml.pref_sources)
         addPreferencesFromResource(R.xml.pref_tracking)
+        addPreferencesFromResource(R.xml.pref_backup)
         addPreferencesFromResource(R.xml.pref_advanced)
         addPreferencesFromResource(R.xml.pref_about)
 

+ 0 - 12
app/src/main/java/eu/kanade/tachiyomi/util/FileExtensions.kt

@@ -19,15 +19,3 @@ fun File.getUriCompat(context: Context): Uri {
     return uri
 }
 
-/**
- * Deletes file if exists
- *
- * @return success of file deletion
- */
-fun File.deleteIfExists(): Boolean {
-    if (this.exists()) {
-        this.delete()
-        return true
-    }
-    return false
-}

+ 33 - 0
app/src/main/java/eu/kanade/tachiyomi/widget/CustomLayoutPicker.kt

@@ -0,0 +1,33 @@
+package eu.kanade.tachiyomi.widget
+
+import android.support.v7.widget.RecyclerView
+import android.view.ViewGroup
+import com.nononsenseapps.filepicker.AbstractFilePickerFragment
+import com.nononsenseapps.filepicker.FilePickerActivity
+import com.nononsenseapps.filepicker.FilePickerFragment
+import com.nononsenseapps.filepicker.LogicHandler
+import eu.kanade.tachiyomi.R
+import eu.kanade.tachiyomi.util.inflate
+import java.io.File
+
+class CustomLayoutPickerActivity : FilePickerActivity() {
+
+    override fun getFragment(startPath: String?, mode: Int, allowMultiple: Boolean, allowCreateDir: Boolean):
+            AbstractFilePickerFragment<File> {
+        val fragment = CustomLayoutFilePickerFragment()
+        fragment.setArgs(startPath, mode, allowMultiple, allowCreateDir)
+        return fragment
+    }
+}
+
+class CustomLayoutFilePickerFragment : FilePickerFragment() {
+    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
+        when (viewType) {
+            LogicHandler.VIEWTYPE_DIR -> {
+                val view = parent.inflate(R.layout.listitem_dir)
+                return DirViewHolder(view)
+            }
+            else -> return super.onCreateViewHolder(parent, viewType)
+        }
+    }
+}

+ 0 - 4
app/src/main/res/menu/menu_navigation.xml

@@ -36,9 +36,5 @@
             android:icon="@drawable/ic_settings_black_24dp"
             android:title="@string/label_settings"
             android:checkable="false" />
-        <item
-            android:id="@+id/nav_drawer_backup"
-            android:icon="@drawable/ic_backup_black_24dp"
-            android:title="@string/label_backup" />
     </group>
 </menu>

+ 42 - 0
app/src/main/res/values/arrays.xml

@@ -48,6 +48,14 @@
         <item>3</item>
     </string-array>
 
+    <string-array name="backup_slots">
+        <item>1</item>
+        <item>2</item>
+        <item>3</item>
+        <item>4</item>
+        <item>5</item>
+    </string-array>
+
     <string-array name="remove_after_read_slots">
         <item>@string/disabled</item>
         <item>@string/last_read_chapter</item>
@@ -146,6 +154,24 @@
         <item>48</item>
     </string-array>
 
+    <string-array name="backup_update_interval">
+        <item>@string/update_never</item>
+        <item>@string/update_6hour</item>
+        <item>@string/update_12hour</item>
+        <item>@string/update_24hour</item>
+        <item>@string/update_48hour</item>
+        <item>@string/update_weekly</item>
+    </string-array>
+
+    <string-array name="backup_update_interval_values">
+        <item>0</item>
+        <item>6</item>
+        <item>12</item>
+        <item>24</item>
+        <item>48</item>
+        <item>168</item>
+    </string-array>
+
     <string-array name="library_update_restrictions">
         <item>@string/wifi</item>
         <item>@string/charging</item>
@@ -188,6 +214,22 @@
         <item>2</item>
     </string-array>
 
+    <string-array name="backup_options">
+        <item>@string/manga</item>
+        <item>@string/categories</item>
+        <item>@string/chapters</item>
+        <item>@string/track</item>
+        <item>@string/history</item>
+    </string-array>
+
+    <string-array name="backup_options_values">
+        <item>0</item>
+        <item>1</item>
+        <item>2</item>
+        <item>3</item>
+        <item>4</item>
+    </string-array>
+
     <string-array name="languages_values">
         <item/> <!-- system language -->
         <item>bg</item>

+ 7 - 1
app/src/main/res/values/keys.xml

@@ -52,6 +52,12 @@
     <string name="pref_remove_after_marked_as_read_key" translatable="false">pref_remove_after_marked_as_read_key</string>
     <string name="pref_last_used_category_key" translatable="false">last_used_category</string>
 
+    <string name="pref_create_local_backup_key" translatable="false">create_local_backup</string>
+    <string name="pref_restore_local_backup_key" translatable="false">restore_local_backup</string>
+    <string name="pref_backup_interval_key" translatable="false">backup_interval</string>
+    <string name="pref_backup_directory_key" translatable="false">backup_directory</string>
+    <string name="pref_backup_slots_key" translatable="false">backup_slots</string>
+
     <string name="pref_source_languages" translatable="false">source_languages</string>
     <string name="pref_category_tracking_accounts_key" translatable="false">category_tracking_accounts</string>
 
@@ -73,4 +79,4 @@
     <!-- String Fonts -->
     <string name="font_roboto_medium" translatable="false">sans-serif</string>
     <string name="font_roboto_regular" translatable="false">sans-serif</string>
-</resources>
+</resources>

+ 37 - 8
app/src/main/res/values/strings.xml

@@ -1,7 +1,13 @@
 <resources>
     <string name="app_name" translatable="false">Tachiyomi</string>
 
+    <!--Models-->
     <string name="name">Name</string>
+    <string name="categories">Categories</string>
+    <string name="manga">Manga</string>
+    <string name="chapters">Chapters</string>
+    <string name="track">Tracking</string>
+    <string name="history">History</string>
 
     <!-- Activities and fragments labels (toolbar title) -->
     <string name="label_settings">Settings</string>
@@ -53,11 +59,13 @@
     <string name="action_stop">Stop</string>
     <string name="action_pause">Pause</string>
     <string name="action_clear">Clear</string>
+    <string name="action_close">Close</string>
     <string name="action_previous_chapter">Previous chapter</string>
     <string name="action_next_chapter">Next chapter</string>
     <string name="action_retry">Retry</string>
     <string name="action_remove">Remove</string>
     <string name="action_resume">Resume</string>
+    <string name="action_move">Move</string>
     <string name="action_open_in_browser">Open in browser</string>
     <string name="action_add_to_home_screen">Add to home screen</string>
     <string name="action_display_mode">Change display mode</string>
@@ -72,6 +80,10 @@
     <string name="action_save">Save</string>
     <string name="action_reset">Reset</string>
     <string name="action_undo">Undo</string>
+    <string name="action_export">Export</string>
+    <string name="action_open_log">Open log</string>
+    <string name="action_create">Create</string>
+    <string name="action_restore">Restore</string>
 
     <!-- Operations -->
     <string name="deleting">Deleting…</string>
@@ -101,6 +113,8 @@
     <string name="update_12hour">Every 12 hours</string>
     <string name="update_24hour">Daily</string>
     <string name="update_48hour">Every 2 days</string>
+    <string name="update_weekly">Weekly</string>
+    <string name="update_monthly">Monthly</string>
     <string name="pref_library_update_categories">Categories to include in global update</string>
     <string name="all">All</string>
     <string name="pref_library_update_restriction">Library update restrictions</string>
@@ -181,6 +195,29 @@
       <!-- Sync section -->
     <string name="services">Services</string>
 
+    <!-- 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="pref_backup_directory">Backup directory</string>
+    <string name="pref_backup_service_category">Service</string>
+    <string name="pref_backup_interval">Backup frequency</string>
+    <string name="pref_backup_slots">Max automatic backups</string>
+    <string name="dialog_restoring_backup">Restoring backup\n%1$s added to library</string>
+    <string name="source_not_found">Source not found</string>
+    <string name="dialog_restoring_source_not_found">Restoring backup\n%1$s source not found</string>
+    <string name="backup_created">Backup created</string>
+    <string name="restore_completed">Restore completed</string>
+    <string name="error_opening_log">Could not open log</string>
+    <string name="restore_completed_content">Restore took %1$s.\n%2$s errors found.</string>
+    <string name="backup_restore_content">Restore uses source to fetch data, carrier costs may apply.\nAlso make sure you are properly logged in sources that require so before restoring.</string>
+    <string name="file_saved">File saved at %1$s</string>
+    <string name="backup_choice">What do you want to backup?</string>
+    <string name="restoring_backup">Restoring backup</string>
+    <string name="creating_backup">Creating backup</string>
+
       <!-- Advanced section -->
     <string name="pref_clear_chapter_cache">Clear chapter cache</string>
     <string name="used_cache">Used: %1$s</string>
@@ -290,7 +327,6 @@
     <string name="score">Score</string>
     <string name="title">Title</string>
     <string name="status">Status</string>
-    <string name="chapters">Chapters</string>
 
     <!-- Category activity -->
     <string name="error_category_exists">A category with this name already exists!</string>
@@ -324,13 +360,6 @@
     <string name="confirm_set_image_as_cover">Do you want to set this image as the cover?</string>
     <string name="viewer_for_this_series">Viewer for this series</string>
 
-    <!-- Backup fragment -->
-    <string name="backup">Backup</string>
-    <string name="restore">Restore</string>
-    <string name="backup_please_wait">Backup in progress. Please wait…</string>
-    <string name="backup_completed">Backup successfully restored</string>
-    <string name="restore_please_wait">Restoring backup. Please wait…</string>
-
     <!-- Recent manga fragment -->
     <string name="recent_manga_source">%1$s - Ch.%2$s</string>
 

+ 48 - 0
app/src/main/res/xml/pref_backup.xml

@@ -0,0 +1,48 @@
+<?xml version="1.0" encoding="utf-8"?>
+<PreferenceScreen xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:app="http://schemas.android.com/apk/res-auto">
+
+    <PreferenceScreen
+        android:icon="@drawable/ic_backup_black_24dp"
+        android:key="backup_screen"
+        android:persistent="false"
+        android:title="Backup"
+        app:asp_tintEnabled="true">
+
+        <Preference
+            android:key="@string/pref_create_local_backup_key"
+            android:summary="@string/pref_create_backup_summ"
+            android:title="@string/pref_create_backup" />
+
+        <Preference
+            android:key="@string/pref_restore_local_backup_key"
+            android:summary="@string/pref_restore_backup_summ"
+            android:title="@string/pref_restore_backup" />
+
+        <PreferenceCategory
+            android:persistent="false"
+            android:title="@string/pref_backup_service_category" />
+
+        <eu.kanade.tachiyomi.widget.preference.IntListPreference
+            android:defaultValue="0"
+            android:entries="@array/backup_update_interval"
+            android:entryValues="@array/backup_update_interval_values"
+            android:key="@string/pref_backup_interval_key"
+            android:summary="%s"
+            android:title="@string/pref_backup_interval"/>
+
+        <Preference
+            android:key="@string/pref_backup_directory_key"
+            android:title="@string/pref_backup_directory" />
+
+        <eu.kanade.tachiyomi.widget.preference.IntListPreference
+            android:defaultValue="1"
+            android:entries="@array/backup_slots"
+            android:entryValues="@array/backup_slots"
+            android:key="@string/pref_backup_slots_key"
+            android:summary="%s"
+            android:title="@string/pref_backup_slots" />
+
+    </PreferenceScreen>
+
+</PreferenceScreen>

+ 308 - 464
app/src/test/java/eu/kanade/tachiyomi/data/backup/BackupTest.kt

@@ -1,568 +1,412 @@
 package eu.kanade.tachiyomi.data.backup
 
+import android.app.Application
+import android.content.Context
 import android.os.Build
-import com.google.gson.Gson
-import com.google.gson.JsonElement
+import com.github.salomonbrys.kotson.fromJson
+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.database.DatabaseHelper
 import eu.kanade.tachiyomi.data.database.models.*
+import eu.kanade.tachiyomi.source.SourceManager
+import eu.kanade.tachiyomi.source.online.HttpSource
 import org.assertj.core.api.Assertions.assertThat
 import org.junit.Before
 import org.junit.Test
 import org.junit.runner.RunWith
+import org.mockito.Mockito
+import org.mockito.Mockito.*
 import org.robolectric.RuntimeEnvironment
 import org.robolectric.annotation.Config
-import uy.kohesive.injekt.injectLazy
-import java.util.*
-
+import rx.Observable
+import rx.observers.TestSubscriber
+import uy.kohesive.injekt.Injekt
+import uy.kohesive.injekt.api.InjektModule
+import uy.kohesive.injekt.api.InjektRegistrar
+import uy.kohesive.injekt.api.addSingleton
+
+/**
+ * Test class for the [BackupManager].
+ * Note that this does not include the backup create/restore services.
+ */
 @Config(constants = BuildConfig::class, sdk = intArrayOf(Build.VERSION_CODES.LOLLIPOP))
 @RunWith(CustomRobolectricGradleTestRunner::class)
 class BackupTest {
+    // Create root object
+    var root = JsonObject()
 
-    val gson: Gson by injectLazy()
+    // Create information object
+    var information = JsonObject()
 
-    lateinit var db: DatabaseHelper
+    // Create manga array
+    var mangaEntries = JsonArray()
+
+    // Create category array
+    var categoryEntries = JsonArray()
+
+    lateinit var app: Application
+    lateinit var context: Context
+    lateinit var source: HttpSource
 
     lateinit var backupManager: BackupManager
 
-    lateinit var root: JsonObject
+    lateinit var db: DatabaseHelper
 
     @Before
     fun setup() {
-        val app = RuntimeEnvironment.application
-        db = DatabaseHelper(app)
-        backupManager = BackupManager(db)
-        root = JsonObject()
-    }
+        app = RuntimeEnvironment.application
+        context = app.applicationContext
+        backupManager = BackupManager(context)
+        db = backupManager.databaseHelper
+
+        // Mock the source manager
+        val module = object : InjektModule {
+            override fun InjektRegistrar.registerInjectables() {
+                addSingleton(Mockito.mock(SourceManager::class.java, RETURNS_DEEP_STUBS))
+            }
+        }
+        Injekt.importModule(module)
 
-    @Test
-    fun testRestoreCategory() {
-        val catName = "cat"
-        root = createRootJson(null, toJson(createCategories(catName)))
-        backupManager.restoreFromJson(root)
+        source = mock(HttpSource::class.java)
+        `when`(backupManager.sourceManager.get(anyLong())).thenReturn(source)
 
-        val dbCats = db.getCategories().executeAsBlocking()
-        assertThat(dbCats).hasSize(1)
-        assertThat(dbCats[0].name).isEqualTo(catName)
+        root.add(Backup.MANGAS, mangaEntries)
+        root.add(Backup.CATEGORIES, categoryEntries)
     }
 
+    /**
+     * Test that checks if no crashes when no categories in library.
+     */
     @Test
     fun testRestoreEmptyCategory() {
-        root = createRootJson(null, toJson(ArrayList<Any>()))
-        backupManager.restoreFromJson(root)
-        val dbCats = db.getCategories().executeAsBlocking()
-        assertThat(dbCats).isEmpty()
-    }
+        // Initialize json with version 2
+        initializeJsonTest(2)
 
-    @Test
-    fun testRestoreExistingCategory() {
-        val catName = "cat"
-        db.insertCategory(createCategory(catName)).executeAsBlocking()
+        // Create backup of empty database
+        backupManager.backupCategories(categoryEntries)
 
-        root = createRootJson(null, toJson(createCategories(catName)))
-        backupManager.restoreFromJson(root)
+        // Restore Json
+        backupManager.restoreCategories(categoryEntries)
 
+        // Check if empty
         val dbCats = db.getCategories().executeAsBlocking()
-        assertThat(dbCats).hasSize(1)
-        assertThat(dbCats[0].name).isEqualTo(catName)
+        assertThat(dbCats).isEmpty()
     }
 
+    /**
+     * Test to check if single category gets restored
+     */
     @Test
-    fun testRestoreCategories() {
-        root = createRootJson(null, toJson(createCategories("cat", "cat2", "cat3")))
-        backupManager.restoreFromJson(root)
+    fun testRestoreSingleCategory() {
+        // Initialize json with version 2
+        initializeJsonTest(2)
 
-        val dbCats = db.getCategories().executeAsBlocking()
-        assertThat(dbCats).hasSize(3)
-    }
-
-    @Test
-    fun testRestoreExistingCategories() {
-        db.insertCategories(createCategories("cat", "cat2")).executeAsBlocking()
+        // Create category and add to json
+        val category = addSingleCategory("category")
 
-        root = createRootJson(null, toJson(createCategories("cat", "cat2", "cat3")))
-        backupManager.restoreFromJson(root)
+        // Restore Json
+        backupManager.restoreCategories(categoryEntries)
 
-        val dbCats = db.getCategories().executeAsBlocking()
-        assertThat(dbCats).hasSize(3)
+        // Check if successful
+        val dbCats = backupManager.databaseHelper.getCategories().executeAsBlocking()
+        assertThat(dbCats).hasSize(1)
+        assertThat(dbCats[0].name).isEqualTo(category.name)
     }
 
+    /**
+     * Test to check if multiple categories get restored.
+     */
     @Test
-    fun testRestoreExistingCategoriesAlt() {
-        db.insertCategories(createCategories("cat", "cat2", "cat3")).executeAsBlocking()
-
-        root = createRootJson(null, toJson(createCategories("cat", "cat2")))
-        backupManager.restoreFromJson(root)
-
-        val dbCats = db.getCategories().executeAsBlocking()
-        assertThat(dbCats).hasSize(3)
+    fun testRestoreMultipleCategories() {
+        // Initialize json with version 2
+        initializeJsonTest(2)
+
+        // Create category and add to json
+        val category = addSingleCategory("category")
+        val category2 = addSingleCategory("category2")
+        val category3 = addSingleCategory("category3")
+        val category4 = addSingleCategory("category4")
+        val category5 = addSingleCategory("category5")
+
+        // Insert category to test if no duplicates on restore.
+        db.insertCategory(category).executeAsBlocking()
+
+        // Restore Json
+        backupManager.restoreCategories(categoryEntries)
+
+        // Check if successful
+        val dbCats = backupManager.databaseHelper.getCategories().executeAsBlocking()
+        assertThat(dbCats).hasSize(5)
+        assertThat(dbCats[0].name).isEqualTo(category.name)
+        assertThat(dbCats[1].name).isEqualTo(category2.name)
+        assertThat(dbCats[2].name).isEqualTo(category3.name)
+        assertThat(dbCats[3].name).isEqualTo(category4.name)
+        assertThat(dbCats[4].name).isEqualTo(category5.name)
     }
 
+    /**
+     * Test if restore of manga is successful
+     */
     @Test
     fun testRestoreManga() {
-        val mangaName = "title"
-        val mangas = createMangas(mangaName)
-        val elements = ArrayList<JsonElement>()
-        for (manga in mangas) {
-            val entry = JsonObject()
-            entry.add("manga", toJson(manga))
-            elements.add(entry)
-        }
-        root = createRootJson(toJson(elements), null)
-        backupManager.restoreFromJson(root)
-
-        val dbMangas = db.getMangas().executeAsBlocking()
-        assertThat(dbMangas).hasSize(1)
-        assertThat(dbMangas[0].title).isEqualTo(mangaName)
-    }
-
-    @Test
-    fun testRestoreExistingManga() {
-        val mangaName = "title"
-        val manga = createManga(mangaName)
-
-        db.insertManga(manga).executeAsBlocking()
-
-        val elements = ArrayList<JsonElement>()
-        val entry = JsonObject()
-        entry.add("manga", toJson(manga))
-        elements.add(entry)
-
-        root = createRootJson(toJson(elements), null)
-        backupManager.restoreFromJson(root)
-
-        val dbMangas = db.getMangas().executeAsBlocking()
-        assertThat(dbMangas).hasSize(1)
-    }
-
-    @Test
-    fun testRestoreExistingMangaWithUpdatedFields() {
-        // Store a manga in db
-        val mangaName = "title"
-        val updatedThumbnailUrl = "updated thumbnail url"
-        var manga = createManga(mangaName)
-        manga.chapter_flags = 1024
-        manga.thumbnail_url = updatedThumbnailUrl
-        db.insertManga(manga).executeAsBlocking()
-
-        // Add an entry for a new manga with different attributes
-        manga = createManga(mangaName)
-        manga.chapter_flags = 512
-        val entry = JsonObject()
-        entry.add("manga", toJson(manga))
-
-        // Append the entry to the backup list
-        val elements = ArrayList<JsonElement>()
-        elements.add(entry)
-
-        // Restore from json
-        root = createRootJson(toJson(elements), null)
-        backupManager.restoreFromJson(root)
-
-        val dbMangas = db.getMangas().executeAsBlocking()
-        assertThat(dbMangas).hasSize(1)
-        assertThat(dbMangas[0].thumbnail_url).isEqualTo(updatedThumbnailUrl)
-        assertThat(dbMangas[0].chapter_flags).isEqualTo(512)
-    }
-
-    @Test
-    fun testRestoreChaptersForManga() {
-        // Create a manga and 3 chapters
-        val manga = createManga("title")
-        manga.id = 1L
-        val chapters = createChapters(manga, "1", "2", "3")
-
-        // Add an entry for the manga
-        val entry = JsonObject()
-        entry.add("manga", toJson(manga))
-        entry.add("chapters", toJson(chapters))
-
-        // Append the entry to the backup list
-        val mangas = ArrayList<JsonElement>()
-        mangas.add(entry)
-
-        // Restore from json
-        root = createRootJson(toJson(mangas), null)
-        backupManager.restoreFromJson(root)
-
-        val dbManga = db.getManga(1).executeAsBlocking()
-        assertThat(dbManga).isNotNull()
-
-        val dbChapters = db.getChapters(dbManga!!).executeAsBlocking()
-        assertThat(dbChapters).hasSize(3)
-    }
+        // Initialize json with version 2
+        initializeJsonTest(2)
 
-    @Test
-    fun testRestoreChaptersForExistingManga() {
-        val mangaId: Long = 3
-        // Create a manga and 3 chapters
-        val manga = createManga("title")
-        manga.id = mangaId
-        val chapters = createChapters(manga, "1", "2", "3")
-        db.insertManga(manga).executeAsBlocking()
-
-        // Add an entry for the manga
-        val entry = JsonObject()
-        entry.add("manga", toJson(manga))
-        entry.add("chapters", toJson(chapters))
-
-        // Append the entry to the backup list
-        val mangas = ArrayList<JsonElement>()
-        mangas.add(entry)
-
-        // Restore from json
-        root = createRootJson(toJson(mangas), null)
-        backupManager.restoreFromJson(root)
-
-        val dbManga = db.getManga(mangaId).executeAsBlocking()
-        assertThat(dbManga).isNotNull()
-
-        val dbChapters = db.getChapters(dbManga!!).executeAsBlocking()
-        assertThat(dbChapters).hasSize(3)
-    }
+        // Add manga to database
+        val manga = getSingleManga("One Piece")
+        manga.viewer = 3
+        manga.id = db.insertManga(manga).executeAsBlocking().insertedId()
 
-    @Test
-    fun testRestoreExistingChaptersForExistingManga() {
-        val mangaId: Long = 5
-        // Store a manga and 3 chapters
-        val manga = createManga("title")
-        manga.id = mangaId
-        var chapters = createChapters(manga, "1", "2", "3")
-        db.insertManga(manga).executeAsBlocking()
-        db.insertChapters(chapters).executeAsBlocking()
-
-        // The backup contains a existing chapter and a new one, so it should have 4 chapters
-        chapters = createChapters(manga, "3", "4")
-
-        // Add an entry for the manga
-        val entry = JsonObject()
-        entry.add("manga", toJson(manga))
-        entry.add("chapters", toJson(chapters))
-
-        // Append the entry to the backup list
-        val mangas = ArrayList<JsonElement>()
-        mangas.add(entry)
-
-        // Restore from json
-        root = createRootJson(toJson(mangas), null)
-        backupManager.restoreFromJson(root)
-
-        val dbManga = db.getManga(mangaId).executeAsBlocking()
-        assertThat(dbManga).isNotNull()
-
-        val dbChapters = db.getChapters(dbManga!!).executeAsBlocking()
-        assertThat(dbChapters).hasSize(4)
-    }
+        var favoriteManga = backupManager.databaseHelper.getFavoriteMangas().executeAsBlocking()
+        assertThat(favoriteManga).hasSize(1)
+        assertThat(favoriteManga[0].viewer).isEqualTo(3)
 
-    @Test
-    fun testRestoreCategoriesForManga() {
-        // Create a manga
-        val manga = createManga("title")
+        // Update json with all options enabled
+        mangaEntries.add(backupManager.backupMangaObject(manga,1))
 
-        // Create categories
-        val categories = createCategories("cat1", "cat2", "cat3")
+        // Change manga in database to default values
+        val dbManga = getSingleManga("One Piece")
+        dbManga.id = manga.id
+        db.insertManga(dbManga).executeAsBlocking()
 
-        // Add an entry for the manga
-        val entry = JsonObject()
-        entry.add("manga", toJson(manga))
-        entry.add("categories", toJson(createStringCategories("cat1")))
+        favoriteManga = backupManager.databaseHelper.getFavoriteMangas().executeAsBlocking()
+        assertThat(favoriteManga).hasSize(1)
+        assertThat(favoriteManga[0].viewer).isEqualTo(0)
 
-        // Append the entry to the backup list
-        val mangas = ArrayList<JsonElement>()
-        mangas.add(entry)
+        // Restore local manga
+        backupManager.restoreMangaNoFetch(manga,dbManga)
 
-        // Restore from json
-        root = createRootJson(toJson(mangas), toJson(categories))
-        backupManager.restoreFromJson(root)
+        // Test if restore successful
+        favoriteManga = backupManager.databaseHelper.getFavoriteMangas().executeAsBlocking()
+        assertThat(favoriteManga).hasSize(1)
+        assertThat(favoriteManga[0].viewer).isEqualTo(3)
 
-        val dbManga = db.getManga(1).executeAsBlocking()
-        assertThat(dbManga).isNotNull()
-
-        val result = db.getCategoriesForManga(dbManga!!).executeAsBlocking()
-
-        assertThat(result).hasSize(1)
-        assertThat(result).contains(Category.create("cat1"))
-        assertThat(result).doesNotContain(Category.create("cat2"))
-    }
-
-    @Test
-    fun testRestoreCategoriesForExistingManga() {
-        // Store a manga
-        val manga = createManga("title")
-        db.insertManga(manga).executeAsBlocking()
-
-        // Create categories
-        val categories = createCategories("cat1", "cat2", "cat3")
-
-        // Add an entry for the manga
-        val entry = JsonObject()
-        entry.add("manga", toJson(manga))
-        entry.add("categories", toJson(createStringCategories("cat1")))
-
-        // Append the entry to the backup list
-        val mangas = ArrayList<JsonElement>()
-        mangas.add(entry)
-
-        // Restore from json
-        root = createRootJson(toJson(mangas), toJson(categories))
-        backupManager.restoreFromJson(root)
-
-        val dbManga = db.getManga(1).executeAsBlocking()
-        assertThat(dbManga).isNotNull()
-
-        val result = db.getCategoriesForManga(dbManga!!).executeAsBlocking()
-
-        assertThat(result).hasSize(1)
-        assertThat(result).contains(Category.create("cat1"))
-        assertThat(result).doesNotContain(Category.create("cat2"))
-    }
-
-    @Test
-    fun testRestoreMultipleCategoriesForManga() {
-        // Create a manga
-        val manga = createManga("title")
+        // Clear database to test manga fetch
+        clearDatabase()
 
-        // Create categories
-        val categories = createCategories("cat1", "cat2", "cat3")
+        // Test if successful
+        favoriteManga = backupManager.databaseHelper.getFavoriteMangas().executeAsBlocking()
+        assertThat(favoriteManga).hasSize(0)
 
-        // Add an entry for the manga
-        val entry = JsonObject()
-        entry.add("manga", toJson(manga))
-        entry.add("categories", toJson(createStringCategories("cat1", "cat3")))
+        // Restore Json
+        // Create JSON from manga to test parser
+        val json = backupManager.parser.toJsonTree(manga)
+        // Restore JSON from manga to test parser
+        val jsonManga = backupManager.parser.fromJson<MangaImpl>(json)
 
-        // Append the entry to the backup list
-        val mangas = ArrayList<JsonElement>()
-        mangas.add(entry)
+        // Restore manga with fetch observable
+        val networkManga = getSingleManga("One Piece")
+        networkManga.description = "This is a description"
+        `when`(source.fetchMangaDetails(jsonManga)).thenReturn(Observable.just(networkManga))
 
-        // Restore from json
-        root = createRootJson(toJson(mangas), toJson(categories))
-        backupManager.restoreFromJson(root)
+        val obs = backupManager.restoreMangaFetchObservable(source, jsonManga)
+        val testSubscriber = TestSubscriber<Manga>()
+        obs.subscribe(testSubscriber)
 
-        val dbManga = db.getManga(1).executeAsBlocking()
-        assertThat(dbManga).isNotNull()
+        testSubscriber.assertNoErrors()
 
-        val result = db.getCategoriesForManga(dbManga!!).executeAsBlocking()
-
-        assertThat(result).hasSize(2)
-        assertThat(result).contains(Category.create("cat1"), Category.create("cat3"))
-        assertThat(result).doesNotContain(Category.create("cat2"))
+        // Check if restore successful
+        val dbCats = backupManager.databaseHelper.getFavoriteMangas().executeAsBlocking()
+        assertThat(dbCats).hasSize(1)
+        assertThat(dbCats[0].viewer).isEqualTo(3)
+        assertThat(dbCats[0].description).isEqualTo("This is a description")
     }
 
+    /**
+     * Test if chapter restore is successful
+     */
     @Test
-    fun testRestoreMultipleCategoriesForExistingMangaAndCategory() {
-        // Store a manga and a category
-        val manga = createManga("title")
-        manga.id = 1L
-        db.insertManga(manga).executeAsBlocking()
+    fun testRestoreChapters() {
+        // Initialize json with version 2
+        initializeJsonTest(2)
 
-        val cat = createCategory("cat1")
-        cat.id = 1
-        db.insertCategory(cat).executeAsBlocking()
-        db.insertMangaCategory(MangaCategory.create(manga, cat)).executeAsBlocking()
+        // Insert manga
+        val manga = getSingleManga("One Piece")
+        manga.id = backupManager.databaseHelper.insertManga(manga).executeAsBlocking().insertedId()
 
-        // Create categories
-        val categories = createCategories("cat1", "cat2", "cat3")
 
-        // Add an entry for the manga
-        val entry = JsonObject()
-        entry.add("manga", toJson(manga))
-        entry.add("categories", toJson(createStringCategories("cat1", "cat2")))
+        // Create restore list
+        val chapters = ArrayList<Chapter>()
+        for (i in 1..8){
+            val chapter = getSingleChapter("Chapter $i")
+            chapter.read = true
+            chapters.add(chapter)
+        }
 
-        // Append the entry to the backup list
-        val mangas = ArrayList<JsonElement>()
-        mangas.add(entry)
+        // Check parser
+        val chaptersJson = backupManager.parser.toJsonTree(chapters)
+        val restoredChapters = backupManager.parser.fromJson<List<ChapterImpl>>(chaptersJson)
 
-        // Restore from json
-        root = createRootJson(toJson(mangas), toJson(categories))
-        backupManager.restoreFromJson(root)
+        // Fetch chapters from upstream
+        // Create list
+        val chaptersRemote = ArrayList<Chapter>()
+        (1..10).mapTo(chaptersRemote) { getSingleChapter("Chapter $it") }
+        `when`(source.fetchChapterList(manga)).thenReturn(Observable.just(chaptersRemote))
 
-        val dbManga = db.getManga(1).executeAsBlocking()
-        assertThat(dbManga).isNotNull()
+        // Call restoreChapterFetchObservable
+        val obs = backupManager.restoreChapterFetchObservable(source, manga, restoredChapters)
+        val testSubscriber = TestSubscriber<Pair<List<Chapter>, List<Chapter>>>()
+        obs.subscribe(testSubscriber)
 
-        val result = db.getCategoriesForManga(dbManga!!).executeAsBlocking()
+        testSubscriber.assertNoErrors()
 
-        assertThat(result).hasSize(2)
-        assertThat(result).contains(Category.create("cat1"), Category.create("cat2"))
-        assertThat(result).doesNotContain(Category.create("cat3"))
+        val dbCats = backupManager.databaseHelper.getChapters(manga).executeAsBlocking()
+        assertThat(dbCats).hasSize(10)
+        assertThat(dbCats[0].read).isEqualTo(true)
     }
 
+    /**
+     * Test to check if history restore works
+     */
     @Test
-    fun testRestoreSyncForManga() {
-        // Create a manga and track
-        val manga = createManga("title")
-        manga.id = 1L
+    fun restoreHistoryForManga(){
+        // Initialize json with version 2
+        initializeJsonTest(2)
 
-        val track = createTrack(manga, 1, 2, 3)
+        val manga = getSingleManga("One Piece")
+        manga.id = backupManager.databaseHelper.insertManga(manga).executeAsBlocking().insertedId()
 
-        // Add an entry for the manga
-        val entry = JsonObject()
-        entry.add("manga", toJson(manga))
-        entry.add("sync", toJson(track))
+        // Create chapter
+        val chapter = getSingleChapter("Chapter 1")
+        chapter.manga_id = manga.id
+        chapter.read = true
+        chapter.id = backupManager.databaseHelper.insertChapter(chapter).executeAsBlocking().insertedId()
 
-        // Append the entry to the backup list
-        val mangas = ArrayList<JsonElement>()
-        mangas.add(entry)
+        val historyJson = getSingleHistory(chapter)
 
-        // Restore from json
-        root = createRootJson(toJson(mangas), null)
-        backupManager.restoreFromJson(root)
+        val historyList = ArrayList<DHistory>()
+        historyList.add(historyJson)
 
-        val dbManga = db.getManga(1).executeAsBlocking()
-        assertThat(dbManga).isNotNull()
+        // Check parser
+        val historyListJson = backupManager.parser.toJsonTree(historyList)
+        val history = backupManager.parser.fromJson<List<DHistory>>(historyListJson)
 
-        val dbSync = db.getTracks(dbManga!!).executeAsBlocking()
-        assertThat(dbSync).hasSize(3)
-    }
+        // Restore categories
+        backupManager.restoreHistoryForManga(history)
 
-    @Test
-    fun testRestoreSyncForExistingManga() {
-        val mangaId: Long = 3
-        // Create a manga and 3 sync
-        val manga = createManga("title")
-        manga.id = mangaId
-        val track = createTrack(manga, 1, 2, 3)
-        db.insertManga(manga).executeAsBlocking()
-
-        // Add an entry for the manga
-        val entry = JsonObject()
-        entry.add("manga", toJson(manga))
-        entry.add("sync", toJson(track))
-
-        // Append the entry to the backup list
-        val mangas = ArrayList<JsonElement>()
-        mangas.add(entry)
-
-        // Restore from json
-        root = createRootJson(toJson(mangas), null)
-        backupManager.restoreFromJson(root)
-
-        val dbManga = db.getManga(mangaId).executeAsBlocking()
-        assertThat(dbManga).isNotNull()
-
-        val dbSync = db.getTracks(dbManga!!).executeAsBlocking()
-        assertThat(dbSync).hasSize(3)
+        val historyDB = backupManager.databaseHelper.getHistoryByMangaId(manga.id!!).executeAsBlocking()
+        assertThat(historyDB).hasSize(1)
+        assertThat(historyDB[0].last_read).isEqualTo(1000)
     }
 
+    /**
+     * Test to check if tracking restore works
+     */
     @Test
-    fun testRestoreExistingSyncForExistingManga() {
-        val mangaId: Long = 5
-        // Store a manga and 3 sync
-        val manga = createManga("title")
-        manga.id = mangaId
-        var track = createTrack(manga, 1, 2, 3)
-        db.insertManga(manga).executeAsBlocking()
-        db.insertTracks(track).executeAsBlocking()
-
-        // The backup contains a existing sync and a new one, so it should have 4 sync
-        track = createTrack(manga, 3, 4)
-
-        // Add an entry for the manga
-        val entry = JsonObject()
-        entry.add("manga", toJson(manga))
-        entry.add("sync", toJson(track))
-
-        // Append the entry to the backup list
-        val mangas = ArrayList<JsonElement>()
-        mangas.add(entry)
-
-        // Restore from json
-        root = createRootJson(toJson(mangas), null)
-        backupManager.restoreFromJson(root)
-
-        val dbManga = db.getManga(mangaId).executeAsBlocking()
-        assertThat(dbManga).isNotNull()
-
-        val dbSync = db.getTracks(dbManga!!).executeAsBlocking()
-        assertThat(dbSync).hasSize(4)
-    }
-
-    private fun createRootJson(mangas: JsonElement?, categories: JsonElement?): JsonObject {
-        val root = JsonObject()
-        if (mangas != null)
-            root.add("mangas", mangas)
-        if (categories != null)
-            root.add("categories", categories)
-        return root
-    }
-
-    private fun createCategory(name: String): Category {
-        val c = CategoryImpl()
-        c.name = name
-        return c
+    fun restoreTrackForManga() {
+        // Initialize json with version 2
+        initializeJsonTest(2)
+
+        // 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()
+
+        // 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()
+        assertThat(trackDB).hasSize(1)
+        assertThat(trackDB[0].last_chapter_read).isEqualTo(5)
+        track.last_chapter_read = 7
+
+        // Create track for different manga to test track not in database
+        val track2 = getSingleTrack(manga2)
+        track2.last_chapter_read = 10
+
+        // 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)
+
+        // Assert if restore works.
+        trackDB = backupManager.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)
+
+        // Assert if restore works.
+        trackDB = backupManager.databaseHelper.getTracks(manga).executeAsBlocking()
+        assertThat(trackDB).hasSize(1)
+        assertThat(trackDB[0].last_chapter_read).isEqualTo(7)
+
+        // Check parser and restore, track not in database
+        trackList = listOf(track2)
+
+        //Check parser
+        trackListJson = backupManager.parser.toJsonTree(trackList)
+        trackListRestore = backupManager.parser.fromJson<List<TrackImpl>>(trackListJson)
+        backupManager.restoreTrackForManga(manga2, trackListRestore)
+
+        // Assert if restore works.
+        trackDB = backupManager.databaseHelper.getTracks(manga2).executeAsBlocking()
+        assertThat(trackDB).hasSize(1)
+        assertThat(trackDB[0].last_chapter_read).isEqualTo(10)
     }
 
-    private fun createCategories(vararg names: String): List<Category> {
-        val cats = ArrayList<Category>()
-        for (name in names) {
-            cats.add(createCategory(name))
-        }
-        return cats
-    }
-
-    private fun createStringCategories(vararg names: String): List<String> {
-        val cats = ArrayList<String>()
-        for (name in names) {
-            cats.add(name)
-        }
-        return cats
+    fun clearJson() {
+        root = JsonObject()
+        information = JsonObject()
+        mangaEntries = JsonArray()
+        categoryEntries = JsonArray()
     }
 
-    private fun createManga(title: String): Manga {
-        val m = Manga.create(1)
-        m.title = title
-        m.author = ""
-        m.artist = ""
-        m.thumbnail_url = ""
-        m.genre = "a list of genres"
-        m.description = "long description"
-        m.url = "url to manga"
-        m.favorite = true
-        return m
+    fun initializeJsonTest(version: Int) {
+        clearJson()
+        backupManager.setVersion(version)
     }
 
-    private fun createMangas(vararg titles: String): List<Manga> {
-        val mangas = ArrayList<Manga>()
-        for (title in titles) {
-            mangas.add(createManga(title))
-        }
-        return mangas
+    fun addSingleCategory(name: String): Category {
+        val category = Category.create(name)
+        val catJson = backupManager.parser.toJsonTree(category)
+        categoryEntries.add(catJson)
+        return category
     }
 
-    private fun createChapter(manga: Manga, url: String): Chapter {
-        val c = Chapter.create()
-        c.url = url
-        c.name = url
-        c.manga_id = manga.id
-        return c
+    fun clearDatabase(){
+        db.deleteMangas().executeAsBlocking()
+        db.deleteHistory().executeAsBlocking()
     }
 
-    private fun createChapters(manga: Manga, vararg urls: String): List<Chapter> {
-        val chapters = ArrayList<Chapter>()
-        for (url in urls) {
-            chapters.add(createChapter(manga, url))
-        }
-        return chapters
+    fun getSingleHistory(chapter: Chapter): DHistory {
+        return DHistory(chapter.url, 1000)
     }
 
-    private fun createTrack(manga: Manga, syncId: Int): Track {
-        val m = Track.create(syncId)
-        m.manga_id = manga.id!!
-        m.title = "title"
-        return m
+    private fun getSingleTrack(manga: Manga): TrackImpl {
+        val track = TrackImpl()
+        track.title = manga.title
+        track.manga_id = manga.id!!
+        track.remote_id = 1
+        track.sync_id = 1
+        return track
     }
 
-    private fun createTrack(manga: Manga, vararg syncIds: Int): List<Track> {
-        val ms = ArrayList<Track>()
-        for (title in syncIds) {
-            ms.add(createTrack(manga, title))
-        }
-        return ms
+    private fun getSingleManga(title: String): MangaImpl {
+        val manga = MangaImpl()
+        manga.source = 1
+        manga.title = title
+        manga.url = "/manga/$title"
+        manga.favorite = true
+        return manga
     }
 
-    private fun toJson(element: Any): JsonElement {
-        return gson.toJsonTree(element)
+    private fun getSingleChapter(name: String): ChapterImpl {
+        val chapter = ChapterImpl()
+        chapter.name = name
+        chapter.url = "/read-online/$name-page-1.html"
+        return chapter
     }
-
 }