From 0642889b647ffb2c2ab014b33e6fd0a286065c84 Mon Sep 17 00:00:00 2001 From: Bram van de Kerkhof Date: Tue, 4 Apr 2017 17:42:17 +0200 Subject: [PATCH] Rewrote Backup (#650) * Rewrote Backup * Save automatic backups with datetime * Minor improvements * Remove suggested directories for backup and hardcoded strings. Rename JSON -> Backup * Bugfix * Fix tests * Run restore inside a transaction, use external cache dir for log and other minor changes --- app/src/main/AndroidManifest.xml | 10 +- app/src/main/java/eu/kanade/tachiyomi/App.kt | 2 + .../data/backup/BackupCreateService.kt | 166 ++++ .../tachiyomi/data/backup/BackupCreatorJob.kt | 41 + .../tachiyomi/data/backup/BackupManager.kt | 544 +++++------ .../data/backup/BackupRestoreService.kt | 413 +++++++++ .../tachiyomi/data/backup/models/Backup.kt | 23 + .../tachiyomi/data/backup/models/DHistory.kt | 3 + .../backup/serializer/BooleanSerializer.kt | 16 - .../backup/serializer/CategoryTypeAdapter.kt | 31 + .../backup/serializer/ChapterTypeAdapter.kt | 61 ++ .../backup/serializer/HistoryTypeAdapter.kt | 32 + .../data/backup/serializer/IdExclusion.kt | 27 - .../backup/serializer/IntegerSerializer.kt | 17 - .../data/backup/serializer/LongSerializer.kt | 16 - .../backup/serializer/MangaTypeAdapter.kt | 37 + .../backup/serializer/TrackTypeAdapter.kt | 53 ++ .../tachiyomi/data/database/DatabaseHelper.kt | 2 + .../data/database/queries/ChapterQueries.kt | 6 + .../data/database/queries/HistoryQueries.kt | 15 + .../resolvers/ChapterBackupPutResolver.kt | 35 + .../data/preference/PreferenceKeys.kt | 6 + .../data/preference/PreferencesHelper.kt | 10 + .../tachiyomi/ui/backup/BackupFragment.kt | 163 ---- .../tachiyomi/ui/backup/BackupPresenter.kt | 94 -- .../kanade/tachiyomi/ui/main/MainActivity.kt | 2 - .../tachiyomi/ui/setting/SettingsActivity.kt | 1 + .../ui/setting/SettingsAdvancedFragment.kt | 1 + .../ui/setting/SettingsBackupFragment.kt | 413 +++++++++ .../ui/setting/SettingsDownloadsFragment.kt | 30 +- .../tachiyomi/ui/setting/SettingsFragment.kt | 1 + .../kanade/tachiyomi/util/FileExtensions.kt | 12 - .../tachiyomi/widget/CustomLayoutPicker.kt | 33 + app/src/main/res/menu/menu_navigation.xml | 4 - app/src/main/res/values/arrays.xml | 42 + app/src/main/res/values/keys.xml | 8 +- app/src/main/res/values/strings.xml | 45 +- app/src/main/res/xml/pref_backup.xml | 48 + .../tachiyomi/data/backup/BackupTest.kt | 852 +++++++----------- 39 files changed, 2166 insertions(+), 1149 deletions(-) create mode 100644 app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupCreateService.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupCreatorJob.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupRestoreService.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/data/backup/models/Backup.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/data/backup/models/DHistory.kt delete mode 100644 app/src/main/java/eu/kanade/tachiyomi/data/backup/serializer/BooleanSerializer.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/data/backup/serializer/CategoryTypeAdapter.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/data/backup/serializer/ChapterTypeAdapter.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/data/backup/serializer/HistoryTypeAdapter.kt delete mode 100644 app/src/main/java/eu/kanade/tachiyomi/data/backup/serializer/IdExclusion.kt delete mode 100644 app/src/main/java/eu/kanade/tachiyomi/data/backup/serializer/IntegerSerializer.kt delete mode 100644 app/src/main/java/eu/kanade/tachiyomi/data/backup/serializer/LongSerializer.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/data/backup/serializer/MangaTypeAdapter.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/data/backup/serializer/TrackTypeAdapter.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/data/database/resolvers/ChapterBackupPutResolver.kt delete mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/backup/BackupFragment.kt delete mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/backup/BackupPresenter.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsBackupFragment.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/widget/CustomLayoutPicker.kt create mode 100644 app/src/main/res/xml/pref_backup.xml diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 6a536ee6ce..a1c830a087 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -45,7 +45,7 @@ android:label="@string/label_categories" android:parentActivityName=".ui.main.MainActivity" /> + + + + diff --git a/app/src/main/java/eu/kanade/tachiyomi/App.kt b/app/src/main/java/eu/kanade/tachiyomi/App.kt index 6bc7e91abd..9fd73b8782 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/App.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/App.kt @@ -5,6 +5,7 @@ import android.content.Context import android.content.res.Configuration import android.support.multidex.MultiDex import com.evernote.android.job.JobManager +import eu.kanade.tachiyomi.data.backup.BackupCreatorJob import eu.kanade.tachiyomi.data.library.LibraryUpdateJob import eu.kanade.tachiyomi.data.updater.UpdateCheckerJob import eu.kanade.tachiyomi.util.LocaleHelper @@ -58,6 +59,7 @@ open class App : Application() { when (tag) { LibraryUpdateJob.TAG -> LibraryUpdateJob() UpdateCheckerJob.TAG -> UpdateCheckerJob() + BackupCreatorJob.TAG -> BackupCreatorJob() else -> null } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupCreateService.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupCreateService.kt new file mode 100644 index 0000000000..d892ac58cd --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupCreateService.kt @@ -0,0 +1,166 @@ +package eu.kanade.tachiyomi.data.backup + +import android.app.IntentService +import android.content.Context +import android.content.Intent +import android.net.Uri +import com.github.salomonbrys.kotson.set +import com.google.gson.JsonArray +import com.google.gson.JsonObject +import com.hippo.unifile.UniFile +import eu.kanade.tachiyomi.data.backup.models.Backup +import eu.kanade.tachiyomi.data.backup.models.Backup.CATEGORIES +import eu.kanade.tachiyomi.data.backup.models.Backup.MANGAS +import eu.kanade.tachiyomi.data.backup.models.Backup.VERSION +import eu.kanade.tachiyomi.data.database.models.Manga +import eu.kanade.tachiyomi.ui.setting.SettingsBackupFragment +import eu.kanade.tachiyomi.util.sendLocalBroadcast +import timber.log.Timber +import eu.kanade.tachiyomi.BuildConfig.APPLICATION_ID as ID + +/** + * [IntentService] used to backup [Manga] information to [JsonArray] + */ +class BackupCreateService : IntentService(NAME) { + + companion object { + // Name of class + private const val NAME = "BackupCreateService" + + // Uri as string + private const val EXTRA_URI = "$ID.$NAME.EXTRA_URI" + // Backup called from job + private const val EXTRA_IS_JOB = "$ID.$NAME.EXTRA_IS_JOB" + // Options for backup + private const val EXTRA_FLAGS = "$ID.$NAME.EXTRA_FLAGS" + + // Filter options + internal const val BACKUP_CATEGORY = 0x1 + internal const val BACKUP_CATEGORY_MASK = 0x1 + internal const val BACKUP_CHAPTER = 0x2 + internal const val BACKUP_CHAPTER_MASK = 0x2 + internal const val BACKUP_HISTORY = 0x4 + internal const val BACKUP_HISTORY_MASK = 0x4 + internal const val BACKUP_TRACK = 0x8 + internal const val BACKUP_TRACK_MASK = 0x8 + internal const val BACKUP_ALL = 0xF + + /** + * Make a backup from library + * + * @param context context of application + * @param path path of Uri + * @param flags determines what to backup + * @param isJob backup called from job + */ + fun makeBackup(context: Context, path: String, flags: Int, isJob: Boolean = false) { + val intent = Intent(context, BackupCreateService::class.java).apply { + putExtra(EXTRA_URI, path) + putExtra(EXTRA_IS_JOB, isJob) + putExtra(EXTRA_FLAGS, flags) + } + context.startService(intent) + } + } + + private val backupManager by lazy { BackupManager(this) } + + override fun onHandleIntent(intent: Intent?) { + if (intent == null) return + + // Get values + val uri = intent.getStringExtra(EXTRA_URI) + val isJob = intent.getBooleanExtra(EXTRA_IS_JOB, false) + val flags = intent.getIntExtra(EXTRA_FLAGS, 0) + // Create backup + createBackupFromApp(Uri.parse(uri), flags, isJob) + } + + /** + * Create backup Json file from database + * + * @param uri path of Uri + * @param isJob backup called from job + */ + fun createBackupFromApp(uri: Uri, flags: Int, isJob: Boolean) { + // Create root object + val root = JsonObject() + + // Create information object + val information = JsonObject() + + // Create manga array + val mangaEntries = JsonArray() + + // Create category array + val categoryEntries = JsonArray() + + // Add value's to root + root[VERSION] = Backup.CURRENT_VERSION + root[MANGAS] = mangaEntries + root[CATEGORIES] = categoryEntries + + backupManager.databaseHelper.inTransaction { + // Get manga from database + val mangas = backupManager.getFavoriteManga() + + // Backup library manga and its dependencies + mangas.forEach { manga -> + mangaEntries.add(backupManager.backupMangaObject(manga, flags)) + } + + // Backup categories + if ((flags and BACKUP_CATEGORY_MASK) == BACKUP_CATEGORY) { + backupManager.backupCategories(categoryEntries) + } + } + + try { + // When BackupCreatorJob + if (isJob) { + // Get dir of file + val dir = UniFile.fromUri(this, uri) + + // Delete older backups + val numberOfBackups = backupManager.numberOfBackups() + val backupRegex = Regex("""tachiyomi_\d+-\d+-\d+_\d+-\d+.json""") + dir.listFiles { _, filename -> backupRegex.matches(filename) } + .orEmpty() + .sortedByDescending { it.name } + .drop(numberOfBackups - 1) + .forEach { it.delete() } + + // Create new file to place backup + val newFile = dir.createFile(Backup.getDefaultFilename()) + ?: throw Exception("Couldn't create backup file") + + newFile.openOutputStream().bufferedWriter().use { + backupManager.parser.toJson(root, it) + } + } else { + val file = UniFile.fromUri(this, uri) + ?: throw Exception("Couldn't create backup file") + file.openOutputStream().bufferedWriter().use { + backupManager.parser.toJson(root, it) + } + + // Show completed dialog + val intent = Intent(SettingsBackupFragment.INTENT_FILTER).apply { + putExtra(SettingsBackupFragment.ACTION, SettingsBackupFragment.ACTION_BACKUP_COMPLETED_DIALOG) + putExtra(SettingsBackupFragment.EXTRA_URI, file.uri.toString()) + } + sendLocalBroadcast(intent) + } + } catch (e: Exception) { + Timber.e(e) + if (!isJob) { + // Show error dialog + val intent = Intent(SettingsBackupFragment.INTENT_FILTER).apply { + putExtra(SettingsBackupFragment.ACTION, SettingsBackupFragment.ACTION_ERROR_BACKUP_DIALOG) + putExtra(SettingsBackupFragment.EXTRA_ERROR_MESSAGE, e.message) + } + sendLocalBroadcast(intent) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupCreatorJob.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupCreatorJob.kt new file mode 100644 index 0000000000..6f4796be68 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupCreatorJob.kt @@ -0,0 +1,41 @@ +package eu.kanade.tachiyomi.data.backup + +import com.evernote.android.job.Job +import com.evernote.android.job.JobManager +import com.evernote.android.job.JobRequest +import eu.kanade.tachiyomi.data.preference.PreferencesHelper +import eu.kanade.tachiyomi.data.preference.getOrDefault +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get + +class BackupCreatorJob : Job() { + + override fun onRunJob(params: Params): Result { + val preferences = Injekt.get() + val path = preferences.backupsDirectory().getOrDefault() + val flags = BackupCreateService.BACKUP_ALL + BackupCreateService.makeBackup(context,path,flags,true) + return Result.SUCCESS + } + + companion object { + const val TAG = "BackupCreator" + + fun setupTask(prefInterval: Int? = null) { + val preferences = Injekt.get() + val interval = prefInterval ?: preferences.backupInterval().getOrDefault() + if (interval > 0) { + JobRequest.Builder(TAG) + .setPeriodic(interval * 60 * 60 * 1000L, 10 * 60 * 1000) + .setPersisted(true) + .setUpdateCurrent(true) + .build() + .schedule() + } + } + + fun cancelTask() { + JobManager.instance().cancelAllForTag(TAG) + } + } +} \ No newline at end of file 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 index 90abe0ebd2..2fc1998d56 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupManager.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupManager.kt @@ -1,203 +1,213 @@ package eu.kanade.tachiyomi.data.backup -import com.github.salomonbrys.kotson.fromJson +import android.content.Context +import com.github.salomonbrys.kotson.* import com.google.gson.* -import com.google.gson.stream.JsonReader -import eu.kanade.tachiyomi.data.backup.serializer.BooleanSerializer -import eu.kanade.tachiyomi.data.backup.serializer.IdExclusion -import eu.kanade.tachiyomi.data.backup.serializer.IntegerSerializer -import eu.kanade.tachiyomi.data.backup.serializer.LongSerializer +import eu.kanade.tachiyomi.data.backup.BackupCreateService.Companion.BACKUP_CATEGORY +import eu.kanade.tachiyomi.data.backup.BackupCreateService.Companion.BACKUP_CATEGORY_MASK +import eu.kanade.tachiyomi.data.backup.BackupCreateService.Companion.BACKUP_CHAPTER +import eu.kanade.tachiyomi.data.backup.BackupCreateService.Companion.BACKUP_CHAPTER_MASK +import eu.kanade.tachiyomi.data.backup.BackupCreateService.Companion.BACKUP_HISTORY +import eu.kanade.tachiyomi.data.backup.BackupCreateService.Companion.BACKUP_HISTORY_MASK +import eu.kanade.tachiyomi.data.backup.BackupCreateService.Companion.BACKUP_TRACK +import eu.kanade.tachiyomi.data.backup.BackupCreateService.Companion.BACKUP_TRACK_MASK +import eu.kanade.tachiyomi.data.backup.models.Backup.CATEGORIES +import eu.kanade.tachiyomi.data.backup.models.Backup.CHAPTERS +import eu.kanade.tachiyomi.data.backup.models.Backup.CURRENT_VERSION +import eu.kanade.tachiyomi.data.backup.models.Backup.HISTORY +import eu.kanade.tachiyomi.data.backup.models.Backup.MANGA +import eu.kanade.tachiyomi.data.backup.models.Backup.TRACK +import eu.kanade.tachiyomi.data.backup.models.DHistory +import eu.kanade.tachiyomi.data.backup.serializer.* import eu.kanade.tachiyomi.data.database.DatabaseHelper import eu.kanade.tachiyomi.data.database.models.* -import java.io.* +import eu.kanade.tachiyomi.data.preference.PreferencesHelper +import eu.kanade.tachiyomi.data.preference.getOrDefault +import eu.kanade.tachiyomi.source.Source +import eu.kanade.tachiyomi.source.SourceManager +import eu.kanade.tachiyomi.util.syncChaptersWithSource +import rx.Observable +import uy.kohesive.injekt.injectLazy import java.util.* -/** - * This class provides the necessary methods to create and restore backups for the data of the - * application. The backup follows a JSON structure, with the following scheme: - * - * { - * "mangas": [ - * { - * "manga": {"id": 1, ...}, - * "chapters": [{"id": 1, ...}, {...}], - * "sync": [{"id": 1, ...}, {...}], - * "categories": ["cat1", "cat2", ...] - * }, - * { ... } - * ], - * "categories": [ - * {"id": 1, ...}, - * {"id": 2, ...} - * ] - * } - * - * @param db the database helper. - */ -class BackupManager(private val db: DatabaseHelper) { - - private val MANGA = "manga" - private val MANGAS = "mangas" - private val CHAPTERS = "chapters" - private val TRACK = "sync" - private val CATEGORIES = "categories" - - @Suppress("PLATFORM_CLASS_MAPPED_TO_KOTLIN") - private val gson = GsonBuilder() - .registerTypeAdapter(java.lang.Integer::class.java, IntegerSerializer()) - .registerTypeAdapter(java.lang.Boolean::class.java, BooleanSerializer()) - .registerTypeAdapter(java.lang.Long::class.java, LongSerializer()) - .setExclusionStrategies(IdExclusion()) - .create() +class BackupManager(val context: Context, version: Int = CURRENT_VERSION) { /** - * Backups the data of the application to a file. - * - * @param file the file where the backup will be saved. - * @throws IOException if there's any IO error. + * Database. */ - @Throws(IOException::class) - fun backupToFile(file: File) { - val root = backupToJson() + internal val databaseHelper: DatabaseHelper by injectLazy() - FileWriter(file).use { - gson.toJson(root, it) + /** + * Source manager. + */ + internal val sourceManager: SourceManager by injectLazy() + + /** + * Version of parser + */ + var version: Int = version + private set + + /** + * Json Parser + */ + var parser: Gson = initParser() + + /** + * Preferences + */ + private val preferences: PreferencesHelper by injectLazy() + + /** + * Set version of parser + * + * @param version version of parser + */ + internal fun setVersion(version: Int) { + this.version = version + parser = initParser() + } + + private fun initParser(): Gson { + return when (version) { + 1 -> GsonBuilder().create() + 2 -> GsonBuilder() + .registerTypeAdapter(MangaTypeAdapter.build()) + .registerTypeHierarchyAdapter(ChapterTypeAdapter.build()) + .registerTypeAdapter(CategoryTypeAdapter.build()) + .registerTypeAdapter(HistoryTypeAdapter.build()) + .registerTypeHierarchyAdapter(TrackTypeAdapter.build()) + .create() + else -> throw Exception("Json version unknown") } } /** - * Creates a JSON object containing the backup of the app's data. + * Backup the categories of library * - * @return the backup as a JSON object. + * @param root root of categories json */ - 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 + internal fun backupCategories(root: JsonArray) { + val categories = databaseHelper.getCategories().executeAsBlocking() + categories.forEach { root.add(parser.toJsonTree(it)) } } /** - * Backups a manga and its related data (chapters, categories this manga is in, sync...). + * Convert a manga to Json * - * @param manga the manga to backup. - * @return a JSON object containing all the data of the manga. + * @param manga manga that gets converted + * @return [JsonElement] containing manga information */ - private fun backupManga(manga: Manga): JsonObject { + internal fun backupMangaObject(manga: Manga, options: Int): JsonElement { // Entry for this manga val entry = JsonObject() // Backup manga fields - entry.add(MANGA, gson.toJsonTree(manga)) + entry[MANGA] = parser.toJsonTree(manga) - // Backup all the chapters - val chapters = db.getChapters(manga).executeAsBlocking() - if (!chapters.isEmpty()) { - entry.add(CHAPTERS, gson.toJsonTree(chapters)) - } - - // Backup tracks - val tracks = db.getTracks(manga).executeAsBlocking() - if (!tracks.isEmpty()) { - entry.add(TRACK, gson.toJsonTree(tracks)) - } - - // 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) + // Check if user wants chapter information in backup + if (options and BACKUP_CHAPTER_MASK == BACKUP_CHAPTER) { + // Backup all the chapters + val chapters = databaseHelper.getChapters(manga).executeAsBlocking() + if (!chapters.isEmpty()) { + val chaptersJson = parser.toJsonTree(chapters) + if (chaptersJson.asJsonArray.size() > 0) { + entry[CHAPTERS] = chaptersJson + } + } + } + + // Check if user wants category information in backup + if (options and BACKUP_CATEGORY_MASK == BACKUP_CATEGORY) { + // Backup categories for this manga + val categoriesForManga = databaseHelper.getCategoriesForManga(manga).executeAsBlocking() + if (!categoriesForManga.isEmpty()) { + val categoriesNames = categoriesForManga.map { it.name } + entry[CATEGORIES] = parser.toJsonTree(categoriesNames) + } + } + + // Check if user wants track information in backup + if (options and BACKUP_TRACK_MASK == BACKUP_TRACK) { + val tracks = databaseHelper.getTracks(manga).executeAsBlocking() + if (!tracks.isEmpty()) { + entry[TRACK] = parser.toJsonTree(tracks) + } + } + + // Check if user wants history information in backup + if (options and BACKUP_HISTORY_MASK == BACKUP_HISTORY) { + val historyForManga = databaseHelper.getHistoryByMangaId(manga.id!!).executeAsBlocking() + if (!historyForManga.isEmpty()) { + val historyData = historyForManga.mapNotNull { history -> + val url = databaseHelper.getChapter(history.chapter_id).executeAsBlocking()?.url + url?.let { DHistory(url, history.last_read) } + } + val historyJson = parser.toJsonTree(historyData) + if (historyJson.asJsonArray.size() > 0) { + entry[HISTORY] = historyJson + } } - entry.add(CATEGORIES, gson.toJsonTree(categoriesNames)) } return entry } - /** - * Backups a category. - * - * @param category the category to backup. - * @return a JSON object containing the data of the category. - */ - private fun backupCategory(category: Category): JsonElement { - return gson.toJsonTree(category) + fun restoreMangaNoFetch(manga: Manga, dbManga: Manga) { + manga.id = dbManga.id + manga.copyFrom(dbManga) + manga.favorite = true + insertManga(manga) } /** - * Restores a backup from a file. + * [Observable] that fetches manga information * - * @param file the file containing the backup. - * @throws IOException if there's any IO error. + * @param source source of manga + * @param manga manga that needs updating + * @return [Observable] that contains manga */ - @Throws(IOException::class) - fun restoreFromFile(file: File) { - JsonReader(FileReader(file)).use { - val root = JsonParser().parse(it).asJsonObject - restoreFromJson(root) - } + fun restoreMangaFetchObservable(source: Source, manga: Manga): Observable { + return source.fetchMangaDetails(manga) + .map { networkManga -> + manga.copyFrom(networkManga) + manga.favorite = true + manga.initialized = true + manga.id = insertManga(manga) + manga + } } /** - * Restores a backup from an input stream. + * [Observable] that fetches chapter information * - * @param stream the stream containing the backup. - * @throws IOException if there's any IO error. + * @param source source of manga + * @param manga manga that needs updating + * @return [Observable] that contains manga */ - @Throws(IOException::class) - fun restoreFromStream(stream: InputStream) { - JsonReader(InputStreamReader(stream)).use { - val root = JsonParser().parse(it).asJsonObject - restoreFromJson(root) - } + fun restoreChapterFetchObservable(source: Source, manga: Manga, chapters: List): Observable, List>> { + return source.fetchChapterList(manga) + .map { syncChaptersWithSource(databaseHelper, it, manga, source) } + .doOnNext { + if (it.first.isNotEmpty()) { + chapters.forEach { it.manga_id = manga.id } + insertChapters(chapters) + } + } } /** - * Restores a backup from a JSON object. Everything executes in a single transaction so that - * nothing is modified if there's an error. + * Restore the categories from Json * - * @param root the root of the JSON. + * @param jsonCategories array containing categories */ - 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) { + internal fun restoreCategories(jsonCategories: JsonArray) { // Get categories from file and from db - val dbCategories = db.getCategories().executeAsBlocking() - val backupCategories = gson.fromJson>(jsonCategories) + val dbCategories = databaseHelper.getCategories().executeAsBlocking() + val backupCategories = parser.fromJson>(jsonCategories) // Iterate over them - for (category in backupCategories) { + backupCategories.forEach { category -> // Used to know if the category is already in the db var found = false for (dbCategory in dbCategories) { @@ -214,102 +224,20 @@ class BackupManager(private val db: DatabaseHelper) { if (!found) { // Let the db assign the id category.id = null - val result = db.insertCategory(category).executeAsBlocking() + val result = databaseHelper.insertCategory(category).executeAsBlocking() category.id = result.insertedId()?.toInt() } } } - /** - * Restores all the mangas and its related data. - * - * @param jsonMangas the mangas and its related data (chapters, sync, categories) from the json. - */ - private fun restoreMangas(jsonMangas: JsonArray) { - for (backupManga in jsonMangas) { - // Map every entry to objects - val element = backupManga.asJsonObject - val manga = gson.fromJson(element.get(MANGA), MangaImpl::class.java) - val chapters = gson.fromJson>(element.get(CHAPTERS) ?: JsonArray()) - val tracks = gson.fromJson>(element.get(TRACK) ?: JsonArray()) - val categories = gson.fromJson>(element.get(CATEGORIES) ?: JsonArray()) - - // Restore everything related to this manga - restoreManga(manga) - restoreChaptersForManga(manga, chapters) - restoreSyncForManga(manga, tracks) - restoreCategoriesForManga(manga, categories) - } - } - - /** - * Restores a manga. - * - * @param manga the manga to restore. - */ - private fun restoreManga(manga: Manga) { - // Try to find existing manga in db - val dbManga = db.getManga(manga.url, manga.source).executeAsBlocking() - if (dbManga == null) { - // Let the db assign the id - manga.id = null - val result = db.insertManga(manga).executeAsBlocking() - manga.id = result.insertedId() - } else { - // If it exists already, we copy only the values related to the source from the db - // (they can be up to date). Local values (flags) are kept from the backup. - manga.id = dbManga.id - manga.copyFrom(dbManga) - manga.favorite = true - db.insertManga(manga).executeAsBlocking() - } - } - - /** - * Restores the chapters of a manga. - * - * @param manga the manga whose chapters have to be restored. - * @param chapters the chapters to restore. - */ - private fun restoreChaptersForManga(manga: Manga, chapters: List) { - // 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() + internal fun restoreCategoriesForManga(manga: Manga, categories: List) { + val dbCategories = databaseHelper.getCategories().executeAsBlocking() val mangaCategoriesToUpdate = ArrayList() for (backupCategoryStr in categories) { for (dbCategory in dbCategories) { @@ -324,45 +252,151 @@ class BackupManager(private val db: DatabaseHelper) { if (!mangaCategoriesToUpdate.isEmpty()) { val mangaAsList = ArrayList() mangaAsList.add(manga) - db.deleteOldMangasCategories(mangaAsList).executeAsBlocking() - db.insertMangasCategories(mangaCategoriesToUpdate).executeAsBlocking() + databaseHelper.deleteOldMangasCategories(mangaAsList).executeAsBlocking() + databaseHelper.insertMangasCategories(mangaCategoriesToUpdate).executeAsBlocking() } } + /** + * Restore history from Json + * + * @param history list containing history to be restored + */ + internal fun restoreHistoryForManga(history: List) { + // List containing history to be updated + val historyToBeUpdated = ArrayList() + for ((url, lastRead) in history) { + val dbHistory = databaseHelper.getHistoryByChapterUrl(url).executeAsBlocking() + // Check if history already in database and update + if (dbHistory != null) { + dbHistory.apply { + last_read = Math.max(lastRead, dbHistory.last_read) + } + historyToBeUpdated.add(dbHistory) + } else { + // If not in database create + databaseHelper.getChapter(url).executeAsBlocking()?.let { + val historyToAdd = History.create(it).apply { + last_read = lastRead + } + historyToBeUpdated.add(historyToAdd) + } + } + } + databaseHelper.updateHistoryLastRead(historyToBeUpdated).executeAsBlocking() + } + /** * Restores the sync of a manga. * * @param manga the manga whose sync have to be restored. * @param tracks the track list to restore. */ - private fun restoreSyncForManga(manga: Manga, tracks: List) { + internal fun restoreTrackForManga(manga: Manga, tracks: List) { // Fix foreign keys with the current manga id - for (track in tracks) { - track.manga_id = manga.id!! - } + tracks.map { it.manga_id = manga.id!! } - val dbTracks = db.getTracks(manga).executeAsBlocking() + // Get tracks from database + val dbTracks = databaseHelper.getTracks(manga).executeAsBlocking() val trackToUpdate = ArrayList() - for (backupTrack in tracks) { - // Try to find existing chapter in db - val pos = dbTracks.indexOf(backupTrack) - if (pos != -1) { - // The sync is already in the db, only update its fields - val dbSync = dbTracks[pos] - // Mark the max chapter as read and nothing else - dbSync.last_chapter_read = Math.max(backupTrack.last_chapter_read, dbSync.last_chapter_read) - trackToUpdate.add(dbSync) - } else { + + for (track in tracks) { + var isInDatabase = false + for (dbTrack in dbTracks) { + if (track.sync_id == dbTrack.sync_id) { + // The sync is already in the db, only update its fields + if (track.remote_id != dbTrack.remote_id) { + dbTrack.remote_id = track.remote_id + } + dbTrack.last_chapter_read = Math.max(dbTrack.last_chapter_read, track.last_chapter_read) + isInDatabase = true + trackToUpdate.add(dbTrack) + break + } + } + if (!isInDatabase) { // Insert new sync. Let the db assign the id - backupTrack.id = null - trackToUpdate.add(backupTrack) + track.id = null + trackToUpdate.add(track) } } - // Update database if (!trackToUpdate.isEmpty()) { - db.insertTracks(trackToUpdate).executeAsBlocking() + databaseHelper.insertTracks(trackToUpdate).executeAsBlocking() } } + /** + * Restore the chapters for manga if chapters already in database + * + * @param manga manga of chapters + * @param chapters list containing chapters that get restored + * @return boolean answering if chapter fetch is not needed + */ + internal fun restoreChaptersForManga(manga: Manga, chapters: List): Boolean { + val dbChapters = databaseHelper.getChapters(manga).executeAsBlocking() + + // Return if fetch is needed + if (dbChapters.isEmpty() || dbChapters.size < chapters.size) + return false + + for (chapter in chapters) { + val pos = dbChapters.indexOf(chapter) + if (pos != -1) { + val dbChapter = dbChapters[pos] + chapter.id = dbChapter.id + chapter.copyFrom(dbChapter) + break + } + } + // Filter the chapters that couldn't be found. + chapters.filter { it.id != null } + chapters.map { it.manga_id = manga.id } + + insertChapters(chapters) + return true + } + + /** + * Returns manga + * + * @return [Manga], null if not found + */ + internal fun getMangaFromDatabase(manga: Manga): Manga? { + return databaseHelper.getManga(manga.url, manga.source).executeAsBlocking() + } + + /** + * Returns list containing manga from library + * + * @return [Manga] from library + */ + internal fun getFavoriteManga(): List { + return databaseHelper.getFavoriteMangas().executeAsBlocking() + } + + /** + * Inserts manga and returns id + * + * @return id of [Manga], null if not found + */ + internal fun insertManga(manga: Manga): Long? { + return databaseHelper.insertManga(manga).executeAsBlocking().insertedId() + } + + /** + * Inserts list of chapters + */ + internal fun insertChapters(chapters: List) { + databaseHelper.updateChaptersBackup(chapters).executeAsBlocking() + } + + /** + * Return number of backups. + * + * @return number of backups selected by user + */ + fun numberOfBackups(): Int { + return preferences.numberOfBackups().getOrDefault() + } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupRestoreService.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupRestoreService.kt new file mode 100644 index 0000000000..2c1eaac8dc --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupRestoreService.kt @@ -0,0 +1,413 @@ +package eu.kanade.tachiyomi.data.backup + +import android.app.Service +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.IBinder +import android.os.PowerManager +import com.github.salomonbrys.kotson.fromJson +import com.google.gson.JsonArray +import com.google.gson.JsonParser +import com.google.gson.stream.JsonReader +import com.hippo.unifile.UniFile +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.data.backup.models.Backup.CATEGORIES +import eu.kanade.tachiyomi.data.backup.models.Backup.CHAPTERS +import eu.kanade.tachiyomi.data.backup.models.Backup.HISTORY +import eu.kanade.tachiyomi.data.backup.models.Backup.MANGA +import eu.kanade.tachiyomi.data.backup.models.Backup.MANGAS +import eu.kanade.tachiyomi.data.backup.models.Backup.TRACK +import eu.kanade.tachiyomi.data.backup.models.Backup.VERSION +import eu.kanade.tachiyomi.data.backup.models.DHistory +import eu.kanade.tachiyomi.data.database.DatabaseHelper +import eu.kanade.tachiyomi.data.database.models.* +import eu.kanade.tachiyomi.source.Source +import eu.kanade.tachiyomi.ui.setting.SettingsBackupFragment +import eu.kanade.tachiyomi.util.AndroidComponentUtil +import eu.kanade.tachiyomi.util.chop +import eu.kanade.tachiyomi.util.sendLocalBroadcast +import rx.Observable +import rx.Subscription +import rx.schedulers.Schedulers +import timber.log.Timber +import uy.kohesive.injekt.injectLazy +import java.io.File +import java.text.SimpleDateFormat +import java.util.* +import eu.kanade.tachiyomi.BuildConfig.APPLICATION_ID as ID + +/** + * Restores backup from json file + */ +class BackupRestoreService : Service() { + + companion object { + // Name of service + private const val NAME = "BackupRestoreService" + + // Uri as string + private const val EXTRA_URI = "$ID.$NAME.EXTRA_URI" + + /** + * Returns the status of the service. + * + * @param context the application context. + * @return true if the service is running, false otherwise. + */ + fun isRunning(context: Context): Boolean { + return AndroidComponentUtil.isServiceRunning(context, BackupRestoreService::class.java) + } + + /** + * Starts a service to restore a backup from Json + * + * @param context context of application + * @param uri path of Uri + */ + fun start(context: Context, uri: String) { + if (!isRunning(context)) { + val intent = Intent(context, BackupRestoreService::class.java).apply { + putExtra(EXTRA_URI, uri) + } + context.startService(intent) + } + } + + /** + * Stops the service. + * + * @param context the application context. + */ + fun stop(context: Context) { + context.stopService(Intent(context, BackupRestoreService::class.java)) + } + } + + /** + * Wake lock that will be held until the service is destroyed. + */ + private lateinit var wakeLock: PowerManager.WakeLock + + /** + * Subscription where the update is done. + */ + private var subscription: Subscription? = null + + /** + * The progress of a backup restore + */ + private var restoreProgress = 0 + + /** + * Amount of manga in Json file (needed for restore) + */ + private var restoreAmount = 0 + + /** + * List containing errors + */ + private val errors = mutableListOf>() + + /** + * Backup manager + */ + private lateinit var backupManager: BackupManager + + /** + * Database + */ + private val db: DatabaseHelper by injectLazy() + + /** + * Method called when the service is created. It injects dependencies and acquire the wake lock. + */ + override fun onCreate() { + super.onCreate() + wakeLock = (getSystemService(Context.POWER_SERVICE) as PowerManager).newWakeLock( + PowerManager.PARTIAL_WAKE_LOCK, "BackupRestoreService:WakeLock") + wakeLock.acquire() + } + + /** + * Method called when the service is destroyed. It destroys the running subscription and + * releases the wake lock. + */ + override fun onDestroy() { + subscription?.unsubscribe() + if (wakeLock.isHeld) { + wakeLock.release() + } + super.onDestroy() + } + + /** + * This method needs to be implemented, but it's not used/needed. + */ + override fun onBind(intent: Intent): IBinder? { + return null + } + + /** + * Method called when the service receives an intent. + * + * @param intent the start intent from. + * @param flags the flags of the command. + * @param startId the start id of this command. + * @return the start value of the command. + */ + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + if (intent == null) return Service.START_NOT_STICKY + + // Unsubscribe from any previous subscription if needed. + subscription?.unsubscribe() + + val startTime = System.currentTimeMillis() + subscription = Observable.defer { + // Get URI + val uri = Uri.parse(intent.getStringExtra(EXTRA_URI)) + // Get file from Uri + val file = UniFile.fromUri(this, uri) + + // Clear errors + errors.clear() + + // Reset progress + restoreProgress = 0 + + db.lowLevel().beginTransaction() + getRestoreObservable(file) + } + .subscribeOn(Schedulers.io()) + .subscribe({ + }, { error -> + db.lowLevel().endTransaction() + Timber.e(error) + writeErrorLog() + val errorIntent = Intent(SettingsBackupFragment.INTENT_FILTER).apply { + putExtra(SettingsBackupFragment.ACTION, SettingsBackupFragment.ACTION_ERROR_RESTORE_DIALOG) + putExtra(SettingsBackupFragment.EXTRA_ERROR_MESSAGE, error.message) + } + sendLocalBroadcast(errorIntent) + stopSelf(startId) + }, { + db.lowLevel().setTransactionSuccessful() + db.lowLevel().endTransaction() + val endTime = System.currentTimeMillis() + val time = endTime - startTime + val file = writeErrorLog() + val completeIntent = Intent(SettingsBackupFragment.INTENT_FILTER).apply { + putExtra(SettingsBackupFragment.EXTRA_TIME, time) + putExtra(SettingsBackupFragment.EXTRA_ERRORS, errors.size) + putExtra(SettingsBackupFragment.EXTRA_ERROR_FILE_PATH, file.parent) + putExtra(SettingsBackupFragment.EXTRA_ERROR_FILE, file.name) + putExtra(SettingsBackupFragment.ACTION, SettingsBackupFragment.ACTION_RESTORE_COMPLETED_DIALOG) + } + sendLocalBroadcast(completeIntent) + stopSelf(startId) + }) + return Service.START_NOT_STICKY + } + + /** + * Returns an [Observable] containing restore process. + * + * @param file restore file + * @return [Observable] + */ + private fun getRestoreObservable(file: UniFile): Observable { + val reader = JsonReader(file.openInputStream().bufferedReader()) + val json = JsonParser().parse(reader).asJsonObject + + // Get parser version + val version = json.get(VERSION)?.asInt ?: 1 + + // Initialize manager + backupManager = BackupManager(this, version) + + val mangasJson = json.get(MANGAS).asJsonArray + + restoreAmount = mangasJson.size() + 1 // +1 for categories + + // Restore categories + json.get(CATEGORIES)?.let { + backupManager.restoreCategories(it.asJsonArray) + restoreProgress += 1 + showRestoreProgress(restoreProgress, restoreAmount, "Categories added", errors.size) + } + + return Observable.from(mangasJson) + .concatMap { + val obj = it.asJsonObject + val manga = backupManager.parser.fromJson(obj.get(MANGA)) + val chapters = backupManager.parser.fromJson>(obj.get(CHAPTERS) ?: JsonArray()) + val categories = backupManager.parser.fromJson>(obj.get(CATEGORIES) ?: JsonArray()) + val history = backupManager.parser.fromJson>(obj.get(HISTORY) ?: JsonArray()) + val tracks = backupManager.parser.fromJson>(obj.get(TRACK) ?: JsonArray()) + + val observable = getMangaRestoreObservable(manga, chapters, categories, history, tracks) + if (observable != null) { + observable + } else { + errors.add(Date() to "${manga.title} - ${getString(R.string.source_not_found)}") + restoreProgress += 1 + val content = getString(R.string.dialog_restoring_source_not_found, manga.title.chop(15)) + showRestoreProgress(restoreProgress, restoreAmount, manga.title, errors.size, content) + Observable.just(manga) + } + } + } + + /** + * Write errors to error log + */ + private fun writeErrorLog(): File { + try { + if (errors.isNotEmpty()) { + val destFile = File(externalCacheDir, "tachiyomi_restore.log") + val sdf = SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS", Locale.getDefault()) + + destFile.bufferedWriter().use { out -> + errors.forEach { (date, message) -> + out.write("[${sdf.format(date)}] $message\n") + } + } + return destFile + } + } catch (e: Exception) { + // Empty + } + return File("") + } + + /** + * Returns a manga restore observable + * + * @param manga manga data from json + * @param chapters chapters data from json + * @param categories categories data from json + * @param history history data from json + * @param tracks tracking data from json + * @return [Observable] containing manga restore information + */ + private fun getMangaRestoreObservable(manga: Manga, chapters: List, + categories: List, history: List, + tracks: List): Observable? { + // Get source + val source = backupManager.sourceManager.get(manga.source) ?: return null + val dbManga = backupManager.getMangaFromDatabase(manga) + + if (dbManga == null) { + // Manga not in database + return mangaFetchObservable(source, manga, chapters, categories, history, tracks) + } else { // Manga in database + // Copy information from manga already in database + backupManager.restoreMangaNoFetch(manga, dbManga) + // Fetch rest of manga information + return mangaNoFetchObservable(source, manga, chapters, categories, history, tracks) + } + } + + /** + * [Observable] that fetches manga information + * + * @param manga manga that needs updating + * @param chapters chapters of manga that needs updating + * @param categories categories that need updating + */ + private fun mangaFetchObservable(source: Source, manga: Manga, chapters: List, + categories: List, history: List, + tracks: List): Observable { + return backupManager.restoreMangaFetchObservable(source, manga) + .onErrorReturn { + errors.add(Date() to "${manga.title} - ${it.message}") + manga + } + .filter { it.id != null } + .flatMap { manga -> + chapterFetchObservable(source, manga, chapters) + // Convert to the manga that contains new chapters. + .map { manga } + } + .doOnNext { + // Restore categories + backupManager.restoreCategoriesForManga(it, categories) + + // Restore history + backupManager.restoreHistoryForManga(history) + + // Restore tracking + backupManager.restoreTrackForManga(it, tracks) + } + .doOnCompleted { + restoreProgress += 1 + showRestoreProgress(restoreProgress, restoreAmount, manga.title, errors.size) + } + } + + private fun mangaNoFetchObservable(source: Source, backupManga: Manga, chapters: List, + categories: List, history: List, + tracks: List): Observable { + + return Observable.just(backupManga) + .flatMap { manga -> + if (!backupManager.restoreChaptersForManga(manga, chapters)) { + chapterFetchObservable(source, manga, chapters) + .map { manga } + } else { + Observable.just(manga) + } + } + .doOnNext { + // Restore categories + backupManager.restoreCategoriesForManga(it, categories) + + // Restore history + backupManager.restoreHistoryForManga(history) + + // Restore tracking + backupManager.restoreTrackForManga(it, tracks) + } + .doOnCompleted { + restoreProgress += 1 + showRestoreProgress(restoreProgress, restoreAmount, backupManga.title, errors.size) + } + } + + /** + * [Observable] that fetches chapter information + * + * @param source source of manga + * @param manga manga that needs updating + * @return [Observable] that contains manga + */ + private fun chapterFetchObservable(source: Source, manga: Manga, chapters: List): Observable, List>> { + return backupManager.restoreChapterFetchObservable(source, manga, chapters) + // If there's any error, return empty update and continue. + .onErrorReturn { + errors.add(Date() to "${manga.title} - ${it.message}") + Pair(emptyList(), emptyList()) + } + } + + + /** + * Called to update dialog in [SettingsBackupFragment] + * + * @param progress restore progress + * @param amount total restoreAmount of manga + * @param title title of restored manga + */ + private fun showRestoreProgress(progress: Int, amount: Int, title: String, errors: Int, + content: String = getString(R.string.dialog_restoring_backup, title.chop(15))) { + val intent = Intent(SettingsBackupFragment.INTENT_FILTER).apply { + putExtra(SettingsBackupFragment.EXTRA_PROGRESS, progress) + putExtra(SettingsBackupFragment.EXTRA_AMOUNT, amount) + putExtra(SettingsBackupFragment.EXTRA_CONTENT, content) + putExtra(SettingsBackupFragment.EXTRA_ERRORS, errors) + putExtra(SettingsBackupFragment.ACTION, SettingsBackupFragment.ACTION_SET_PROGRESS_DIALOG) + } + sendLocalBroadcast(intent) + } + +} \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/models/Backup.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/models/Backup.kt new file mode 100644 index 0000000000..3a5e2d3432 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/backup/models/Backup.kt @@ -0,0 +1,23 @@ +package eu.kanade.tachiyomi.data.backup.models + +import java.text.SimpleDateFormat +import java.util.* + +/** + * Json values + */ +object Backup { + const val CURRENT_VERSION = 2 + const val MANGA = "manga" + const val MANGAS = "mangas" + const val TRACK = "track" + const val CHAPTERS = "chapters" + const val CATEGORIES = "categories" + const val HISTORY = "history" + const val VERSION = "version" + + fun getDefaultFilename(): String { + val date = SimpleDateFormat("yyyy-MM-dd_HH-mm", Locale.getDefault()).format(Date()) + return "tachiyomi_$date.json" + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/models/DHistory.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/models/DHistory.kt new file mode 100644 index 0000000000..3623dd0d37 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/backup/models/DHistory.kt @@ -0,0 +1,3 @@ +package eu.kanade.tachiyomi.data.backup.models + +data class DHistory(val url: String,val lastRead: Long) \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/serializer/BooleanSerializer.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/serializer/BooleanSerializer.kt deleted file mode 100644 index 14d5a4236f..0000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/data/backup/serializer/BooleanSerializer.kt +++ /dev/null @@ -1,16 +0,0 @@ -package eu.kanade.tachiyomi.data.backup.serializer - -import com.google.gson.JsonElement -import com.google.gson.JsonPrimitive -import com.google.gson.JsonSerializationContext -import com.google.gson.JsonSerializer -import java.lang.reflect.Type - -class BooleanSerializer : JsonSerializer { - - override fun serialize(value: Boolean?, type: Type, context: JsonSerializationContext): JsonElement? { - if (value != null && value != false) - return JsonPrimitive(value) - return null - } -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/serializer/CategoryTypeAdapter.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/serializer/CategoryTypeAdapter.kt new file mode 100644 index 0000000000..b31279268f --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/backup/serializer/CategoryTypeAdapter.kt @@ -0,0 +1,31 @@ +package eu.kanade.tachiyomi.data.backup.serializer + +import com.github.salomonbrys.kotson.typeAdapter +import com.google.gson.TypeAdapter +import eu.kanade.tachiyomi.data.database.models.CategoryImpl + +/** + * JSON Serializer used to write / read [CategoryImpl] to / from json + */ +object CategoryTypeAdapter { + + fun build(): TypeAdapter { + return typeAdapter { + write { + beginArray() + value(it.name) + value(it.order) + endArray() + } + + read { + beginArray() + val category = CategoryImpl() + category.name = nextString() + category.order = nextInt() + endArray() + category + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/serializer/ChapterTypeAdapter.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/serializer/ChapterTypeAdapter.kt new file mode 100644 index 0000000000..74002582de --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/backup/serializer/ChapterTypeAdapter.kt @@ -0,0 +1,61 @@ +package eu.kanade.tachiyomi.data.backup.serializer + +import com.github.salomonbrys.kotson.typeAdapter +import com.google.gson.TypeAdapter +import com.google.gson.stream.JsonToken +import eu.kanade.tachiyomi.data.database.models.ChapterImpl + +/** + * JSON Serializer used to write / read [ChapterImpl] to / from json + */ +object ChapterTypeAdapter { + + private const val URL = "u" + private const val READ = "r" + private const val BOOKMARK = "b" + private const val LAST_READ = "l" + + fun build(): TypeAdapter { + return typeAdapter { + write { + if (it.read || it.bookmark || it.last_page_read != 0) { + beginObject() + name(URL) + value(it.url) + if (it.read) { + name(READ) + value(1) + } + if (it.bookmark) { + name(BOOKMARK) + value(1) + } + if (it.last_page_read != 0) { + name(LAST_READ) + value(it.last_page_read) + } + endObject() + } + } + + read { + val chapter = ChapterImpl() + beginObject() + while (hasNext()) { + if (peek() == JsonToken.NAME) { + val name = nextName() + + when (name) { + URL -> chapter.url = nextString() + READ -> chapter.read = nextInt() == 1 + BOOKMARK -> chapter.bookmark = nextInt() == 1 + LAST_READ -> chapter.last_page_read = nextInt() + } + } + } + endObject() + chapter + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/serializer/HistoryTypeAdapter.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/serializer/HistoryTypeAdapter.kt new file mode 100644 index 0000000000..e313c3b90b --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/backup/serializer/HistoryTypeAdapter.kt @@ -0,0 +1,32 @@ +package eu.kanade.tachiyomi.data.backup.serializer + +import com.github.salomonbrys.kotson.typeAdapter +import com.google.gson.TypeAdapter +import eu.kanade.tachiyomi.data.backup.models.DHistory + +/** + * JSON Serializer used to write / read [DHistory] to / from json + */ +object HistoryTypeAdapter { + + fun build(): TypeAdapter { + return typeAdapter { + write { + if (it.lastRead != 0L) { + beginArray() + value(it.url) + value(it.lastRead) + endArray() + } + } + + read { + beginArray() + val url = nextString() + val lastRead = nextLong() + endArray() + DHistory(url, lastRead) + } + } + } +} \ No newline at end of file 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 deleted file mode 100644 index d1d3b7e202..0000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/data/backup/serializer/IdExclusion.kt +++ /dev/null @@ -1,27 +0,0 @@ -package eu.kanade.tachiyomi.data.backup.serializer - -import com.google.gson.ExclusionStrategy -import com.google.gson.FieldAttributes -import eu.kanade.tachiyomi.data.database.models.CategoryImpl -import eu.kanade.tachiyomi.data.database.models.ChapterImpl -import eu.kanade.tachiyomi.data.database.models.MangaImpl -import eu.kanade.tachiyomi.data.database.models.TrackImpl - -class IdExclusion : ExclusionStrategy { - - private val categoryExclusions = listOf("id") - private val mangaExclusions = listOf("id") - private val chapterExclusions = listOf("id", "manga_id") - private val syncExclusions = listOf("id", "manga_id", "update") - - override fun shouldSkipField(f: FieldAttributes) = when (f.declaringClass) { - MangaImpl::class.java -> mangaExclusions.contains(f.name) - ChapterImpl::class.java -> chapterExclusions.contains(f.name) - TrackImpl::class.java -> syncExclusions.contains(f.name) - CategoryImpl::class.java -> categoryExclusions.contains(f.name) - else -> false - } - - override fun shouldSkipClass(clazz: Class<*>) = false - -} 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 deleted file mode 100644 index 4a58b5d4e1..0000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/data/backup/serializer/IntegerSerializer.kt +++ /dev/null @@ -1,17 +0,0 @@ -package eu.kanade.tachiyomi.data.backup.serializer - -import com.google.gson.JsonElement -import com.google.gson.JsonPrimitive -import com.google.gson.JsonSerializationContext -import com.google.gson.JsonSerializer - -import java.lang.reflect.Type - -class IntegerSerializer : JsonSerializer { - - 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/backup/serializer/LongSerializer.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/serializer/LongSerializer.kt deleted file mode 100644 index be62e3fe1d..0000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/data/backup/serializer/LongSerializer.kt +++ /dev/null @@ -1,16 +0,0 @@ -package eu.kanade.tachiyomi.data.backup.serializer - -import com.google.gson.JsonElement -import com.google.gson.JsonPrimitive -import com.google.gson.JsonSerializationContext -import com.google.gson.JsonSerializer -import java.lang.reflect.Type - -class LongSerializer : JsonSerializer { - - override fun serialize(value: Long?, type: Type, context: JsonSerializationContext): JsonElement? { - if (value != null && value !== 0L) - return JsonPrimitive(value) - return null - } -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/serializer/MangaTypeAdapter.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/serializer/MangaTypeAdapter.kt new file mode 100644 index 0000000000..1aaaeda100 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/backup/serializer/MangaTypeAdapter.kt @@ -0,0 +1,37 @@ +package eu.kanade.tachiyomi.data.backup.serializer + +import com.github.salomonbrys.kotson.typeAdapter +import com.google.gson.TypeAdapter +import eu.kanade.tachiyomi.data.database.models.MangaImpl + +/** + * JSON Serializer used to write / read [MangaImpl] to / from json + */ +object MangaTypeAdapter { + + fun build(): TypeAdapter { + return typeAdapter { + write { + beginArray() + value(it.url) + value(it.title) + value(it.source) + value(it.viewer) + value(it.chapter_flags) + endArray() + } + + read { + beginArray() + val manga = MangaImpl() + manga.url = nextString() + manga.title = nextString() + manga.source = nextLong() + manga.viewer = nextInt() + manga.chapter_flags = nextInt() + endArray() + manga + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/serializer/TrackTypeAdapter.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/serializer/TrackTypeAdapter.kt new file mode 100644 index 0000000000..a5f43c3db2 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/backup/serializer/TrackTypeAdapter.kt @@ -0,0 +1,53 @@ +package eu.kanade.tachiyomi.data.backup.serializer + +import com.github.salomonbrys.kotson.typeAdapter +import com.google.gson.TypeAdapter +import com.google.gson.stream.JsonToken +import eu.kanade.tachiyomi.data.database.models.TrackImpl + +/** + * JSON Serializer used to write / read [TrackImpl] to / from json + */ +object TrackTypeAdapter { + + private const val SYNC = "s" + private const val REMOTE = "r" + private const val TITLE = "t" + private const val LAST_READ = "l" + + fun build(): TypeAdapter { + return typeAdapter { + write { + beginObject() + name(TITLE) + value(it.title) + name(SYNC) + value(it.sync_id) + name(REMOTE) + value(it.remote_id) + name(LAST_READ) + value(it.last_chapter_read) + endObject() + } + + read { + val track = TrackImpl() + beginObject() + while (hasNext()) { + if (peek() == JsonToken.NAME) { + val name = nextName() + + when (name) { + TITLE -> track.title = nextString() + SYNC -> track.sync_id = nextInt() + REMOTE -> track.remote_id = nextInt() + LAST_READ -> track.last_chapter_read = nextInt() + } + } + } + endObject() + track + } + } + } +} \ No newline at end of file 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 76bbda1d50..239a759903 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 @@ -24,4 +24,6 @@ open class DatabaseHelper(context: Context) inline fun inTransaction(block: () -> Unit) = db.inTransaction(block) + fun lowLevel() = db.lowLevel() + } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/database/queries/ChapterQueries.kt b/app/src/main/java/eu/kanade/tachiyomi/data/database/queries/ChapterQueries.kt index 7cc999088d..b6cb58670d 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/database/queries/ChapterQueries.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/database/queries/ChapterQueries.kt @@ -6,6 +6,7 @@ import eu.kanade.tachiyomi.data.database.DbProvider import eu.kanade.tachiyomi.data.database.models.Chapter import eu.kanade.tachiyomi.data.database.models.Manga import eu.kanade.tachiyomi.data.database.models.MangaChapter +import eu.kanade.tachiyomi.data.database.resolvers.ChapterBackupPutResolver import eu.kanade.tachiyomi.data.database.resolvers.ChapterProgressPutResolver import eu.kanade.tachiyomi.data.database.resolvers.ChapterSourceOrderPutResolver import eu.kanade.tachiyomi.data.database.resolvers.MangaChapterGetResolver @@ -60,6 +61,11 @@ interface ChapterQueries : DbProvider { fun deleteChapters(chapters: List) = db.delete().objects(chapters).prepare() + fun updateChaptersBackup(chapters: List) = db.put() + .objects(chapters) + .withPutResolver(ChapterBackupPutResolver()) + .prepare() + fun updateChapterProgress(chapter: Chapter) = db.put() .`object`(chapter) .withPutResolver(ChapterProgressPutResolver()) diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/database/queries/HistoryQueries.kt b/app/src/main/java/eu/kanade/tachiyomi/data/database/queries/HistoryQueries.kt index 340e14adb1..fc17e36ec2 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/database/queries/HistoryQueries.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/database/queries/HistoryQueries.kt @@ -1,5 +1,6 @@ package eu.kanade.tachiyomi.data.database.queries +import com.pushtorefresh.storio.sqlite.queries.DeleteQuery import com.pushtorefresh.storio.sqlite.queries.RawQuery import eu.kanade.tachiyomi.data.database.DbProvider import eu.kanade.tachiyomi.data.database.models.History @@ -68,4 +69,18 @@ interface HistoryQueries : DbProvider { .objects(historyList) .withPutResolver(HistoryLastReadPutResolver()) .prepare() + + fun deleteHistory() = db.delete() + .byQuery(DeleteQuery.builder() + .table(HistoryTable.TABLE) + .build()) + .prepare() + + fun deleteHistoryNoLastRead() = db.delete() + .byQuery(DeleteQuery.builder() + .table(HistoryTable.TABLE) + .where("${HistoryTable.COL_LAST_READ} = ?") + .whereArgs(0) + .build()) + .prepare() } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/database/resolvers/ChapterBackupPutResolver.kt b/app/src/main/java/eu/kanade/tachiyomi/data/database/resolvers/ChapterBackupPutResolver.kt new file mode 100644 index 0000000000..1c3e6fb74e --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/database/resolvers/ChapterBackupPutResolver.kt @@ -0,0 +1,35 @@ +package eu.kanade.tachiyomi.data.database.resolvers + +import android.content.ContentValues +import com.pushtorefresh.storio.sqlite.StorIOSQLite +import com.pushtorefresh.storio.sqlite.operations.put.PutResolver +import com.pushtorefresh.storio.sqlite.operations.put.PutResult +import com.pushtorefresh.storio.sqlite.queries.UpdateQuery +import eu.kanade.tachiyomi.data.database.inTransactionReturn +import eu.kanade.tachiyomi.data.database.models.Chapter +import eu.kanade.tachiyomi.data.database.tables.ChapterTable + +class ChapterBackupPutResolver : PutResolver() { + + override fun performPut(db: StorIOSQLite, chapter: Chapter) = db.inTransactionReturn { + val updateQuery = mapToUpdateQuery(chapter) + val contentValues = mapToContentValues(chapter) + + val numberOfRowsUpdated = db.lowLevel().update(updateQuery, contentValues) + PutResult.newUpdateResult(numberOfRowsUpdated, updateQuery.table()) + } + + fun mapToUpdateQuery(chapter: Chapter) = UpdateQuery.builder() + .table(ChapterTable.TABLE) + .where("${ChapterTable.COL_URL} = ?") + .whereArgs(chapter.url) + .build() + + fun mapToContentValues(chapter: Chapter) = ContentValues(3).apply { + put(ChapterTable.COL_READ, chapter.read) + put(ChapterTable.COL_BOOKMARK, chapter.bookmark) + put(ChapterTable.COL_LAST_PAGE_READ, chapter.last_page_read) + } + +} + diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferenceKeys.kt b/app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferenceKeys.kt index 2ff7f41df7..d0f391332b 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferenceKeys.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferenceKeys.kt @@ -65,12 +65,18 @@ class PreferenceKeys(context: Context) { val enabledLanguages = context.getString(R.string.pref_source_languages) + val backupDirectory = context.getString(R.string.pref_backup_directory_key) + val downloadsDirectory = context.getString(R.string.pref_download_directory_key) val downloadThreads = context.getString(R.string.pref_download_slots_key) val downloadOnlyOverWifi = context.getString(R.string.pref_download_only_over_wifi_key) + val numberOfBackups = context.getString(R.string.pref_backup_slots_key) + + val backupInterval = context.getString(R.string.pref_backup_interval_key) + val removeAfterReadSlots = context.getString(R.string.pref_remove_after_read_slots_key) val removeAfterMarkedAsRead = context.getString(R.string.pref_remove_after_marked_as_read_key) diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferencesHelper.kt b/app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferencesHelper.kt index 932104c726..930f561efe 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferencesHelper.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferencesHelper.kt @@ -26,6 +26,10 @@ class PreferencesHelper(val context: Context) { File(Environment.getExternalStorageDirectory().absolutePath + File.separator + context.getString(R.string.app_name), "downloads")) + private val defaultBackupDir = Uri.fromFile( + File(Environment.getExternalStorageDirectory().absolutePath + File.separator + + context.getString(R.string.app_name), "backup")) + fun startScreen() = prefs.getInt(keys.startScreen, 1) fun clear() = prefs.edit().clear().apply() @@ -112,12 +116,18 @@ class PreferencesHelper(val context: Context) { fun anilistScoreType() = rxPrefs.getInteger("anilist_score_type", 0) + fun backupsDirectory() = rxPrefs.getString(keys.backupDirectory, defaultBackupDir.toString()) + fun downloadsDirectory() = rxPrefs.getString(keys.downloadsDirectory, defaultDownloadsDir.toString()) fun downloadThreads() = rxPrefs.getInteger(keys.downloadThreads, 1) fun downloadOnlyOverWifi() = prefs.getBoolean(keys.downloadOnlyOverWifi, true) + fun numberOfBackups() = rxPrefs.getInteger(keys.numberOfBackups, 1) + + fun backupInterval() = rxPrefs.getInteger(keys.backupInterval, 0) + fun removeAfterReadSlots() = prefs.getInt(keys.removeAfterReadSlots, -1) fun removeAfterMarkedAsRead() = prefs.getBoolean(keys.removeAfterMarkedAsRead, false) 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 deleted file mode 100644 index d172354780..0000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/backup/BackupFragment.kt +++ /dev/null @@ -1,163 +0,0 @@ -package eu.kanade.tachiyomi.ui.backup - -import android.app.Activity -import android.app.Dialog -import android.content.Intent -import android.net.Uri -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import com.afollestad.materialdialogs.MaterialDialog -import eu.kanade.tachiyomi.R -import eu.kanade.tachiyomi.ui.base.activity.ActivityMixin -import eu.kanade.tachiyomi.ui.base.fragment.BaseRxFragment -import eu.kanade.tachiyomi.util.toast -import kotlinx.android.synthetic.main.fragment_backup.* -import nucleus.factory.RequiresPresenter -import rx.Observable -import rx.android.schedulers.AndroidSchedulers -import rx.internal.util.SubscriptionList -import rx.schedulers.Schedulers -import timber.log.Timber -import java.io.File -import java.text.SimpleDateFormat -import java.util.* - -/** - * Fragment to create and restore backups of the application's data. - * Uses R.layout.fragment_backup. - */ -@RequiresPresenter(BackupPresenter::class) -class BackupFragment : BaseRxFragment() { - - private var backupDialog: Dialog? = null - private var restoreDialog: Dialog? = null - - private lateinit var subscriptions: SubscriptionList - - override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedState: Bundle?): View { - return inflater.inflate(R.layout.fragment_backup, container, false) - } - - override fun onViewCreated(view: View, savedState: Bundle?) { - setToolbarTitle(getString(R.string.label_backup)) - - (activity as ActivityMixin).requestPermissionsOnMarshmallow() - subscriptions = SubscriptionList() - - backup_button.setOnClickListener { - val today = SimpleDateFormat("yyyy-MM-dd").format(Date()) - val file = File(activity.externalCacheDir, "tachiyomi-$today.json") - presenter.createBackup(file) - - backupDialog = MaterialDialog.Builder(activity) - .content(R.string.backup_please_wait) - .progress(true, 0) - .show() - } - - restore_button.setOnClickListener { - val intent = Intent(Intent.ACTION_GET_CONTENT) - intent.addCategory(Intent.CATEGORY_OPENABLE) - intent.type = "application/*" - val chooser = Intent.createChooser(intent, getString(R.string.file_select_backup)) - startActivityForResult(chooser, REQUEST_BACKUP_OPEN) - } - } - - override fun onDestroyView() { - subscriptions.unsubscribe() - super.onDestroyView() - } - - /** - * Called from the presenter when the backup is completed. - * - * @param file the file where the backup is saved. - */ - fun onBackupCompleted(file: File) { - dismissBackupDialog() - val intent = Intent(Intent.ACTION_SEND) - intent.type = "application/json" - intent.putExtra(Intent.EXTRA_STREAM, Uri.parse("file://" + file)) - startActivity(Intent.createChooser(intent, "")) - } - - /** - * Called from the presenter when the restore is completed. - */ - fun onRestoreCompleted() { - dismissRestoreDialog() - context.toast(R.string.backup_completed) - } - - /** - * Called from the presenter when there's an error doing the backup. - * @param error the exception thrown. - */ - fun onBackupError(error: Throwable) { - dismissBackupDialog() - context.toast(error.message) - } - - /** - * Called from the presenter when there's an error restoring the backup. - * @param error the exception thrown. - */ - fun onRestoreError(error: Throwable) { - dismissRestoreDialog() - context.toast(error.message) - } - - override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { - if (data != null && resultCode == Activity.RESULT_OK && requestCode == REQUEST_BACKUP_OPEN) { - restoreDialog = MaterialDialog.Builder(activity) - .content(R.string.restore_please_wait) - .progress(true, 0) - .show() - - // When using cloud services, we have to open the input stream in a background thread. - Observable.fromCallable { context.contentResolver.openInputStream(data.data) } - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe({ - presenter.restoreBackup(it) - }, { error -> - context.toast(error.message) - Timber.e(error) - }) - .apply { subscriptions.add(this) } - - } - } - - /** - * Dismisses the backup dialog. - */ - fun dismissBackupDialog() { - backupDialog?.let { - it.dismiss() - backupDialog = null - } - } - - /** - * Dismisses the restore dialog. - */ - fun dismissRestoreDialog() { - restoreDialog?.let { - it.dismiss() - restoreDialog = null - } - } - - companion object { - - private val REQUEST_BACKUP_OPEN = 102 - - fun newInstance(): BackupFragment { - return BackupFragment() - } - } -} 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 deleted file mode 100644 index 24397e9917..0000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/backup/BackupPresenter.kt +++ /dev/null @@ -1,94 +0,0 @@ -package eu.kanade.tachiyomi.ui.backup - -import android.os.Bundle -import eu.kanade.tachiyomi.data.backup.BackupManager -import eu.kanade.tachiyomi.data.database.DatabaseHelper -import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter -import eu.kanade.tachiyomi.util.isNullOrUnsubscribed -import rx.Observable -import rx.Subscription -import rx.android.schedulers.AndroidSchedulers -import rx.schedulers.Schedulers -import uy.kohesive.injekt.injectLazy -import java.io.File -import java.io.InputStream - -/** - * Presenter of [BackupFragment]. - */ -class BackupPresenter : BasePresenter() { - - /** - * Database. - */ - val db: DatabaseHelper by injectLazy() - - /** - * Backup manager. - */ - private lateinit var backupManager: BackupManager - - /** - * Subscription where the backup is restored. - */ - private var restoreSubscription: Subscription? = null - - /** - * Subscription where the backup is created. - */ - private var backupSubscription: Subscription? = null - - override fun onCreate(savedState: Bundle?) { - super.onCreate(savedState) - backupManager = BackupManager(db) - } - - /** - * Creates a backup and saves it to a file. - * - * @param file the path where the file will be saved. - */ - fun createBackup(file: File) { - if (backupSubscription.isNullOrUnsubscribed()) { - backupSubscription = getBackupObservable(file) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribeFirst( - { view, result -> view.onBackupCompleted(file) }, - BackupFragment::onBackupError) - } - } - - /** - * Restores a backup from a stream. - * - * @param stream the input stream of the backup file. - */ - fun restoreBackup(stream: InputStream) { - if (restoreSubscription.isNullOrUnsubscribed()) { - restoreSubscription = getRestoreObservable(stream) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribeFirst( - { view, result -> view.onRestoreCompleted() }, - BackupFragment::onRestoreError) - } - } - - /** - * Returns the observable to save a backup. - */ - private fun getBackupObservable(file: File) = Observable.fromCallable { - backupManager.backupToFile(file) - true - } - - /** - * Returns the observable to restore a backup. - */ - private fun getRestoreObservable(stream: InputStream) = Observable.fromCallable { - backupManager.restoreFromStream(stream) - true - } - -} 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 3ee8feeee3..2504d7ef82 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 @@ -8,7 +8,6 @@ import android.support.v4.view.GravityCompat import android.view.MenuItem import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.data.preference.PreferencesHelper -import eu.kanade.tachiyomi.ui.backup.BackupFragment import eu.kanade.tachiyomi.ui.base.activity.BaseActivity import eu.kanade.tachiyomi.ui.catalogue.CatalogueFragment import eu.kanade.tachiyomi.ui.download.DownloadActivity @@ -71,7 +70,6 @@ class MainActivity : BaseActivity() { val intent = Intent(this, SettingsActivity::class.java) startActivityForResult(intent, REQUEST_OPEN_SETTINGS) } - R.id.nav_drawer_backup -> setFragment(BackupFragment.newInstance(), id) } } drawer.closeDrawer(GravityCompat.START) diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsActivity.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsActivity.kt index b8e4cc467c..21304dae57 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsActivity.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsActivity.kt @@ -65,6 +65,7 @@ class SettingsActivity : BaseActivity(), "downloads_screen" -> SettingsDownloadsFragment.newInstance(key) "sources_screen" -> SettingsSourcesFragment.newInstance(key) "tracking_screen" -> SettingsTrackingFragment.newInstance(key) + "backup_screen" -> SettingsBackupFragment.newInstance(key) "advanced_screen" -> SettingsAdvancedFragment.newInstance(key) "about_screen" -> SettingsAboutFragment.newInstance(key) else -> SettingsFragment.newInstance(key) diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsAdvancedFragment.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsAdvancedFragment.kt index 35bb43079b..e8576414d2 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsAdvancedFragment.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsAdvancedFragment.kt @@ -108,6 +108,7 @@ class SettingsAdvancedFragment : SettingsFragment() { .onPositive { dialog, which -> (activity as SettingsActivity).parentFlags = SettingsActivity.FLAG_DATABASE_CLEARED db.deleteMangasNotInLibrary().executeAsBlocking() + db.deleteHistoryNoLastRead().executeAsBlocking() activity.toast(R.string.clear_database_completed) } .show() diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsBackupFragment.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsBackupFragment.kt new file mode 100644 index 0000000000..864075c504 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsBackupFragment.kt @@ -0,0 +1,413 @@ +package eu.kanade.tachiyomi.ui.setting + +import android.app.Activity +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.net.Uri +import android.os.Build +import android.os.Bundle +import android.support.v7.preference.XpPreferenceFragment +import android.view.View +import com.afollestad.materialdialogs.MaterialDialog +import com.hippo.unifile.UniFile +import com.nononsenseapps.filepicker.FilePickerActivity +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.data.backup.BackupCreateService +import eu.kanade.tachiyomi.data.backup.BackupCreatorJob +import eu.kanade.tachiyomi.data.backup.BackupRestoreService +import eu.kanade.tachiyomi.data.backup.models.Backup +import eu.kanade.tachiyomi.data.preference.PreferencesHelper +import eu.kanade.tachiyomi.data.preference.getOrDefault +import eu.kanade.tachiyomi.ui.base.activity.BaseActivity +import eu.kanade.tachiyomi.util.* +import eu.kanade.tachiyomi.widget.CustomLayoutPickerActivity +import eu.kanade.tachiyomi.widget.preference.IntListPreference +import net.xpece.android.support.preference.Preference +import uy.kohesive.injekt.injectLazy +import java.io.File +import java.util.concurrent.TimeUnit +import eu.kanade.tachiyomi.BuildConfig.APPLICATION_ID as ID + +/** + * Settings for [BackupCreateService] and [BackupRestoreService] + */ +class SettingsBackupFragment : SettingsFragment() { + + companion object { + const val INTENT_FILTER = "SettingsBackupFragment" + const val ACTION_BACKUP_COMPLETED_DIALOG = "$ID.$INTENT_FILTER.ACTION_BACKUP_COMPLETED_DIALOG" + const val ACTION_SET_PROGRESS_DIALOG = "$ID.$INTENT_FILTER.ACTION_SET_PROGRESS_DIALOG" + const val ACTION_ERROR_BACKUP_DIALOG = "$ID.$INTENT_FILTER.ACTION_ERROR_BACKUP_DIALOG" + const val ACTION_ERROR_RESTORE_DIALOG = "$ID.$INTENT_FILTER.ACTION_ERROR_RESTORE_DIALOG" + const val ACTION_RESTORE_COMPLETED_DIALOG = "$ID.$INTENT_FILTER.ACTION_RESTORE_COMPLETED_DIALOG" + const val ACTION = "$ID.$INTENT_FILTER.ACTION" + const val EXTRA_PROGRESS = "$ID.$INTENT_FILTER.EXTRA_PROGRESS" + const val EXTRA_AMOUNT = "$ID.$INTENT_FILTER.EXTRA_AMOUNT" + const val EXTRA_ERRORS = "$ID.$INTENT_FILTER.EXTRA_ERRORS" + const val EXTRA_CONTENT = "$ID.$INTENT_FILTER.EXTRA_CONTENT" + const val EXTRA_ERROR_MESSAGE = "$ID.$INTENT_FILTER.EXTRA_ERROR_MESSAGE" + const val EXTRA_URI = "$ID.$INTENT_FILTER.EXTRA_URI" + const val EXTRA_TIME = "$ID.$INTENT_FILTER.EXTRA_TIME" + const val EXTRA_ERROR_FILE_PATH = "$ID.$INTENT_FILTER.EXTRA_ERROR_FILE_PATH" + const val EXTRA_ERROR_FILE = "$ID.$INTENT_FILTER.EXTRA_ERROR_FILE" + + private const val BACKUP_CREATE = 201 + private const val BACKUP_RESTORE = 202 + private const val BACKUP_DIR = 203 + + fun newInstance(rootKey: String): SettingsBackupFragment { + val args = Bundle() + args.putString(XpPreferenceFragment.ARG_PREFERENCE_ROOT, rootKey) + return SettingsBackupFragment().apply { arguments = args } + } + } + + /** + * Preference selected to create backup + */ + private val createBackup: Preference by bindPref(R.string.pref_create_local_backup_key) + + /** + * Preference selected to restore backup + */ + private val restoreBackup: Preference by bindPref(R.string.pref_restore_local_backup_key) + + /** + * Preference which determines the frequency of automatic backups. + */ + private val automaticBackup: IntListPreference by bindPref(R.string.pref_backup_interval_key) + + /** + * Preference containing number of automatic backups + */ + private val backupSlots: IntListPreference by bindPref(R.string.pref_backup_slots_key) + + /** + * Preference containing interval of automatic backups + */ + private val backupDirPref: Preference by bindPref(R.string.pref_backup_directory_key) + + /** + * Preferences + */ + private val preferences: PreferencesHelper by injectLazy() + + /** + * Value containing information on what to backup + */ + private var backup_flags = 0 + + /** + * The root directory for backups.. + */ + private var backupDir = preferences.backupsDirectory().getOrDefault().let { + UniFile.fromUri(context, Uri.parse(it)) + } + + val restoreDialog: MaterialDialog by lazy { + MaterialDialog.Builder(context) + .title(R.string.backup) + .content(R.string.restoring_backup) + .progress(false, 100, true) + .cancelable(false) + .negativeText(R.string.action_stop) + .onNegative { materialDialog, _ -> + BackupRestoreService.stop(context) + materialDialog.dismiss() + } + .build() + } + + val backupDialog: MaterialDialog by lazy { + MaterialDialog.Builder(context) + .title(R.string.backup) + .content(R.string.creating_backup) + .progress(true, 0) + .cancelable(false) + .build() + } + + private val receiver = object : BroadcastReceiver() { + + override fun onReceive(context: Context, intent: Intent) { + when (intent.getStringExtra(ACTION)) { + ACTION_BACKUP_COMPLETED_DIALOG -> { + backupDialog.dismiss() + val uri = Uri.parse(intent.getStringExtra(EXTRA_URI)) + val file = UniFile.fromUri(context, uri) + MaterialDialog.Builder(this@SettingsBackupFragment.context) + .title(getString(R.string.backup_created)) + .content(getString(R.string.file_saved, file.filePath)) + .positiveText(getString(R.string.action_close)) + .negativeText(getString(R.string.action_export)) + .onPositive { materialDialog, _ -> materialDialog.dismiss() } + .onNegative { _, _ -> + val sendIntent = Intent(Intent.ACTION_SEND) + sendIntent.type = "application/json" + sendIntent.putExtra(Intent.EXTRA_STREAM, file.uri) + startActivity(Intent.createChooser(sendIntent, "")) + } + .show() + + } + ACTION_SET_PROGRESS_DIALOG -> { + val progress = intent.getIntExtra(EXTRA_PROGRESS, 0) + val amount = intent.getIntExtra(EXTRA_AMOUNT, 0) + val content = intent.getStringExtra(EXTRA_CONTENT) + restoreDialog.setContent(content) + restoreDialog.setProgress(progress) + restoreDialog.maxProgress = amount + } + ACTION_RESTORE_COMPLETED_DIALOG -> { + restoreDialog.dismiss() + val time = intent.getLongExtra(EXTRA_TIME, 0) + val errors = intent.getIntExtra(EXTRA_ERRORS, 0) + val path = intent.getStringExtra(EXTRA_ERROR_FILE_PATH) + val file = intent.getStringExtra(EXTRA_ERROR_FILE) + val timeString = String.format("%02d min, %02d sec", + TimeUnit.MILLISECONDS.toMinutes(time), + TimeUnit.MILLISECONDS.toSeconds(time) - + TimeUnit.MINUTES.toSeconds(TimeUnit.MILLISECONDS.toMinutes(time)) + ) + + if (errors > 0) { + MaterialDialog.Builder(this@SettingsBackupFragment.context) + .title(getString(R.string.restore_completed)) + .content(getString(R.string.restore_completed_content, timeString, + if (errors > 0) "$errors" else getString(android.R.string.no))) + .positiveText(getString(R.string.action_close)) + .negativeText(getString(R.string.action_open_log)) + .onPositive { materialDialog, _ -> materialDialog.dismiss() } + .onNegative { materialDialog, _ -> + if (!path.isEmpty()) { + val destFile = File(path, file) + val uri = destFile.getUriCompat(context) + val sendIntent = Intent(Intent.ACTION_VIEW).apply { + setDataAndType(uri, "text/plain") + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_GRANT_READ_URI_PERMISSION + } + startActivity(sendIntent) + } else { + context.toast(getString(R.string.error_opening_log)) + } + materialDialog.dismiss() + } + .show() + } + } + ACTION_ERROR_BACKUP_DIALOG -> { + context.toast(intent.getStringExtra(EXTRA_ERROR_MESSAGE)) + backupDialog.dismiss() + } + ACTION_ERROR_RESTORE_DIALOG -> { + context.toast(intent.getStringExtra(EXTRA_ERROR_MESSAGE)) + restoreDialog.dismiss() + } + } + } + + } + + override fun onPause() { + context.unregisterLocalReceiver(receiver) + super.onPause() + } + + override fun onStart() { + super.onStart() + context.registerLocalReceiver(receiver, IntentFilter(INTENT_FILTER)) + } + + override fun onViewCreated(view: View, savedState: Bundle?) { + super.onViewCreated(view, savedState) + + (activity as BaseActivity).requestPermissionsOnMarshmallow() + + // Set onClickListeners + createBackup.setOnPreferenceClickListener { + MaterialDialog.Builder(context) + .title(R.string.pref_create_backup) + .content(R.string.backup_choice) + .items(R.array.backup_options) + .itemsCallbackMultiChoice(arrayOf(0, 1, 2, 3, 4 /*todo not hard code*/)) { _, positions, _ -> + // TODO not very happy with global value, but putExtra doesn't work + backup_flags = 0 + for (i in 1..positions.size - 1) { + when (positions[i]) { + 1 -> backup_flags = backup_flags or BackupCreateService.BACKUP_CATEGORY + 2 -> backup_flags = backup_flags or BackupCreateService.BACKUP_CHAPTER + 3 -> backup_flags = backup_flags or BackupCreateService.BACKUP_TRACK + 4 -> backup_flags = backup_flags or BackupCreateService.BACKUP_HISTORY + } + } + // If API lower as KitKat use custom dir picker + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.KITKAT) { + // Get dirs + val currentDir = preferences.backupsDirectory().getOrDefault() + + val i = Intent(activity, CustomLayoutPickerActivity::class.java) + i.putExtra(FilePickerActivity.EXTRA_ALLOW_MULTIPLE, false) + i.putExtra(FilePickerActivity.EXTRA_ALLOW_CREATE_DIR, true) + i.putExtra(FilePickerActivity.EXTRA_MODE, FilePickerActivity.MODE_DIR) + i.putExtra(FilePickerActivity.EXTRA_START_PATH, currentDir) + startActivityForResult(i, BACKUP_CREATE) + } else { + // Use Androids build in file creator + val intent = Intent(Intent.ACTION_CREATE_DOCUMENT) + intent.addCategory(Intent.CATEGORY_OPENABLE) + + // TODO create custom MIME data type? Will make older backups deprecated + intent.type = "application/*" + intent.putExtra(Intent.EXTRA_TITLE, Backup.getDefaultFilename()) + startActivityForResult(intent, BACKUP_CREATE) + } + true + } + .itemsDisabledIndices(0) + .positiveText(getString(R.string.action_create)) + .negativeText(android.R.string.cancel) + .show() + true + } + + restoreBackup.setOnPreferenceClickListener { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.KITKAT) { + val intent = Intent() + intent.type = "application/*" + intent.action = Intent.ACTION_GET_CONTENT + startActivityForResult(Intent.createChooser(intent, getString(R.string.file_select_backup)), BACKUP_RESTORE) + } else { + val intent = Intent(Intent.ACTION_OPEN_DOCUMENT) + intent.addCategory(Intent.CATEGORY_OPENABLE) + intent.type = "application/*" + startActivityForResult(intent, BACKUP_RESTORE) + } + true + } + + automaticBackup.setOnPreferenceChangeListener { _, newValue -> + // Always cancel the previous task, it seems that sometimes they are not updated. + BackupCreatorJob.cancelTask() + + val interval = (newValue as String).toInt() + if (interval > 0) { + BackupCreatorJob.setupTask(interval) + } + true + } + + backupSlots.setOnPreferenceChangeListener { preference, newValue -> + preferences.numberOfBackups().set((newValue as String).toInt()) + preference.summary = newValue + true + } + + backupDirPref.setOnPreferenceClickListener { + val currentDir = preferences.backupsDirectory().getOrDefault() + + if (Build.VERSION.SDK_INT < 21) { + // Custom dir selected, open directory selector + val i = Intent(activity, CustomLayoutPickerActivity::class.java) + i.putExtra(FilePickerActivity.EXTRA_ALLOW_MULTIPLE, false) + i.putExtra(FilePickerActivity.EXTRA_ALLOW_CREATE_DIR, true) + i.putExtra(FilePickerActivity.EXTRA_MODE, FilePickerActivity.MODE_DIR) + i.putExtra(FilePickerActivity.EXTRA_START_PATH, currentDir) + + startActivityForResult(i, BACKUP_DIR) + } else { + val i = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE) + startActivityForResult(i, BACKUP_DIR) + } + + true + } + + subscriptions += preferences.backupsDirectory().asObservable() + .subscribe { path -> + backupDir = UniFile.fromUri(context, Uri.parse(path)) + backupDirPref.summary = backupDir.filePath ?: path + } + + subscriptions += preferences.backupInterval().asObservable() + .subscribe { + backupDirPref.isVisible = it > 0 + backupSlots.isVisible = it > 0 + } + } + + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + when (requestCode) { + BACKUP_DIR -> if (data != null && resultCode == Activity.RESULT_OK) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) { + val uri = Uri.fromFile(File(data.data.path)) + preferences.backupsDirectory().set(uri.toString()) + } else { + val uri = data.data + val flags = Intent.FLAG_GRANT_READ_URI_PERMISSION or + Intent.FLAG_GRANT_WRITE_URI_PERMISSION + + context.contentResolver.takePersistableUriPermission(uri, flags) + + val file = UniFile.fromUri(context, uri) + preferences.backupsDirectory().set(file.uri.toString()) + } + } + BACKUP_CREATE -> if (data != null && resultCode == Activity.RESULT_OK) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.KITKAT) { + val dir = data.data.path + val file = File(dir, Backup.getDefaultFilename()) + + backupDialog.show() + BackupCreateService.makeBackup(context, file.toURI().toString(), backup_flags) + } else { + val uri = data.data + val flags = Intent.FLAG_GRANT_READ_URI_PERMISSION or + Intent.FLAG_GRANT_WRITE_URI_PERMISSION + + context.contentResolver.takePersistableUriPermission(uri, flags) + val file = UniFile.fromUri(context, uri) + + backupDialog.show() + BackupCreateService.makeBackup(context, file.uri.toString(), backup_flags) + } + } + BACKUP_RESTORE -> if (data != null && resultCode == Activity.RESULT_OK) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.KITKAT) { + val uri = Uri.fromFile(File(data.data.path)) + + MaterialDialog.Builder(context) + .title(getString(R.string.pref_restore_backup)) + .content(getString(R.string.backup_restore_content)) + .positiveText(getString(R.string.action_restore)) + .onPositive { materialDialog, _ -> + materialDialog.dismiss() + restoreDialog.show() + BackupRestoreService.start(context, uri.toString()) + } + .show() + } else { + val uri = data.data + val flags = Intent.FLAG_GRANT_READ_URI_PERMISSION or + Intent.FLAG_GRANT_WRITE_URI_PERMISSION + + context.contentResolver.takePersistableUriPermission(uri, flags) + val file = UniFile.fromUri(context, uri) + + MaterialDialog.Builder(context) + .title(getString(R.string.pref_restore_backup)) + .content(getString(R.string.backup_restore_content)) + .positiveText(getString(R.string.action_restore)) + .onPositive { materialDialog, _ -> + materialDialog.dismiss() + restoreDialog.show() + BackupRestoreService.start(context, file.uri.toString()) + } + .show() + } + } + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsDownloadsFragment.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsDownloadsFragment.kt index 5a948b47e9..09d56d5989 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsDownloadsFragment.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsDownloadsFragment.kt @@ -9,21 +9,16 @@ import android.os.Environment import android.support.v4.content.ContextCompat import android.support.v7.preference.Preference import android.support.v7.preference.XpPreferenceFragment -import android.support.v7.widget.RecyclerView import android.view.View -import android.view.ViewGroup import com.afollestad.materialdialogs.MaterialDialog import com.hippo.unifile.UniFile -import com.nononsenseapps.filepicker.AbstractFilePickerFragment import com.nononsenseapps.filepicker.FilePickerActivity -import com.nononsenseapps.filepicker.FilePickerFragment -import com.nononsenseapps.filepicker.LogicHandler import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.data.database.DatabaseHelper import eu.kanade.tachiyomi.data.preference.PreferencesHelper import eu.kanade.tachiyomi.data.preference.getOrDefault -import eu.kanade.tachiyomi.util.inflate import eu.kanade.tachiyomi.util.plusAssign +import eu.kanade.tachiyomi.widget.CustomLayoutPickerActivity import net.xpece.android.support.preference.MultiSelectListPreference import uy.kohesive.injekt.injectLazy import java.io.File @@ -151,27 +146,4 @@ class SettingsDownloadsFragment : SettingsFragment() { } } } - - class CustomLayoutPickerActivity : FilePickerActivity() { - - override fun getFragment(startPath: String?, mode: Int, allowMultiple: Boolean, allowCreateDir: Boolean): - AbstractFilePickerFragment { - val fragment = CustomLayoutFilePickerFragment() - fragment.setArgs(startPath, mode, allowMultiple, allowCreateDir) - return fragment - } - } - - class CustomLayoutFilePickerFragment : FilePickerFragment() { - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { - when (viewType) { - LogicHandler.VIEWTYPE_DIR -> { - val view = parent.inflate(R.layout.listitem_dir) - return DirViewHolder(view) - } - else -> return super.onCreateViewHolder(parent, viewType) - } - } - } - } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsFragment.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsFragment.kt index 9db02ad001..291db41798 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsFragment.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsFragment.kt @@ -29,6 +29,7 @@ open class SettingsFragment : XpPreferenceFragment() { addPreferencesFromResource(R.xml.pref_downloads) addPreferencesFromResource(R.xml.pref_sources) addPreferencesFromResource(R.xml.pref_tracking) + addPreferencesFromResource(R.xml.pref_backup) addPreferencesFromResource(R.xml.pref_advanced) addPreferencesFromResource(R.xml.pref_about) diff --git a/app/src/main/java/eu/kanade/tachiyomi/util/FileExtensions.kt b/app/src/main/java/eu/kanade/tachiyomi/util/FileExtensions.kt index 7b208c6081..dd1f574c38 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/util/FileExtensions.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/util/FileExtensions.kt @@ -19,15 +19,3 @@ fun File.getUriCompat(context: Context): Uri { return uri } -/** - * Deletes file if exists - * - * @return success of file deletion - */ -fun File.deleteIfExists(): Boolean { - if (this.exists()) { - this.delete() - return true - } - return false -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/widget/CustomLayoutPicker.kt b/app/src/main/java/eu/kanade/tachiyomi/widget/CustomLayoutPicker.kt new file mode 100644 index 0000000000..79eb16ac96 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/widget/CustomLayoutPicker.kt @@ -0,0 +1,33 @@ +package eu.kanade.tachiyomi.widget + +import android.support.v7.widget.RecyclerView +import android.view.ViewGroup +import com.nononsenseapps.filepicker.AbstractFilePickerFragment +import com.nononsenseapps.filepicker.FilePickerActivity +import com.nononsenseapps.filepicker.FilePickerFragment +import com.nononsenseapps.filepicker.LogicHandler +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.util.inflate +import java.io.File + +class CustomLayoutPickerActivity : FilePickerActivity() { + + override fun getFragment(startPath: String?, mode: Int, allowMultiple: Boolean, allowCreateDir: Boolean): + AbstractFilePickerFragment { + val fragment = CustomLayoutFilePickerFragment() + fragment.setArgs(startPath, mode, allowMultiple, allowCreateDir) + return fragment + } +} + +class CustomLayoutFilePickerFragment : FilePickerFragment() { + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { + when (viewType) { + LogicHandler.VIEWTYPE_DIR -> { + val view = parent.inflate(R.layout.listitem_dir) + return DirViewHolder(view) + } + else -> return super.onCreateViewHolder(parent, viewType) + } + } +} \ No newline at end of file diff --git a/app/src/main/res/menu/menu_navigation.xml b/app/src/main/res/menu/menu_navigation.xml index e069a295ca..06e2bd68e1 100644 --- a/app/src/main/res/menu/menu_navigation.xml +++ b/app/src/main/res/menu/menu_navigation.xml @@ -36,9 +36,5 @@ android:icon="@drawable/ic_settings_black_24dp" android:title="@string/label_settings" android:checkable="false" /> - diff --git a/app/src/main/res/values/arrays.xml b/app/src/main/res/values/arrays.xml index f7df692ef2..72139c01e0 100644 --- a/app/src/main/res/values/arrays.xml +++ b/app/src/main/res/values/arrays.xml @@ -48,6 +48,14 @@ 3 + + 1 + 2 + 3 + 4 + 5 + + @string/disabled @string/last_read_chapter @@ -146,6 +154,24 @@ 48 + + @string/update_never + @string/update_6hour + @string/update_12hour + @string/update_24hour + @string/update_48hour + @string/update_weekly + + + + 0 + 6 + 12 + 24 + 48 + 168 + + @string/wifi @string/charging @@ -188,6 +214,22 @@ 2 + + @string/manga + @string/categories + @string/chapters + @string/track + @string/history + + + + 0 + 1 + 2 + 3 + 4 + + bg diff --git a/app/src/main/res/values/keys.xml b/app/src/main/res/values/keys.xml index ff4ce8afd7..2dbbb39f67 100644 --- a/app/src/main/res/values/keys.xml +++ b/app/src/main/res/values/keys.xml @@ -52,6 +52,12 @@ pref_remove_after_marked_as_read_key last_used_category + create_local_backup + restore_local_backup + backup_interval + backup_directory + backup_slots + source_languages category_tracking_accounts @@ -73,4 +79,4 @@ sans-serif sans-serif - \ No newline at end of file + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 93b31880d1..c54758889e 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,7 +1,13 @@ Tachiyomi + Name + Categories + Manga + Chapters + Tracking + History Settings @@ -53,11 +59,13 @@ Stop Pause Clear + Close Previous chapter Next chapter Retry Remove Resume + Move Open in browser Add to home screen Change display mode @@ -72,6 +80,10 @@ Save Reset Undo + Export + Open log + Create + Restore Deleting… @@ -101,6 +113,8 @@ Every 12 hours Daily Every 2 days + Weekly + Monthly Categories to include in global update All Library update restrictions @@ -181,6 +195,29 @@ Services + + Backup + Create backup + Can be used to restore current library + Restore backup + Restore library from backup file + Backup directory + Service + Backup frequency + Max automatic backups + Restoring backup\n%1$s added to library + Source not found + Restoring backup\n%1$s source not found + Backup created + Restore completed + Could not open log + Restore took %1$s.\n%2$s errors found. + Restore uses source to fetch data, carrier costs may apply.\nAlso make sure you are properly logged in sources that require so before restoring. + File saved at %1$s + What do you want to backup? + Restoring backup + Creating backup + Clear chapter cache Used: %1$s @@ -290,7 +327,6 @@ Score Title Status - Chapters A category with this name already exists! @@ -324,13 +360,6 @@ Do you want to set this image as the cover? Viewer for this series - - Backup - Restore - Backup in progress. Please wait… - Backup successfully restored - Restoring backup. Please wait… - %1$s - Ch.%2$s diff --git a/app/src/main/res/xml/pref_backup.xml b/app/src/main/res/xml/pref_backup.xml new file mode 100644 index 0000000000..e1b42cb0b0 --- /dev/null +++ b/app/src/main/res/xml/pref_backup.xml @@ -0,0 +1,48 @@ + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/test/java/eu/kanade/tachiyomi/data/backup/BackupTest.kt b/app/src/test/java/eu/kanade/tachiyomi/data/backup/BackupTest.kt index 70b29e2524..481ae4fe76 100644 --- a/app/src/test/java/eu/kanade/tachiyomi/data/backup/BackupTest.kt +++ b/app/src/test/java/eu/kanade/tachiyomi/data/backup/BackupTest.kt @@ -1,568 +1,412 @@ package eu.kanade.tachiyomi.data.backup +import android.app.Application +import android.content.Context import android.os.Build -import com.google.gson.Gson -import com.google.gson.JsonElement +import com.github.salomonbrys.kotson.fromJson +import com.google.gson.JsonArray import com.google.gson.JsonObject import eu.kanade.tachiyomi.BuildConfig import eu.kanade.tachiyomi.CustomRobolectricGradleTestRunner +import eu.kanade.tachiyomi.data.backup.models.Backup +import eu.kanade.tachiyomi.data.backup.models.DHistory import eu.kanade.tachiyomi.data.database.DatabaseHelper import eu.kanade.tachiyomi.data.database.models.* +import eu.kanade.tachiyomi.source.SourceManager +import eu.kanade.tachiyomi.source.online.HttpSource import org.assertj.core.api.Assertions.assertThat import org.junit.Before import org.junit.Test import org.junit.runner.RunWith +import org.mockito.Mockito +import org.mockito.Mockito.* import org.robolectric.RuntimeEnvironment import org.robolectric.annotation.Config -import uy.kohesive.injekt.injectLazy -import java.util.* +import rx.Observable +import rx.observers.TestSubscriber +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.InjektModule +import uy.kohesive.injekt.api.InjektRegistrar +import uy.kohesive.injekt.api.addSingleton +/** + * Test class for the [BackupManager]. + * Note that this does not include the backup create/restore services. + */ @Config(constants = BuildConfig::class, sdk = intArrayOf(Build.VERSION_CODES.LOLLIPOP)) @RunWith(CustomRobolectricGradleTestRunner::class) class BackupTest { + // Create root object + var root = JsonObject() - val gson: Gson by injectLazy() + // Create information object + var information = JsonObject() - lateinit var db: DatabaseHelper + // Create manga array + var mangaEntries = JsonArray() + + // Create category array + var categoryEntries = JsonArray() + + lateinit var app: Application + lateinit var context: Context + lateinit var source: HttpSource lateinit var backupManager: BackupManager - lateinit var root: JsonObject + lateinit var db: DatabaseHelper @Before fun setup() { - val app = RuntimeEnvironment.application - db = DatabaseHelper(app) - backupManager = BackupManager(db) - root = JsonObject() - } - - @Test - fun testRestoreCategory() { - val catName = "cat" - root = createRootJson(null, toJson(createCategories(catName))) - backupManager.restoreFromJson(root) - - val dbCats = db.getCategories().executeAsBlocking() - assertThat(dbCats).hasSize(1) - assertThat(dbCats[0].name).isEqualTo(catName) + app = RuntimeEnvironment.application + context = app.applicationContext + backupManager = BackupManager(context) + db = backupManager.databaseHelper + + // Mock the source manager + val module = object : InjektModule { + override fun InjektRegistrar.registerInjectables() { + addSingleton(Mockito.mock(SourceManager::class.java, RETURNS_DEEP_STUBS)) + } + } + Injekt.importModule(module) + + source = mock(HttpSource::class.java) + `when`(backupManager.sourceManager.get(anyLong())).thenReturn(source) + + root.add(Backup.MANGAS, mangaEntries) + root.add(Backup.CATEGORIES, categoryEntries) } + /** + * Test that checks if no crashes when no categories in library. + */ @Test fun testRestoreEmptyCategory() { - root = createRootJson(null, toJson(ArrayList())) - backupManager.restoreFromJson(root) + // Initialize json with version 2 + initializeJsonTest(2) + + // Create backup of empty database + backupManager.backupCategories(categoryEntries) + + // Restore Json + backupManager.restoreCategories(categoryEntries) + + // Check if empty val dbCats = db.getCategories().executeAsBlocking() assertThat(dbCats).isEmpty() } + /** + * Test to check if single category gets restored + */ @Test - fun testRestoreExistingCategory() { - val catName = "cat" - db.insertCategory(createCategory(catName)).executeAsBlocking() + fun testRestoreSingleCategory() { + // Initialize json with version 2 + initializeJsonTest(2) - root = createRootJson(null, toJson(createCategories(catName))) - backupManager.restoreFromJson(root) + // Create category and add to json + val category = addSingleCategory("category") - val dbCats = db.getCategories().executeAsBlocking() + // Restore Json + backupManager.restoreCategories(categoryEntries) + + // Check if successful + val dbCats = backupManager.databaseHelper.getCategories().executeAsBlocking() assertThat(dbCats).hasSize(1) - assertThat(dbCats[0].name).isEqualTo(catName) + assertThat(dbCats[0].name).isEqualTo(category.name) } + /** + * Test to check if multiple categories get restored. + */ @Test - fun testRestoreCategories() { - root = createRootJson(null, toJson(createCategories("cat", "cat2", "cat3"))) - backupManager.restoreFromJson(root) + fun testRestoreMultipleCategories() { + // Initialize json with version 2 + initializeJsonTest(2) - val dbCats = db.getCategories().executeAsBlocking() - assertThat(dbCats).hasSize(3) - } - - @Test - fun testRestoreExistingCategories() { - db.insertCategories(createCategories("cat", "cat2")).executeAsBlocking() - - root = createRootJson(null, toJson(createCategories("cat", "cat2", "cat3"))) - backupManager.restoreFromJson(root) - - val dbCats = db.getCategories().executeAsBlocking() - assertThat(dbCats).hasSize(3) - } - - @Test - fun testRestoreExistingCategoriesAlt() { - db.insertCategories(createCategories("cat", "cat2", "cat3")).executeAsBlocking() - - root = createRootJson(null, toJson(createCategories("cat", "cat2"))) - backupManager.restoreFromJson(root) - - val dbCats = db.getCategories().executeAsBlocking() - assertThat(dbCats).hasSize(3) + // Create category and add to json + val category = addSingleCategory("category") + val category2 = addSingleCategory("category2") + val category3 = addSingleCategory("category3") + val category4 = addSingleCategory("category4") + val category5 = addSingleCategory("category5") + + // Insert category to test if no duplicates on restore. + db.insertCategory(category).executeAsBlocking() + + // Restore Json + backupManager.restoreCategories(categoryEntries) + + // Check if successful + val dbCats = backupManager.databaseHelper.getCategories().executeAsBlocking() + assertThat(dbCats).hasSize(5) + assertThat(dbCats[0].name).isEqualTo(category.name) + assertThat(dbCats[1].name).isEqualTo(category2.name) + assertThat(dbCats[2].name).isEqualTo(category3.name) + assertThat(dbCats[3].name).isEqualTo(category4.name) + assertThat(dbCats[4].name).isEqualTo(category5.name) } + /** + * Test if restore of manga is successful + */ @Test fun testRestoreManga() { - val mangaName = "title" - val mangas = createMangas(mangaName) - val elements = ArrayList() - for (manga in mangas) { - val entry = JsonObject() - entry.add("manga", toJson(manga)) - elements.add(entry) - } - root = createRootJson(toJson(elements), null) - backupManager.restoreFromJson(root) + // Initialize json with version 2 + initializeJsonTest(2) - val dbMangas = db.getMangas().executeAsBlocking() - assertThat(dbMangas).hasSize(1) - assertThat(dbMangas[0].title).isEqualTo(mangaName) + // Add manga to database + val manga = getSingleManga("One Piece") + manga.viewer = 3 + manga.id = db.insertManga(manga).executeAsBlocking().insertedId() + + var favoriteManga = backupManager.databaseHelper.getFavoriteMangas().executeAsBlocking() + assertThat(favoriteManga).hasSize(1) + assertThat(favoriteManga[0].viewer).isEqualTo(3) + + // Update json with all options enabled + mangaEntries.add(backupManager.backupMangaObject(manga,1)) + + // Change manga in database to default values + val dbManga = getSingleManga("One Piece") + dbManga.id = manga.id + db.insertManga(dbManga).executeAsBlocking() + + favoriteManga = backupManager.databaseHelper.getFavoriteMangas().executeAsBlocking() + assertThat(favoriteManga).hasSize(1) + assertThat(favoriteManga[0].viewer).isEqualTo(0) + + // Restore local manga + backupManager.restoreMangaNoFetch(manga,dbManga) + + // Test if restore successful + favoriteManga = backupManager.databaseHelper.getFavoriteMangas().executeAsBlocking() + assertThat(favoriteManga).hasSize(1) + assertThat(favoriteManga[0].viewer).isEqualTo(3) + + // Clear database to test manga fetch + clearDatabase() + + // Test if successful + favoriteManga = backupManager.databaseHelper.getFavoriteMangas().executeAsBlocking() + assertThat(favoriteManga).hasSize(0) + + // Restore Json + // Create JSON from manga to test parser + val json = backupManager.parser.toJsonTree(manga) + // Restore JSON from manga to test parser + val jsonManga = backupManager.parser.fromJson(json) + + // Restore manga with fetch observable + val networkManga = getSingleManga("One Piece") + networkManga.description = "This is a description" + `when`(source.fetchMangaDetails(jsonManga)).thenReturn(Observable.just(networkManga)) + + val obs = backupManager.restoreMangaFetchObservable(source, jsonManga) + val testSubscriber = TestSubscriber() + obs.subscribe(testSubscriber) + + testSubscriber.assertNoErrors() + + // Check if restore successful + val dbCats = backupManager.databaseHelper.getFavoriteMangas().executeAsBlocking() + assertThat(dbCats).hasSize(1) + assertThat(dbCats[0].viewer).isEqualTo(3) + assertThat(dbCats[0].description).isEqualTo("This is a description") } + /** + * Test if chapter restore is successful + */ @Test - fun testRestoreExistingManga() { - val mangaName = "title" - val manga = createManga(mangaName) + fun testRestoreChapters() { + // Initialize json with version 2 + initializeJsonTest(2) - db.insertManga(manga).executeAsBlocking() + // Insert manga + val manga = getSingleManga("One Piece") + manga.id = backupManager.databaseHelper.insertManga(manga).executeAsBlocking().insertedId() - val elements = ArrayList() - val entry = JsonObject() - entry.add("manga", toJson(manga)) - elements.add(entry) - root = createRootJson(toJson(elements), null) - backupManager.restoreFromJson(root) - - val dbMangas = db.getMangas().executeAsBlocking() - assertThat(dbMangas).hasSize(1) - } - - @Test - fun testRestoreExistingMangaWithUpdatedFields() { - // Store a manga in db - val mangaName = "title" - val updatedThumbnailUrl = "updated thumbnail url" - var manga = createManga(mangaName) - manga.chapter_flags = 1024 - manga.thumbnail_url = updatedThumbnailUrl - db.insertManga(manga).executeAsBlocking() - - // Add an entry for a new manga with different attributes - manga = createManga(mangaName) - manga.chapter_flags = 512 - val entry = JsonObject() - entry.add("manga", toJson(manga)) - - // Append the entry to the backup list - val elements = ArrayList() - elements.add(entry) - - // Restore from json - root = createRootJson(toJson(elements), null) - backupManager.restoreFromJson(root) - - val dbMangas = db.getMangas().executeAsBlocking() - assertThat(dbMangas).hasSize(1) - assertThat(dbMangas[0].thumbnail_url).isEqualTo(updatedThumbnailUrl) - assertThat(dbMangas[0].chapter_flags).isEqualTo(512) - } - - @Test - fun testRestoreChaptersForManga() { - // Create a manga and 3 chapters - val manga = createManga("title") - manga.id = 1L - val chapters = createChapters(manga, "1", "2", "3") - - // Add an entry for the manga - val entry = JsonObject() - entry.add("manga", toJson(manga)) - entry.add("chapters", toJson(chapters)) - - // Append the entry to the backup list - val mangas = ArrayList() - mangas.add(entry) - - // Restore from json - root = createRootJson(toJson(mangas), null) - backupManager.restoreFromJson(root) - - val dbManga = db.getManga(1).executeAsBlocking() - assertThat(dbManga).isNotNull() - - val dbChapters = db.getChapters(dbManga!!).executeAsBlocking() - assertThat(dbChapters).hasSize(3) - } - - @Test - fun testRestoreChaptersForExistingManga() { - val mangaId: Long = 3 - // Create a manga and 3 chapters - val manga = createManga("title") - manga.id = mangaId - val chapters = createChapters(manga, "1", "2", "3") - db.insertManga(manga).executeAsBlocking() - - // Add an entry for the manga - val entry = JsonObject() - entry.add("manga", toJson(manga)) - entry.add("chapters", toJson(chapters)) - - // Append the entry to the backup list - val mangas = ArrayList() - mangas.add(entry) - - // Restore from json - root = createRootJson(toJson(mangas), null) - backupManager.restoreFromJson(root) - - val dbManga = db.getManga(mangaId).executeAsBlocking() - assertThat(dbManga).isNotNull() - - val dbChapters = db.getChapters(dbManga!!).executeAsBlocking() - assertThat(dbChapters).hasSize(3) - } - - @Test - fun testRestoreExistingChaptersForExistingManga() { - val mangaId: Long = 5 - // Store a manga and 3 chapters - val manga = createManga("title") - manga.id = mangaId - var chapters = createChapters(manga, "1", "2", "3") - db.insertManga(manga).executeAsBlocking() - db.insertChapters(chapters).executeAsBlocking() - - // The backup contains a existing chapter and a new one, so it should have 4 chapters - chapters = createChapters(manga, "3", "4") - - // Add an entry for the manga - val entry = JsonObject() - entry.add("manga", toJson(manga)) - entry.add("chapters", toJson(chapters)) - - // Append the entry to the backup list - val mangas = ArrayList() - mangas.add(entry) - - // Restore from json - root = createRootJson(toJson(mangas), null) - backupManager.restoreFromJson(root) - - val dbManga = db.getManga(mangaId).executeAsBlocking() - assertThat(dbManga).isNotNull() - - val dbChapters = db.getChapters(dbManga!!).executeAsBlocking() - assertThat(dbChapters).hasSize(4) - } - - @Test - fun testRestoreCategoriesForManga() { - // Create a manga - val manga = createManga("title") - - // Create categories - val categories = createCategories("cat1", "cat2", "cat3") - - // Add an entry for the manga - val entry = JsonObject() - entry.add("manga", toJson(manga)) - entry.add("categories", toJson(createStringCategories("cat1"))) - - // Append the entry to the backup list - val mangas = ArrayList() - mangas.add(entry) - - // Restore from json - root = createRootJson(toJson(mangas), toJson(categories)) - backupManager.restoreFromJson(root) - - val dbManga = db.getManga(1).executeAsBlocking() - assertThat(dbManga).isNotNull() - - val result = db.getCategoriesForManga(dbManga!!).executeAsBlocking() - - assertThat(result).hasSize(1) - assertThat(result).contains(Category.create("cat1")) - assertThat(result).doesNotContain(Category.create("cat2")) - } - - @Test - fun testRestoreCategoriesForExistingManga() { - // Store a manga - val manga = createManga("title") - db.insertManga(manga).executeAsBlocking() - - // Create categories - val categories = createCategories("cat1", "cat2", "cat3") - - // Add an entry for the manga - val entry = JsonObject() - entry.add("manga", toJson(manga)) - entry.add("categories", toJson(createStringCategories("cat1"))) - - // Append the entry to the backup list - val mangas = ArrayList() - mangas.add(entry) - - // Restore from json - root = createRootJson(toJson(mangas), toJson(categories)) - backupManager.restoreFromJson(root) - - val dbManga = db.getManga(1).executeAsBlocking() - assertThat(dbManga).isNotNull() - - val result = db.getCategoriesForManga(dbManga!!).executeAsBlocking() - - assertThat(result).hasSize(1) - assertThat(result).contains(Category.create("cat1")) - assertThat(result).doesNotContain(Category.create("cat2")) - } - - @Test - fun testRestoreMultipleCategoriesForManga() { - // Create a manga - val manga = createManga("title") - - // Create categories - val categories = createCategories("cat1", "cat2", "cat3") - - // Add an entry for the manga - val entry = JsonObject() - entry.add("manga", toJson(manga)) - entry.add("categories", toJson(createStringCategories("cat1", "cat3"))) - - // Append the entry to the backup list - val mangas = ArrayList() - mangas.add(entry) - - // Restore from json - root = createRootJson(toJson(mangas), toJson(categories)) - backupManager.restoreFromJson(root) - - val dbManga = db.getManga(1).executeAsBlocking() - assertThat(dbManga).isNotNull() - - val result = db.getCategoriesForManga(dbManga!!).executeAsBlocking() - - assertThat(result).hasSize(2) - assertThat(result).contains(Category.create("cat1"), Category.create("cat3")) - assertThat(result).doesNotContain(Category.create("cat2")) - } - - @Test - fun testRestoreMultipleCategoriesForExistingMangaAndCategory() { - // Store a manga and a category - val manga = createManga("title") - manga.id = 1L - db.insertManga(manga).executeAsBlocking() - - val cat = createCategory("cat1") - cat.id = 1 - db.insertCategory(cat).executeAsBlocking() - db.insertMangaCategory(MangaCategory.create(manga, cat)).executeAsBlocking() - - // Create categories - val categories = createCategories("cat1", "cat2", "cat3") - - // Add an entry for the manga - val entry = JsonObject() - entry.add("manga", toJson(manga)) - entry.add("categories", toJson(createStringCategories("cat1", "cat2"))) - - // Append the entry to the backup list - val mangas = ArrayList() - mangas.add(entry) - - // Restore from json - root = createRootJson(toJson(mangas), toJson(categories)) - backupManager.restoreFromJson(root) - - val dbManga = db.getManga(1).executeAsBlocking() - assertThat(dbManga).isNotNull() - - val result = db.getCategoriesForManga(dbManga!!).executeAsBlocking() - - assertThat(result).hasSize(2) - assertThat(result).contains(Category.create("cat1"), Category.create("cat2")) - assertThat(result).doesNotContain(Category.create("cat3")) - } - - @Test - fun testRestoreSyncForManga() { - // Create a manga and track - val manga = createManga("title") - manga.id = 1L - - val track = createTrack(manga, 1, 2, 3) - - // Add an entry for the manga - val entry = JsonObject() - entry.add("manga", toJson(manga)) - entry.add("sync", toJson(track)) - - // Append the entry to the backup list - val mangas = ArrayList() - mangas.add(entry) - - // Restore from json - root = createRootJson(toJson(mangas), null) - backupManager.restoreFromJson(root) - - val dbManga = db.getManga(1).executeAsBlocking() - assertThat(dbManga).isNotNull() - - val dbSync = db.getTracks(dbManga!!).executeAsBlocking() - assertThat(dbSync).hasSize(3) - } - - @Test - fun testRestoreSyncForExistingManga() { - val mangaId: Long = 3 - // Create a manga and 3 sync - val manga = createManga("title") - manga.id = mangaId - val track = createTrack(manga, 1, 2, 3) - db.insertManga(manga).executeAsBlocking() - - // Add an entry for the manga - val entry = JsonObject() - entry.add("manga", toJson(manga)) - entry.add("sync", toJson(track)) - - // Append the entry to the backup list - val mangas = ArrayList() - mangas.add(entry) - - // Restore from json - root = createRootJson(toJson(mangas), null) - backupManager.restoreFromJson(root) - - val dbManga = db.getManga(mangaId).executeAsBlocking() - assertThat(dbManga).isNotNull() - - val dbSync = db.getTracks(dbManga!!).executeAsBlocking() - assertThat(dbSync).hasSize(3) - } - - @Test - fun testRestoreExistingSyncForExistingManga() { - val mangaId: Long = 5 - // Store a manga and 3 sync - val manga = createManga("title") - manga.id = mangaId - var track = createTrack(manga, 1, 2, 3) - db.insertManga(manga).executeAsBlocking() - db.insertTracks(track).executeAsBlocking() - - // The backup contains a existing sync and a new one, so it should have 4 sync - track = createTrack(manga, 3, 4) - - // Add an entry for the manga - val entry = JsonObject() - entry.add("manga", toJson(manga)) - entry.add("sync", toJson(track)) - - // Append the entry to the backup list - val mangas = ArrayList() - mangas.add(entry) - - // Restore from json - root = createRootJson(toJson(mangas), null) - backupManager.restoreFromJson(root) - - val dbManga = db.getManga(mangaId).executeAsBlocking() - assertThat(dbManga).isNotNull() - - val dbSync = db.getTracks(dbManga!!).executeAsBlocking() - assertThat(dbSync).hasSize(4) - } - - private fun createRootJson(mangas: JsonElement?, categories: JsonElement?): JsonObject { - val root = JsonObject() - if (mangas != null) - root.add("mangas", mangas) - if (categories != null) - root.add("categories", categories) - return root - } - - private fun createCategory(name: String): Category { - val c = CategoryImpl() - c.name = name - return c - } - - private fun createCategories(vararg names: String): List { - val cats = ArrayList() - for (name in names) { - cats.add(createCategory(name)) - } - return cats - } - - private fun createStringCategories(vararg names: String): List { - val cats = ArrayList() - for (name in names) { - cats.add(name) - } - return cats - } - - private fun createManga(title: String): Manga { - val m = Manga.create(1) - m.title = title - m.author = "" - m.artist = "" - m.thumbnail_url = "" - m.genre = "a list of genres" - m.description = "long description" - m.url = "url to manga" - m.favorite = true - return m - } - - private fun createMangas(vararg titles: String): List { - val mangas = ArrayList() - for (title in titles) { - mangas.add(createManga(title)) - } - return mangas - } - - private fun createChapter(manga: Manga, url: String): Chapter { - val c = Chapter.create() - c.url = url - c.name = url - c.manga_id = manga.id - return c - } - - private fun createChapters(manga: Manga, vararg urls: String): List { + // Create restore list val chapters = ArrayList() - for (url in urls) { - chapters.add(createChapter(manga, url)) + for (i in 1..8){ + val chapter = getSingleChapter("Chapter $i") + chapter.read = true + chapters.add(chapter) } - return chapters + + // Check parser + val chaptersJson = backupManager.parser.toJsonTree(chapters) + val restoredChapters = backupManager.parser.fromJson>(chaptersJson) + + // Fetch chapters from upstream + // Create list + val chaptersRemote = ArrayList() + (1..10).mapTo(chaptersRemote) { getSingleChapter("Chapter $it") } + `when`(source.fetchChapterList(manga)).thenReturn(Observable.just(chaptersRemote)) + + // Call restoreChapterFetchObservable + val obs = backupManager.restoreChapterFetchObservable(source, manga, restoredChapters) + val testSubscriber = TestSubscriber, List>>() + obs.subscribe(testSubscriber) + + testSubscriber.assertNoErrors() + + val dbCats = backupManager.databaseHelper.getChapters(manga).executeAsBlocking() + assertThat(dbCats).hasSize(10) + assertThat(dbCats[0].read).isEqualTo(true) } - private fun createTrack(manga: Manga, syncId: Int): Track { - val m = Track.create(syncId) - m.manga_id = manga.id!! - m.title = "title" - return m + /** + * Test to check if history restore works + */ + @Test + fun restoreHistoryForManga(){ + // Initialize json with version 2 + initializeJsonTest(2) + + val manga = getSingleManga("One Piece") + manga.id = backupManager.databaseHelper.insertManga(manga).executeAsBlocking().insertedId() + + // Create chapter + val chapter = getSingleChapter("Chapter 1") + chapter.manga_id = manga.id + chapter.read = true + chapter.id = backupManager.databaseHelper.insertChapter(chapter).executeAsBlocking().insertedId() + + val historyJson = getSingleHistory(chapter) + + val historyList = ArrayList() + historyList.add(historyJson) + + // Check parser + val historyListJson = backupManager.parser.toJsonTree(historyList) + val history = backupManager.parser.fromJson>(historyListJson) + + // Restore categories + backupManager.restoreHistoryForManga(history) + + val historyDB = backupManager.databaseHelper.getHistoryByMangaId(manga.id!!).executeAsBlocking() + assertThat(historyDB).hasSize(1) + assertThat(historyDB[0].last_read).isEqualTo(1000) } - private fun createTrack(manga: Manga, vararg syncIds: Int): List { - val ms = ArrayList() - for (title in syncIds) { - ms.add(createTrack(manga, title)) - } - return ms + /** + * Test to check if tracking restore works + */ + @Test + fun restoreTrackForManga() { + // Initialize json with version 2 + initializeJsonTest(2) + + // Create mangas + val manga = getSingleManga("One Piece") + val manga2 = getSingleManga("Bleach") + manga.id = backupManager.databaseHelper.insertManga(manga).executeAsBlocking().insertedId() + manga2.id = backupManager.databaseHelper.insertManga(manga2).executeAsBlocking().insertedId() + + // Create track and add it to database + // This tests duplicate errors. + val track = getSingleTrack(manga) + track.last_chapter_read = 5 + backupManager.databaseHelper.insertTrack(track).executeAsBlocking() + var trackDB = backupManager.databaseHelper.getTracks(manga).executeAsBlocking() + assertThat(trackDB).hasSize(1) + assertThat(trackDB[0].last_chapter_read).isEqualTo(5) + track.last_chapter_read = 7 + + // Create track for different manga to test track not in database + val track2 = getSingleTrack(manga2) + track2.last_chapter_read = 10 + + // Check parser and restore already in database + var trackList = listOf(track) + //Check parser + var trackListJson = backupManager.parser.toJsonTree(trackList) + var trackListRestore = backupManager.parser.fromJson>(trackListJson) + backupManager.restoreTrackForManga(manga, trackListRestore) + + // Assert if restore works. + trackDB = backupManager.databaseHelper.getTracks(manga).executeAsBlocking() + assertThat(trackDB).hasSize(1) + assertThat(trackDB[0].last_chapter_read).isEqualTo(7) + + // Check parser and restore already in database with lower chapter_read + track.last_chapter_read = 5 + trackList = listOf(track) + backupManager.restoreTrackForManga(manga, trackList) + + // Assert if restore works. + trackDB = backupManager.databaseHelper.getTracks(manga).executeAsBlocking() + assertThat(trackDB).hasSize(1) + assertThat(trackDB[0].last_chapter_read).isEqualTo(7) + + // Check parser and restore, track not in database + trackList = listOf(track2) + + //Check parser + trackListJson = backupManager.parser.toJsonTree(trackList) + trackListRestore = backupManager.parser.fromJson>(trackListJson) + backupManager.restoreTrackForManga(manga2, trackListRestore) + + // Assert if restore works. + trackDB = backupManager.databaseHelper.getTracks(manga2).executeAsBlocking() + assertThat(trackDB).hasSize(1) + assertThat(trackDB[0].last_chapter_read).isEqualTo(10) } - private fun toJson(element: Any): JsonElement { - return gson.toJsonTree(element) + fun clearJson() { + root = JsonObject() + information = JsonObject() + mangaEntries = JsonArray() + categoryEntries = JsonArray() } + fun initializeJsonTest(version: Int) { + clearJson() + backupManager.setVersion(version) + } + + fun addSingleCategory(name: String): Category { + val category = Category.create(name) + val catJson = backupManager.parser.toJsonTree(category) + categoryEntries.add(catJson) + return category + } + + fun clearDatabase(){ + db.deleteMangas().executeAsBlocking() + db.deleteHistory().executeAsBlocking() + } + + fun getSingleHistory(chapter: Chapter): DHistory { + return DHistory(chapter.url, 1000) + } + + private fun getSingleTrack(manga: Manga): TrackImpl { + val track = TrackImpl() + track.title = manga.title + track.manga_id = manga.id!! + track.remote_id = 1 + track.sync_id = 1 + return track + } + + private fun getSingleManga(title: String): MangaImpl { + val manga = MangaImpl() + manga.source = 1 + manga.title = title + manga.url = "/manga/$title" + manga.favorite = true + return manga + } + + private fun getSingleChapter(name: String): ChapterImpl { + val chapter = ChapterImpl() + chapter.name = name + chapter.url = "/read-online/$name-page-1.html" + return chapter + } } \ No newline at end of file