From da44dc3fb5b0db4a2783f3fb0f69a93d7ce8b229 Mon Sep 17 00:00:00 2001 From: inorichi Date: Sun, 24 Jan 2016 16:16:28 +0100 Subject: [PATCH] Support backups --- .../tachiyomi/data/backup/BackupManager.kt | 381 ++++++++++++ .../data/backup/serializer/IdExclusion.kt | 27 + .../backup/serializer/IntegerSerializer.kt | 17 + .../tachiyomi/data/database/DatabaseHelper.kt | 9 + .../tachiyomi/data/database/RawQueries.kt | 14 + .../data/database/models/Category.java | 19 + .../tachiyomi/data/database/models/Manga.java | 4 +- .../data/database/models/MangaSync.java | 23 + .../injection/component/AppComponent.kt | 2 + .../tachiyomi/ui/backup/BackupFragment.kt | 133 ++++ .../tachiyomi/ui/backup/BackupPresenter.kt | 109 ++++ .../kanade/tachiyomi/ui/main/MainActivity.kt | 5 + .../res/drawable/ic_backup_black_24dp.xml | 9 + app/src/main/res/layout/fragment_backup.xml | 21 + app/src/main/res/menu/menu_navigation.xml | 14 +- app/src/main/res/values/strings.xml | 8 + .../java/eu/kanade/tachiyomi/BackupTest.java | 573 ++++++++++++++++++ 17 files changed, 1361 insertions(+), 7 deletions(-) create mode 100644 app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupManager.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/data/backup/serializer/IdExclusion.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/data/backup/serializer/IntegerSerializer.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/backup/BackupFragment.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/backup/BackupPresenter.kt create mode 100644 app/src/main/res/drawable/ic_backup_black_24dp.xml create mode 100644 app/src/main/res/layout/fragment_backup.xml create mode 100644 app/src/test/java/eu/kanade/tachiyomi/BackupTest.java diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupManager.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupManager.kt new file mode 100644 index 000000000..160f4603a --- /dev/null +++ b/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() + 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(jsonCategories, + object : TypeToken>() {}.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>() {}.type + val mangaSyncToken = object : TypeToken>() {}.type + val categoriesNamesToken = object : TypeToken>() {}.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(element.get(CHAPTERS), chapterToken) + val sync = getArrayOrEmpty(element.get(MANGA_SYNC), mangaSyncToken) + val categories = getArrayOrEmpty(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) { + // 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() + 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) { + val dbCategories = db.getCategories().executeAsBlocking() + val mangaCategoriesToUpdate = ArrayList() + 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() + 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) { + // 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() + 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 getArrayOrEmpty(element: JsonElement?, type: Type): List { + return gson.fromJson>(element, type) ?: ArrayList() + } + +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/serializer/IdExclusion.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/serializer/IdExclusion.kt new file mode 100644 index 000000000..867cb81b3 --- /dev/null +++ b/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 + +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/serializer/IntegerSerializer.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/serializer/IntegerSerializer.kt new file mode 100644 index 000000000..4a58b5d4e --- /dev/null +++ b/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 { + + override fun serialize(value: Int?, type: Type, context: JsonSerializationContext): JsonElement? { + if (value != null && value !== 0) + return JsonPrimitive(value) + return null + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/database/DatabaseHelper.kt b/app/src/main/java/eu/kanade/tachiyomi/data/database/DatabaseHelper.kt index 6c2ad4a29..2172c6eb1 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/database/DatabaseHelper.kt +++ b/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) = 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) = db.put().objects(categories).prepare() diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/database/RawQueries.kt b/app/src/main/java/eu/kanade/tachiyomi/data/database/RawQueries.kt index 0e4342418..e61b20679 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/database/RawQueries.kt +++ b/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}" \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/database/models/Category.java b/app/src/main/java/eu/kanade/tachiyomi/data/database/models/Category.java index e4b142e3f..91eaa09f1 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/database/models/Category.java +++ b/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(); + } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/database/models/Manga.java b/app/src/main/java/eu/kanade/tachiyomi/data/database/models/Manga.java index d64f4b108..ad4e1eed4 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/database/models/Manga.java +++ b/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; diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/database/models/MangaSync.java b/app/src/main/java/eu/kanade/tachiyomi/data/database/models/MangaSync.java index 619064c5a..92a5a6404 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/database/models/MangaSync.java +++ b/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; + } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/injection/component/AppComponent.kt b/app/src/main/java/eu/kanade/tachiyomi/injection/component/AppComponent.kt index ced38255b..84c23ae5f 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/injection/component/AppComponent.kt +++ b/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) diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/backup/BackupFragment.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/backup/BackupFragment.kt new file mode 100644 index 000000000..15f4b0c04 --- /dev/null +++ b/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() { + + 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() + } + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/backup/BackupPresenter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/backup/BackupPresenter.kt new file mode 100644 index 000000000..1be74c597 --- /dev/null +++ b/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() { + + /** + * 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 { + return Observable.fromCallable { + backupManager.backupToFile(backupFile!!) + true + }.subscribeOn(Schedulers.io()).observeOn(AndroidSchedulers.mainThread()) + } + + /** + * Returns the observable to restore a backup. + */ + private fun getRestoreObservable(): Observable { + return Observable.fromCallable { + backupManager.restoreFromStream(restoreStream!!) + true + }.subscribeOn(Schedulers.io()).observeOn(AndroidSchedulers.mainThread()) + } + +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/main/MainActivity.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/main/MainActivity.kt index dfaeba8d0..be33e9a44 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/main/MainActivity.kt +++ b/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 diff --git a/app/src/main/res/drawable/ic_backup_black_24dp.xml b/app/src/main/res/drawable/ic_backup_black_24dp.xml new file mode 100644 index 000000000..086281669 --- /dev/null +++ b/app/src/main/res/drawable/ic_backup_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/layout/fragment_backup.xml b/app/src/main/res/layout/fragment_backup.xml new file mode 100644 index 000000000..e13611cf7 --- /dev/null +++ b/app/src/main/res/layout/fragment_backup.xml @@ -0,0 +1,21 @@ + + + +