mirror of
				https://github.com/mihonapp/mihon.git
				synced 2025-10-31 14:27:57 +01:00 
			
		
		
		
	Support backups
This commit is contained in:
		| @@ -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>() | ||||
|     } | ||||
|  | ||||
| } | ||||
| @@ -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 | ||||
|  | ||||
| } | ||||
| @@ -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 | ||||
|     } | ||||
| } | ||||
| @@ -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() | ||||
|   | ||||
| @@ -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}" | ||||
| @@ -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(); | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -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; | ||||
|   | ||||
| @@ -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; | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -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) | ||||
|   | ||||
| @@ -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() | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -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()) | ||||
|     } | ||||
|  | ||||
| } | ||||
| @@ -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 | ||||
|   | ||||
		Reference in New Issue
	
	Block a user