Browse Source

Merge pull request #212 from inorichi/backup

Support backups
inorichi 9 years ago
parent
commit
a809b05808

+ 381 - 0
app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupManager.kt

@@ -0,0 +1,381 @@
+package eu.kanade.tachiyomi.data.backup
+
+import com.google.gson.*
+import com.google.gson.reflect.TypeToken
+import com.google.gson.stream.JsonReader
+import eu.kanade.tachiyomi.data.backup.serializer.IdExclusion
+import eu.kanade.tachiyomi.data.backup.serializer.IntegerSerializer
+import eu.kanade.tachiyomi.data.database.DatabaseHelper
+import eu.kanade.tachiyomi.data.database.models.*
+import java.io.*
+import java.lang.reflect.Type
+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 MANGA_SYNC = "sync"
+    private val CATEGORIES = "categories"
+
+    @Suppress("PLATFORM_CLASS_MAPPED_TO_KOTLIN")
+    private val gson = GsonBuilder()
+            .registerTypeAdapter(Integer::class.java, IntegerSerializer())
+            .setExclusionStrategies(IdExclusion())
+            .create()
+
+    /**
+     * 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.
+     */
+    @Throws(IOException::class)
+    fun backupToFile(file: File) {
+        val root = backupToJson()
+
+        FileWriter(file).use {
+            gson.toJson(root, it)
+        }
+    }
+
+    /**
+     * Creates a JSON object containing the backup of the app's data.
+     *
+     * @return the backup as a JSON object.
+     */
+    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))
+        }
+
+        // Backup categories
+        val categoryEntries = JsonArray()
+        root.add(CATEGORIES, categoryEntries)
+        for (category in db.getCategories().executeAsBlocking()) {
+            categoryEntries.add(backupCategory(category))
+        }
+
+        return root
+    }
+
+    /**
+     * Backups a manga and its related data (chapters, categories this manga is in, sync...).
+     *
+     * @param manga the manga to backup.
+     * @return a JSON object containing all the data of the manga.
+     */
+    private fun backupManga(manga: Manga): JsonObject {
+        // Entry for this manga
+        val entry = JsonObject()
+
+        // Backup manga fields
+        entry.add(MANGA, gson.toJsonTree(manga))
+
+        // Backup all the chapters
+        val chapters = db.getChapters(manga).executeAsBlocking()
+        if (!chapters.isEmpty()) {
+            entry.add(CHAPTERS, gson.toJsonTree(chapters))
+        }
+
+        // Backup manga sync
+        val mangaSync = db.getMangasSync(manga).executeAsBlocking()
+        if (!mangaSync.isEmpty()) {
+            entry.add(MANGA_SYNC, gson.toJsonTree(mangaSync))
+        }
+
+        // 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)
+            }
+            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)
+        }
+    }
+
+    /**
+     * Restores a backup from an input stream.
+     *
+     * @param stream the stream containing the backup.
+     * @throws IOException if there's any IO error.
+     */
+    @Throws(IOException::class)
+    fun restoreFromStream(stream: InputStream) {
+        JsonReader(InputStreamReader(stream)).use {
+            val root = JsonParser().parse(it).asJsonObject
+            restoreFromJson(root)
+        }
+    }
+
+    /**
+     * Restores a backup from a JSON object. Everything executes in a single transaction so that
+     * nothing is modified if there's an error.
+     *
+     * @param root the root of the JSON.
+     */
+    fun restoreFromJson(root: JsonObject) {
+        db.inTransaction {
+            // Restore categories
+            root.get(CATEGORIES)?.let {
+                restoreCategories(it.asJsonArray)
+            }
+
+            // Restore mangas
+            root.get(MANGAS)?.let {
+                restoreMangas(it.asJsonArray)
+            }
+        }
+    }
+
+    /**
+     * Restores the categories.
+     *
+     * @param jsonCategories the categories of the json.
+     */
+    private fun restoreCategories(jsonCategories: JsonArray) {
+        // Get categories from file and from db
+        val dbCategories = db.getCategories().executeAsBlocking()
+        val backupCategories = getArrayOrEmpty<Category>(jsonCategories,
+                object : TypeToken<List<Category>>() {}.type)
+
+        // Iterate over them
+        for (category in backupCategories) {
+            // Used to know if the category is already in the db
+            var found = false
+            for (dbCategory in dbCategories) {
+                // If the category is already in the db, assign the id to the file's category
+                // and do nothing
+                if (category.nameLower == dbCategory.nameLower) {
+                    category.id = dbCategory.id
+                    found = true
+                    break
+                }
+            }
+            // If the category isn't in the db, remove the id and insert a new category
+            // Store the inserted id in the category
+            if (!found) {
+                // Let the db assign the id
+                category.id = null
+                val result = db.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) {
+        val chapterToken = object : TypeToken<List<Chapter>>() {}.type
+        val mangaSyncToken = object : TypeToken<List<MangaSync>>() {}.type
+        val categoriesNamesToken = object : TypeToken<List<String>>() {}.type
+
+        for (backupManga in jsonMangas) {
+            // Map every entry to objects
+            val element = backupManga.asJsonObject
+            val manga = gson.fromJson(element.get(MANGA), Manga::class.java)
+            val chapters = getArrayOrEmpty<Chapter>(element.get(CHAPTERS), chapterToken)
+            val sync = getArrayOrEmpty<MangaSync>(element.get(MANGA_SYNC), mangaSyncToken)
+            val categories = getArrayOrEmpty<String>(element.get(CATEGORIES), categoriesNamesToken)
+
+            // Restore everything related to this manga
+            restoreManga(manga)
+            restoreChaptersForManga(manga, chapters)
+            restoreSyncForManga(manga, sync)
+            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()
+        val mangaCategoriesToUpdate = ArrayList<MangaCategory>()
+        for (backupCategoryStr in categories) {
+            for (dbCategory in dbCategories) {
+                if (backupCategoryStr.toLowerCase() == dbCategory.nameLower) {
+                    mangaCategoriesToUpdate.add(MangaCategory.create(manga, dbCategory))
+                    break
+                }
+            }
+        }
+
+        // Update database
+        if (!mangaCategoriesToUpdate.isEmpty()) {
+            val mangaAsList = ArrayList<Manga>()
+            mangaAsList.add(manga)
+            db.deleteOldMangasCategories(mangaAsList).executeAsBlocking()
+            db.insertMangasCategories(mangaCategoriesToUpdate).executeAsBlocking()
+        }
+    }
+
+    /**
+     * Restores the sync of a manga.
+     *
+     * @param manga the manga whose sync have to be restored.
+     * @param sync the sync to restore.
+     */
+    private fun restoreSyncForManga(manga: Manga, sync: List<MangaSync>) {
+        // Fix foreign keys with the current manga id
+        for (mangaSync in sync) {
+            mangaSync.manga_id = manga.id
+        }
+
+        val dbSyncs = db.getMangasSync(manga).executeAsBlocking()
+        val syncToUpdate = ArrayList<MangaSync>()
+        for (backupSync in sync) {
+            // Try to find existing chapter in db
+            val pos = dbSyncs.indexOf(backupSync)
+            if (pos != -1) {
+                // The sync is already in the db, only update its fields
+                val dbSync = dbSyncs[pos]
+                // Mark the max chapter as read and nothing else
+                dbSync.last_chapter_read = Math.max(backupSync.last_chapter_read, dbSync.last_chapter_read)
+                syncToUpdate.add(dbSync)
+            } else {
+                // Insert new sync. Let the db assign the id
+                backupSync.id = null
+                syncToUpdate.add(backupSync)
+            }
+        }
+
+        // Update database
+        if (!syncToUpdate.isEmpty()) {
+            db.insertMangasSync(syncToUpdate).executeAsBlocking()
+        }
+    }
+
+    /**
+     * Returns a list of items from a json element, or an empty list if the element is null.
+     *
+     * @param element the json to be mapped to a list of items.
+     * @param type the gson mapping to restore the list.
+     * @return a list of items.
+     */
+    private fun <T> getArrayOrEmpty(element: JsonElement?, type: Type): List<T> {
+        return gson.fromJson<List<T>>(element, type) ?: ArrayList<T>()
+    }
+
+}

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

@@ -0,0 +1,27 @@
+package eu.kanade.tachiyomi.data.backup.serializer
+
+import com.google.gson.ExclusionStrategy
+import com.google.gson.FieldAttributes
+import eu.kanade.tachiyomi.data.database.models.Category
+import eu.kanade.tachiyomi.data.database.models.Chapter
+import eu.kanade.tachiyomi.data.database.models.Manga
+import eu.kanade.tachiyomi.data.database.models.MangaSync
+
+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) {
+        Manga::class.java -> mangaExclusions.contains(f.name)
+        Chapter::class.java -> chapterExclusions.contains(f.name)
+        MangaSync::class.java -> syncExclusions.contains(f.name)
+        Category::class.java -> categoryExclusions.contains(f.name)
+        else -> false
+    }
+
+    override fun shouldSkipClass(clazz: Class<*>) = false
+
+}

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

@@ -0,0 +1,17 @@
+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
+    }
+}

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

@@ -256,6 +256,8 @@ open class DatabaseHelper(context: Context) {
 
     fun insertMangaSync(manga: MangaSync) = db.put().`object`(manga).prepare()
 
+    fun insertMangasSync(mangas: List<MangaSync>) = db.put().objects(mangas).prepare()
+
     fun deleteMangaSync(manga: MangaSync) = db.delete().`object`(manga).prepare()
 
     // Categories related queries
@@ -268,6 +270,13 @@ open class DatabaseHelper(context: Context) {
                     .build())
             .prepare()
 
+    fun getCategoriesForManga(manga: Manga) = db.get()
+            .listOfObjects(Category::class.java)
+            .withQuery(RawQuery.builder()
+                    .query(getCategoriesForMangaQuery(manga))
+                    .build())
+            .prepare()
+
     fun insertCategory(category: Category) = db.put().`object`(category).prepare()
 
     fun insertCategories(categories: List<Category>) = db.put().objects(categories).prepare()

+ 14 - 0
app/src/main/java/eu/kanade/tachiyomi/data/database/RawQueries.kt

@@ -1,6 +1,8 @@
 package eu.kanade.tachiyomi.data.database
 
 import java.util.*
+import eu.kanade.tachiyomi.data.database.models.Manga as MangaModel
+import eu.kanade.tachiyomi.data.database.tables.CategoryTable as Category
 import eu.kanade.tachiyomi.data.database.tables.ChapterTable as Chapter
 import eu.kanade.tachiyomi.data.database.tables.MangaCategoryTable as MangaCategory
 import eu.kanade.tachiyomi.data.database.tables.MangaTable as Manga
@@ -38,3 +40,15 @@ fun getRecentsQuery(date: Date): String =
     "ON ${Manga.TABLE}.${Manga.COLUMN_ID} = ${Chapter.TABLE}.${Chapter.COLUMN_MANGA_ID} " +
     "WHERE ${Manga.COLUMN_FAVORITE} = 1 AND ${Chapter.COLUMN_DATE_UPLOAD} > ${date.time} " +
     "ORDER BY ${Chapter.COLUMN_DATE_UPLOAD} DESC"
+
+
+/**
+ * Query to get the categorias for a manga.
+ *
+ * @param manga the manga.
+ */
+fun getCategoriesForMangaQuery(manga: MangaModel) =
+    "SELECT ${Category.TABLE}.* FROM ${Category.TABLE} " +
+    "JOIN ${MangaCategory.TABLE} ON ${Category.TABLE}.${Category.COLUMN_ID} = " +
+    "${MangaCategory.TABLE}.${MangaCategory.COLUMN_CATEGORY_ID} " +
+    "WHERE ${MangaCategory.COLUMN_MANGA_ID} = ${manga.id}"

+ 19 - 0
app/src/main/java/eu/kanade/tachiyomi/data/database/models/Category.java

@@ -35,4 +35,23 @@ public class Category implements Serializable {
         c.id = 0;
         return c;
     }
+
+    public String getNameLower() {
+        return name.toLowerCase();
+    }
+
+    @Override
+    public boolean equals(Object o) {
+        if (this == o) return true;
+        if (o == null || getClass() != o.getClass()) return false;
+
+        Category category = (Category) o;
+
+        return name.equals(category.name);
+    }
+
+    @Override
+    public int hashCode() {
+        return name.hashCode();
+    }
 }

+ 2 - 2
app/src/main/java/eu/kanade/tachiyomi/data/database/models/Manga.java

@@ -59,9 +59,9 @@ public class Manga implements Serializable {
     @StorIOSQLiteColumn(name = MangaTable.COLUMN_CHAPTER_FLAGS)
     public int chapter_flags;
 
-    public int unread;
+    public transient int unread;
 
-    public int category;
+    public transient int category;
 
     public static final int UNKNOWN = 0;
     public static final int ONGOING = 1;

+ 23 - 0
app/src/main/java/eu/kanade/tachiyomi/data/database/models/MangaSync.java

@@ -40,6 +40,10 @@ public class MangaSync implements Serializable {
 
     public boolean update;
 
+    public static MangaSync create() {
+        return new MangaSync();
+    }
+
     public static MangaSync create(MangaSyncService service) {
         MangaSync mangasync = new MangaSync();
         mangasync.sync_id = service.getId();
@@ -52,4 +56,23 @@ public class MangaSync implements Serializable {
         status = other.status;
     }
 
+    @Override
+    public boolean equals(Object o) {
+        if (this == o) return true;
+        if (o == null || getClass() != o.getClass()) return false;
+
+        MangaSync mangaSync = (MangaSync) o;
+
+        if (manga_id != mangaSync.manga_id) return false;
+        if (sync_id != mangaSync.sync_id) return false;
+        return remote_id == mangaSync.remote_id;
+    }
+
+    @Override
+    public int hashCode() {
+        int result = (int) (manga_id ^ (manga_id >>> 32));
+        result = 31 * result + sync_id;
+        result = 31 * result + remote_id;
+        return result;
+    }
 }

+ 2 - 0
app/src/main/java/eu/kanade/tachiyomi/injection/component/AppComponent.kt

@@ -10,6 +10,7 @@ import eu.kanade.tachiyomi.data.source.base.Source
 import eu.kanade.tachiyomi.data.updater.UpdateDownloader
 import eu.kanade.tachiyomi.injection.module.AppModule
 import eu.kanade.tachiyomi.injection.module.DataModule
+import eu.kanade.tachiyomi.ui.backup.BackupPresenter
 import eu.kanade.tachiyomi.ui.catalogue.CataloguePresenter
 import eu.kanade.tachiyomi.ui.category.CategoryPresenter
 import eu.kanade.tachiyomi.ui.download.DownloadPresenter
@@ -38,6 +39,7 @@ interface AppComponent {
     fun inject(myAnimeListPresenter: MyAnimeListPresenter)
     fun inject(categoryPresenter: CategoryPresenter)
     fun inject(recentChaptersPresenter: RecentChaptersPresenter)
+    fun inject(backupPresenter: BackupPresenter)
 
     fun inject(mangaActivity: MangaActivity)
     fun inject(settingsActivity: SettingsActivity)

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

@@ -0,0 +1,133 @@
+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.fragment.BaseRxFragment
+import eu.kanade.tachiyomi.util.toast
+import kotlinx.android.synthetic.main.fragment_backup.*
+import nucleus.factory.RequiresPresenter
+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
+
+    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?) {
+        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/octet-stream"
+            val chooser = Intent.createChooser(intent, getString(R.string.file_select_cover))
+            startActivityForResult(chooser, REQUEST_BACKUP_OPEN)
+        }
+    }
+
+    /**
+     * Called from the presenter when the backup is completed.
+     */
+    fun onBackupCompleted() {
+        dismissBackupDialog()
+        val intent = Intent(Intent.ACTION_SEND)
+        intent.type = "text/plain"
+        intent.putExtra(Intent.EXTRA_STREAM, Uri.parse("file://" + presenter.backupFile))
+        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()
+
+            val stream = context.contentResolver.openInputStream(data.data)
+            presenter.restoreBackup(stream)
+        }
+    }
+
+    /**
+     * 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()
+        }
+    }
+}

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

@@ -0,0 +1,109 @@
+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 rx.Observable
+import rx.android.schedulers.AndroidSchedulers
+import rx.schedulers.Schedulers
+import java.io.File
+import java.io.InputStream
+import javax.inject.Inject
+
+/**
+ * Presenter of [BackupFragment].
+ */
+class BackupPresenter : BasePresenter<BackupFragment>() {
+
+    /**
+     * Database.
+     */
+    @Inject lateinit var db: DatabaseHelper
+
+    /**
+     * Backup manager.
+     */
+    private lateinit var backupManager: BackupManager
+
+    /**
+     * File where the backup is saved.
+     */
+    var backupFile: File? = null
+        private set
+
+    /**
+     * Stream to restore a backup.
+     */
+    private var restoreStream: InputStream? = null
+
+    /**
+     * Id of the restartable that creates a backup.
+     */
+    private val CREATE_BACKUP = 1
+
+    /**
+     * Id of the restartable that restores a backup.
+     */
+    private val RESTORE_BACKUP = 2
+
+    override fun onCreate(savedState: Bundle?) {
+        super.onCreate(savedState)
+        backupManager = BackupManager(db)
+
+        startableFirst(CREATE_BACKUP,
+                { getBackupObservable() },
+                { view, next -> view.onBackupCompleted() },
+                { view, error -> view.onBackupError(error) })
+
+        startableFirst(RESTORE_BACKUP,
+                { getRestoreObservable() },
+                { view, next -> view.onRestoreCompleted() },
+                { view, error -> view.onRestoreError(error) })
+    }
+
+    /**
+     * Creates a backup and saves it to a file.
+     *
+     * @param file the path where the file will be saved.
+     */
+    fun createBackup(file: File) {
+        if (isUnsubscribed(CREATE_BACKUP)) {
+            backupFile = file
+            start(CREATE_BACKUP)
+        }
+    }
+
+    /**
+     * Restores a backup from a stream.
+     *
+     * @param stream the input stream of the backup file.
+     */
+    fun restoreBackup(stream: InputStream) {
+        if (isUnsubscribed(RESTORE_BACKUP)) {
+            restoreStream = stream
+            start(RESTORE_BACKUP)
+        }
+    }
+
+    /**
+     * Returns the observable to save a backup.
+     */
+    private fun getBackupObservable(): Observable<Boolean> {
+        return Observable.fromCallable {
+            backupManager.backupToFile(backupFile!!)
+            true
+        }.subscribeOn(Schedulers.io()).observeOn(AndroidSchedulers.mainThread())
+    }
+
+    /**
+     * Returns the observable to restore a backup.
+     */
+    private fun getRestoreObservable(): Observable<Boolean> {
+        return Observable.fromCallable {
+            backupManager.restoreFromStream(restoreStream!!)
+            true
+        }.subscribeOn(Schedulers.io()).observeOn(AndroidSchedulers.mainThread())
+    }
+
+}

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

@@ -9,6 +9,7 @@ import android.support.v4.widget.DrawerLayout
 import android.view.MenuItem
 import android.view.View
 import eu.kanade.tachiyomi.R
+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.DownloadFragment
@@ -80,6 +81,10 @@ class MainActivity : BaseActivity() {
                     item.isChecked = false
                     startActivity(Intent(this, SettingsActivity::class.java))
                 }
+                R.id.nav_drawer_backup -> {
+                    setFragment(BackupFragment.newInstance())
+                    item.isChecked = true
+                }
             }
             drawer.closeDrawer(GravityCompat.START)
             true

+ 9 - 0
app/src/main/res/drawable/ic_backup_black_24dp.xml

@@ -0,0 +1,9 @@
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+        android:width="24dp"
+        android:height="24dp"
+        android:viewportWidth="24.0"
+        android:viewportHeight="24.0">
+    <path
+        android:fillColor="#FF000000"
+        android:pathData="M19.35,10.04C18.67,6.59 15.64,4 12,4 9.11,4 6.6,5.64 5.35,8.04 2.34,8.36 0,10.91 0,14c0,3.31 2.69,6 6,6h13c2.76,0 5,-2.24 5,-5 0,-2.64 -2.05,-4.78 -4.65,-4.96zM14,13v4h-4v-4H7l5,-5 5,5h-3z"/>
+</vector>

+ 21 - 0
app/src/main/res/layout/fragment_backup.xml

@@ -0,0 +1,21 @@
+<?xml version="1.0" encoding="utf-8"?>
+<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
+              android:orientation="vertical"
+              android:layout_width="match_parent"
+              android:layout_height="match_parent"
+              android:gravity="center">
+
+    <Button
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        android:id="@+id/backup_button"
+        android:layout_marginBottom="16dp"
+        android:text="@string/backup"/>
+
+    <Button
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        android:id="@+id/restore_button"
+        android:text="@string/restore"/>
+
+</LinearLayout>

+ 9 - 5
app/src/main/res/menu/menu_navigation.xml

@@ -21,10 +21,14 @@
             android:title="@string/label_download_queue" />
     </group>
     <group android:id="@+id/group_settings"
-           android:checkableBehavior="none">
-    <item
-        android:id="@+id/nav_drawer_settings"
-        android:icon="@drawable/ic_settings_black_24dp"
-        android:title="@string/label_settings" />
+           android:checkableBehavior="single">
+        <item
+            android:id="@+id/nav_drawer_settings"
+            android:icon="@drawable/ic_settings_black_24dp"
+            android:title="@string/label_settings" />
+        <item
+            android:id="@+id/nav_drawer_backup"
+            android:icon="@drawable/ic_backup_black_24dp"
+            android:title="@string/label_backup" />
     </group>
 </menu>

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

@@ -11,6 +11,7 @@
     <string name="label_catalogues">Catalogues</string>
     <string name="label_categories">Categories</string>
     <string name="label_selected">Selected: %1$d</string>
+    <string name="label_backup">Backup</string>
 
     <!-- Actions -->
     <string name="action_settings">Settings</string>
@@ -243,6 +244,13 @@
     <string name="decode_image_error">Image could not be loaded.\nTry changing the image decoder or with one of the options below</string>
     <string name="confirm_update_manga_sync">Update last chapter read in enabled services to %1$d?</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>
+
     <!-- Downloads activity and service -->
     <string name="download_queue_error">An error occurred while downloading chapters. You can try again in the downloads section</string>
 

+ 573 - 0
app/src/test/java/eu/kanade/tachiyomi/BackupTest.java

@@ -0,0 +1,573 @@
+package eu.kanade.tachiyomi;
+
+import android.app.Application;
+import android.os.Build;
+
+import com.google.gson.Gson;
+import com.google.gson.JsonElement;
+import com.google.gson.JsonObject;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.RuntimeEnvironment;
+import org.robolectric.annotation.Config;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import eu.kanade.tachiyomi.data.backup.BackupManager;
+import eu.kanade.tachiyomi.data.database.DatabaseHelper;
+import eu.kanade.tachiyomi.data.database.models.Category;
+import eu.kanade.tachiyomi.data.database.models.Chapter;
+import eu.kanade.tachiyomi.data.database.models.Manga;
+import eu.kanade.tachiyomi.data.database.models.MangaCategory;
+import eu.kanade.tachiyomi.data.database.models.MangaSync;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+@Config(constants = BuildConfig.class, sdk = Build.VERSION_CODES.LOLLIPOP)
+@RunWith(CustomRobolectricGradleTestRunner.class)
+public class BackupTest {
+
+    DatabaseHelper db;
+    BackupManager backupManager;
+    Gson gson;
+    JsonObject root;
+
+    @Before
+    public void setup() {
+        Application app = RuntimeEnvironment.application;
+        db = new DatabaseHelper(app);
+        backupManager = new BackupManager(db);
+        gson = new Gson();
+        root = new JsonObject();
+    }
+
+    @Test
+    public void testRestoreCategory() {
+        String catName = "cat";
+        root = createRootJson(null, toJson(createCategories(catName)));
+        backupManager.restoreFromJson(root);
+
+        List<Category> dbCats = db.getCategories().executeAsBlocking();
+        assertThat(dbCats).hasSize(1);
+        assertThat(dbCats.get(0).name).isEqualTo(catName);
+    }
+
+    @Test
+    public void testRestoreEmptyCategory() {
+        root = createRootJson(null, toJson(new ArrayList<>()));
+        backupManager.restoreFromJson(root);
+        List<Category> dbCats = db.getCategories().executeAsBlocking();
+        assertThat(dbCats).isEmpty();
+    }
+
+    @Test
+    public void testRestoreExistingCategory() {
+        String catName = "cat";
+        db.insertCategory(createCategory(catName)).executeAsBlocking();
+
+        root = createRootJson(null, toJson(createCategories(catName)));
+        backupManager.restoreFromJson(root);
+
+        List<Category> dbCats = db.getCategories().executeAsBlocking();
+        assertThat(dbCats).hasSize(1);
+        assertThat(dbCats.get(0).name).isEqualTo(catName);
+    }
+
+    @Test
+    public void testRestoreCategories() {
+        root = createRootJson(null, toJson(createCategories("cat", "cat2", "cat3")));
+        backupManager.restoreFromJson(root);
+
+        List<Category> dbCats = db.getCategories().executeAsBlocking();
+        assertThat(dbCats).hasSize(3);
+    }
+
+    @Test
+    public void testRestoreExistingCategories() {
+        db.insertCategories(createCategories("cat", "cat2")).executeAsBlocking();
+
+        root = createRootJson(null, toJson(createCategories("cat", "cat2", "cat3")));
+        backupManager.restoreFromJson(root);
+
+        List<Category> dbCats = db.getCategories().executeAsBlocking();
+        assertThat(dbCats).hasSize(3);
+    }
+
+    @Test
+    public void testRestoreExistingCategoriesAlt() {
+        db.insertCategories(createCategories("cat", "cat2", "cat3")).executeAsBlocking();
+
+        root = createRootJson(null, toJson(createCategories("cat", "cat2")));
+        backupManager.restoreFromJson(root);
+
+        List<Category> dbCats = db.getCategories().executeAsBlocking();
+        assertThat(dbCats).hasSize(3);
+    }
+
+    @Test
+    public void testRestoreManga() {
+        String mangaName = "title";
+        List<Manga> mangas = createMangas(mangaName);
+        List<JsonElement> elements = new ArrayList<>();
+        for (Manga manga : mangas) {
+            JsonObject entry = new JsonObject();
+            entry.add("manga", toJson(manga));
+            elements.add(entry);
+        }
+        root = createRootJson(toJson(elements), null);
+        backupManager.restoreFromJson(root);
+
+        List<Manga> dbMangas = db.getMangas().executeAsBlocking();
+        assertThat(dbMangas).hasSize(1);
+        assertThat(dbMangas.get(0).title).isEqualTo(mangaName);
+    }
+
+    @Test
+    public void testRestoreExistingManga() {
+        String mangaName = "title";
+        Manga manga = createManga(mangaName);
+
+        db.insertManga(manga).executeAsBlocking();
+
+        List<JsonElement> elements = new ArrayList<>();
+        JsonObject entry = new JsonObject();
+        entry.add("manga", toJson(manga));
+        elements.add(entry);
+
+        root = createRootJson(toJson(elements), null);
+        backupManager.restoreFromJson(root);
+
+        List<Manga> dbMangas = db.getMangas().executeAsBlocking();
+        assertThat(dbMangas).hasSize(1);
+    }
+
+    @Test
+    public void testRestoreExistingMangaWithUpdatedFields() {
+        // Store a manga in db
+        String mangaName = "title";
+        String updatedThumbnailUrl = "updated thumbnail url";
+        Manga 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;
+        JsonObject entry = new JsonObject();
+        entry.add("manga", toJson(manga));
+
+        // Append the entry to the backup list
+        List<JsonElement> elements = new ArrayList<>();
+        elements.add(entry);
+
+        // Restore from json
+        root = createRootJson(toJson(elements), null);
+        backupManager.restoreFromJson(root);
+
+        List<Manga> dbMangas = db.getMangas().executeAsBlocking();
+        assertThat(dbMangas).hasSize(1);
+        assertThat(dbMangas.get(0).thumbnail_url).isEqualTo(updatedThumbnailUrl);
+        assertThat(dbMangas.get(0).chapter_flags).isEqualTo(512);
+    }
+
+    @Test
+    public void testRestoreChaptersForManga() {
+        // Create a manga and 3 chapters
+        Manga manga = createManga("title");
+        manga.id = 1L;
+        List<Chapter> chapters = createChapters(manga, "1", "2", "3");
+
+        // Add an entry for the manga
+        JsonObject entry = new JsonObject();
+        entry.add("manga", toJson(manga));
+        entry.add("chapters", toJson(chapters));
+
+        // Append the entry to the backup list
+        List<JsonElement> mangas = new ArrayList<>();
+        mangas.add(entry);
+
+        // Restore from json
+        root = createRootJson(toJson(mangas), null);
+        backupManager.restoreFromJson(root);
+
+        Manga dbManga = db.getManga(1).executeAsBlocking();
+        assertThat(dbManga).isNotNull();
+
+        List<Chapter> dbChapters = db.getChapters(dbManga).executeAsBlocking();
+        assertThat(dbChapters).hasSize(3);
+    }
+
+    @Test
+    public void testRestoreChaptersForExistingManga() {
+        long mangaId = 3;
+        // Create a manga and 3 chapters
+        Manga manga = createManga("title");
+        manga.id = mangaId;
+        List<Chapter> chapters = createChapters(manga, "1", "2", "3");
+        db.insertManga(manga).executeAsBlocking();
+
+        // Add an entry for the manga
+        JsonObject entry = new JsonObject();
+        entry.add("manga", toJson(manga));
+        entry.add("chapters", toJson(chapters));
+
+        // Append the entry to the backup list
+        List<JsonElement> mangas = new ArrayList<>();
+        mangas.add(entry);
+
+        // Restore from json
+        root = createRootJson(toJson(mangas), null);
+        backupManager.restoreFromJson(root);
+
+        Manga dbManga = db.getManga(mangaId).executeAsBlocking();
+        assertThat(dbManga).isNotNull();
+
+        List<Chapter> dbChapters = db.getChapters(dbManga).executeAsBlocking();
+        assertThat(dbChapters).hasSize(3);
+    }
+
+    @Test
+    public void testRestoreExistingChaptersForExistingManga() {
+        long mangaId = 5;
+        // Store a manga and 3 chapters
+        Manga manga = createManga("title");
+        manga.id = mangaId;
+        List<Chapter> 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
+        JsonObject entry = new JsonObject();
+        entry.add("manga", toJson(manga));
+        entry.add("chapters", toJson(chapters));
+
+        // Append the entry to the backup list
+        List<JsonElement> mangas = new ArrayList<>();
+        mangas.add(entry);
+
+        // Restore from json
+        root = createRootJson(toJson(mangas), null);
+        backupManager.restoreFromJson(root);
+
+        Manga dbManga = db.getManga(mangaId).executeAsBlocking();
+        assertThat(dbManga).isNotNull();
+
+        List<Chapter> dbChapters = db.getChapters(dbManga).executeAsBlocking();
+        assertThat(dbChapters).hasSize(4);
+    }
+
+    @Test
+    public void testRestoreCategoriesForManga() {
+        // Create a manga
+        Manga manga = createManga("title");
+
+        // Create categories
+        List<Category> categories = createCategories("cat1", "cat2", "cat3");
+
+        // Add an entry for the manga
+        JsonObject entry = new JsonObject();
+        entry.add("manga", toJson(manga));
+        entry.add("categories", toJson(createStringCategories("cat1")));
+
+        // Append the entry to the backup list
+        List<JsonElement> mangas = new ArrayList<>();
+        mangas.add(entry);
+
+        // Restore from json
+        root = createRootJson(toJson(mangas), toJson(categories));
+        backupManager.restoreFromJson(root);
+
+        Manga dbManga = db.getManga(1).executeAsBlocking();
+        assertThat(dbManga).isNotNull();
+
+        assertThat(db.getCategoriesForManga(dbManga).executeAsBlocking())
+                .hasSize(1)
+                .contains(Category.create("cat1"))
+                .doesNotContain(Category.create("cat2"));
+    }
+
+    @Test
+    public void testRestoreCategoriesForExistingManga() {
+        // Store a manga
+        Manga manga = createManga("title");
+        db.insertManga(manga).executeAsBlocking();
+
+        // Create categories
+        List<Category> categories = createCategories("cat1", "cat2", "cat3");
+
+        // Add an entry for the manga
+        JsonObject entry = new JsonObject();
+        entry.add("manga", toJson(manga));
+        entry.add("categories", toJson(createStringCategories("cat1")));
+
+        // Append the entry to the backup list
+        List<JsonElement> mangas = new ArrayList<>();
+        mangas.add(entry);
+
+        // Restore from json
+        root = createRootJson(toJson(mangas), toJson(categories));
+        backupManager.restoreFromJson(root);
+
+        Manga dbManga = db.getManga(1).executeAsBlocking();
+        assertThat(dbManga).isNotNull();
+
+        assertThat(db.getCategoriesForManga(dbManga).executeAsBlocking())
+                .hasSize(1)
+                .contains(Category.create("cat1"))
+                .doesNotContain(Category.create("cat2"));
+    }
+
+    @Test
+    public void testRestoreMultipleCategoriesForManga() {
+        // Create a manga
+        Manga manga = createManga("title");
+
+        // Create categories
+        List<Category> categories = createCategories("cat1", "cat2", "cat3");
+
+        // Add an entry for the manga
+        JsonObject entry = new JsonObject();
+        entry.add("manga", toJson(manga));
+        entry.add("categories", toJson(createStringCategories("cat1", "cat3")));
+
+        // Append the entry to the backup list
+        List<JsonElement> mangas = new ArrayList<>();
+        mangas.add(entry);
+
+        // Restore from json
+        root = createRootJson(toJson(mangas), toJson(categories));
+        backupManager.restoreFromJson(root);
+
+        Manga dbManga = db.getManga(1).executeAsBlocking();
+        assertThat(dbManga).isNotNull();
+
+        assertThat(db.getCategoriesForManga(dbManga).executeAsBlocking())
+                .hasSize(2)
+                .contains(Category.create("cat1"), Category.create("cat3"))
+                .doesNotContain(Category.create("cat2"));
+    }
+
+    @Test
+    public void testRestoreMultipleCategoriesForExistingMangaAndCategory() {
+        // Store a manga and a category
+        Manga manga = createManga("title");
+        manga.id = 1L;
+        db.insertManga(manga).executeAsBlocking();
+
+        Category cat = createCategory("cat1");
+        cat.id = 1;
+        db.insertCategory(cat).executeAsBlocking();
+        db.insertMangaCategory(MangaCategory.create(manga, cat)).executeAsBlocking();
+
+        // Create categories
+        List<Category> categories = createCategories("cat1", "cat2", "cat3");
+
+        // Add an entry for the manga
+        JsonObject entry = new JsonObject();
+        entry.add("manga", toJson(manga));
+        entry.add("categories", toJson(createStringCategories("cat1", "cat2")));
+
+        // Append the entry to the backup list
+        List<JsonElement> mangas = new ArrayList<>();
+        mangas.add(entry);
+
+        // Restore from json
+        root = createRootJson(toJson(mangas), toJson(categories));
+        backupManager.restoreFromJson(root);
+
+        Manga dbManga = db.getManga(1).executeAsBlocking();
+        assertThat(dbManga).isNotNull();
+
+        assertThat(db.getCategoriesForManga(dbManga).executeAsBlocking())
+                .hasSize(2)
+                .contains(Category.create("cat1"), Category.create("cat2"))
+                .doesNotContain(Category.create("cat3"));
+    }
+
+    @Test
+    public void testRestoreSyncForManga() {
+        // Create a manga and mangaSync
+        Manga manga = createManga("title");
+        manga.id = 1L;
+
+        List<MangaSync> mangaSync = createMangaSync(manga, 1, 2, 3);
+
+        // Add an entry for the manga
+        JsonObject entry = new JsonObject();
+        entry.add("manga", toJson(manga));
+        entry.add("sync", toJson(mangaSync));
+
+        // Append the entry to the backup list
+        List<JsonElement> mangas = new ArrayList<>();
+        mangas.add(entry);
+
+        // Restore from json
+        root = createRootJson(toJson(mangas), null);
+        backupManager.restoreFromJson(root);
+
+        Manga dbManga = db.getManga(1).executeAsBlocking();
+        assertThat(dbManga).isNotNull();
+
+        List<MangaSync> dbSync = db.getMangasSync(dbManga).executeAsBlocking();
+        assertThat(dbSync).hasSize(3);
+    }
+
+    @Test
+    public void testRestoreSyncForExistingManga() {
+        long mangaId = 3;
+        // Create a manga and 3 sync
+        Manga manga = createManga("title");
+        manga.id = mangaId;
+        List<MangaSync> mangaSync = createMangaSync(manga, 1, 2, 3);
+        db.insertManga(manga).executeAsBlocking();
+
+        // Add an entry for the manga
+        JsonObject entry = new JsonObject();
+        entry.add("manga", toJson(manga));
+        entry.add("sync", toJson(mangaSync));
+
+        // Append the entry to the backup list
+        List<JsonElement> mangas = new ArrayList<>();
+        mangas.add(entry);
+
+        // Restore from json
+        root = createRootJson(toJson(mangas), null);
+        backupManager.restoreFromJson(root);
+
+        Manga dbManga = db.getManga(mangaId).executeAsBlocking();
+        assertThat(dbManga).isNotNull();
+
+        List<MangaSync> dbSync = db.getMangasSync(dbManga).executeAsBlocking();
+        assertThat(dbSync).hasSize(3);
+    }
+
+    @Test
+    public void testRestoreExistingSyncForExistingManga() {
+        long mangaId = 5;
+        // Store a manga and 3 sync
+        Manga manga = createManga("title");
+        manga.id = mangaId;
+        List<MangaSync> mangaSync = createMangaSync(manga, 1, 2, 3);
+        db.insertManga(manga).executeAsBlocking();
+        db.insertMangasSync(mangaSync).executeAsBlocking();
+
+        // The backup contains a existing sync and a new one, so it should have 4 sync
+        mangaSync = createMangaSync(manga, 3, 4);
+
+        // Add an entry for the manga
+        JsonObject entry = new JsonObject();
+        entry.add("manga", toJson(manga));
+        entry.add("sync", toJson(mangaSync));
+
+        // Append the entry to the backup list
+        List<JsonElement> mangas = new ArrayList<>();
+        mangas.add(entry);
+
+        // Restore from json
+        root = createRootJson(toJson(mangas), null);
+        backupManager.restoreFromJson(root);
+
+        Manga dbManga = db.getManga(mangaId).executeAsBlocking();
+        assertThat(dbManga).isNotNull();
+
+        List<MangaSync> dbSync = db.getMangasSync(dbManga).executeAsBlocking();
+        assertThat(dbSync).hasSize(4);
+    }
+
+    private JsonObject createRootJson(JsonElement mangas, JsonElement categories) {
+        JsonObject root = new JsonObject();
+        if (mangas != null)
+            root.add("mangas", mangas);
+        if (categories != null)
+            root.add("categories", categories);
+        return root;
+    }
+
+    private Category createCategory(String name) {
+        Category c = new Category();
+        c.name = name;
+        return c;
+    }
+
+    private List<Category> createCategories(String... names) {
+        List<Category> cats = new ArrayList<>();
+        for (String name : names) {
+            cats.add(createCategory(name));
+        }
+        return cats;
+    }
+
+    private List<String> createStringCategories(String... names) {
+        List<String> cats = new ArrayList<>();
+        for (String name : names) {
+            cats.add(name);
+        }
+        return cats;
+    }
+
+    private Manga createManga(String title) {
+        Manga m = new Manga();
+        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;
+        m.source = 1;
+        return m;
+    }
+
+    private List<Manga> createMangas(String... titles) {
+        List<Manga> mangas = new ArrayList<>();
+        for (String title : titles) {
+            mangas.add(createManga(title));
+        }
+        return mangas;
+    }
+
+    private Chapter createChapter(Manga manga, String url) {
+        Chapter c = Chapter.create();
+        c.url = url;
+        c.name = url;
+        c.manga_id = manga.id;
+        return c;
+    }
+
+    private List<Chapter> createChapters(Manga manga, String... urls) {
+        List<Chapter> chapters = new ArrayList<>();
+        for (String url : urls) {
+            chapters.add(createChapter(manga, url));
+        }
+        return chapters;
+    }
+
+    private MangaSync createMangaSync(Manga manga, int syncId) {
+        MangaSync m = MangaSync.create();
+        m.manga_id = manga.id;
+        m.sync_id = syncId;
+        m.title = "title";
+        return m;
+    }
+
+    private List<MangaSync> createMangaSync(Manga manga, Integer... syncIds) {
+        List<MangaSync> ms = new ArrayList<>();
+        for (int title : syncIds) {
+            ms.add(createMangaSync(manga, title));
+        }
+        return ms;
+    }
+
+    private JsonElement toJson(Object element) {
+        return gson.toJsonTree(element);
+    }
+
+}