mirror of
				https://github.com/mihonapp/mihon.git
				synced 2025-10-30 22:07:57 +01:00 
			
		
		
		
	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
This commit is contained in:
		
				
					committed by
					
						 inorichi
						inorichi
					
				
			
			
				
	
			
			
			
						parent
						
							3094d084d6
						
					
				
				
					commit
					0642889b64
				
			| @@ -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 | ||||
|             } | ||||
|         } | ||||
|   | ||||
| @@ -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) | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,41 @@ | ||||
| package eu.kanade.tachiyomi.data.backup | ||||
|  | ||||
| import com.evernote.android.job.Job | ||||
| import com.evernote.android.job.JobManager | ||||
| import com.evernote.android.job.JobRequest | ||||
| import eu.kanade.tachiyomi.data.preference.PreferencesHelper | ||||
| import eu.kanade.tachiyomi.data.preference.getOrDefault | ||||
| import uy.kohesive.injekt.Injekt | ||||
| import uy.kohesive.injekt.api.get | ||||
|  | ||||
| class BackupCreatorJob : Job() { | ||||
|  | ||||
|     override fun onRunJob(params: Params): Result { | ||||
|         val preferences = Injekt.get<PreferencesHelper>() | ||||
|         val path = preferences.backupsDirectory().getOrDefault() | ||||
|         val flags = BackupCreateService.BACKUP_ALL | ||||
|         BackupCreateService.makeBackup(context,path,flags,true) | ||||
|         return Result.SUCCESS | ||||
|     } | ||||
|  | ||||
|     companion object { | ||||
|         const val TAG = "BackupCreator" | ||||
|  | ||||
|         fun setupTask(prefInterval: Int? = null) { | ||||
|             val preferences = Injekt.get<PreferencesHelper>() | ||||
|             val interval = prefInterval ?: preferences.backupInterval().getOrDefault() | ||||
|             if (interval > 0) { | ||||
|                 JobRequest.Builder(TAG) | ||||
|                         .setPeriodic(interval * 60 * 60 * 1000L, 10 * 60 * 1000) | ||||
|                         .setPersisted(true) | ||||
|                         .setUpdateCurrent(true) | ||||
|                         .build() | ||||
|                         .schedule() | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         fun cancelTask() { | ||||
|             JobManager.instance().cancelAllForTag(TAG) | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -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<MangaImpl>(MangaTypeAdapter.build()) | ||||
|                     .registerTypeHierarchyAdapter<ChapterImpl>(ChapterTypeAdapter.build()) | ||||
|                     .registerTypeAdapter<CategoryImpl>(CategoryTypeAdapter.build()) | ||||
|                     .registerTypeAdapter<DHistory>(HistoryTypeAdapter.build()) | ||||
|                     .registerTypeHierarchyAdapter<TrackImpl>(TrackTypeAdapter.build()) | ||||
|                     .create() | ||||
|             else -> throw Exception("Json version unknown") | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * 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<String>() | ||||
|             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<Manga> { | ||||
|         return source.fetchMangaDetails(manga) | ||||
|                 .map { networkManga -> | ||||
|                     manga.copyFrom(networkManga) | ||||
|                     manga.favorite = true | ||||
|                     manga.initialized = true | ||||
|                     manga.id = insertManga(manga) | ||||
|                     manga | ||||
|                 } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Restores a backup from 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<Chapter>): Observable<Pair<List<Chapter>, List<Chapter>>> { | ||||
|         return source.fetchChapterList(manga) | ||||
|                 .map { syncChaptersWithSource(databaseHelper, it, manga, source) } | ||||
|                 .doOnNext { | ||||
|                     if (it.first.isNotEmpty()) { | ||||
|                         chapters.forEach { it.manga_id = manga.id } | ||||
|                         insertChapters(chapters) | ||||
|                     } | ||||
|                 } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Restores 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<List<CategoryImpl>>(jsonCategories) | ||||
|         val dbCategories = databaseHelper.getCategories().executeAsBlocking() | ||||
|         val backupCategories = parser.fromJson<List<CategoryImpl>>(jsonCategories) | ||||
|  | ||||
|         // Iterate over them | ||||
|         for (category in backupCategories) { | ||||
|         backupCategories.forEach { category -> | ||||
|             // Used to know if the category is already in the db | ||||
|             var found = false | ||||
|             for (dbCategory in dbCategories) { | ||||
| @@ -214,102 +224,20 @@ class BackupManager(private val db: DatabaseHelper) { | ||||
|             if (!found) { | ||||
|                 // Let the db assign the id | ||||
|                 category.id = null | ||||
|                 val result = db.insertCategory(category).executeAsBlocking() | ||||
|                 val result = databaseHelper.insertCategory(category).executeAsBlocking() | ||||
|                 category.id = result.insertedId()?.toInt() | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Restores all the mangas and its related data. | ||||
|      * | ||||
|      * @param jsonMangas the mangas and its related data (chapters, sync, categories) from the json. | ||||
|      */ | ||||
|     private fun restoreMangas(jsonMangas: JsonArray) { | ||||
|         for (backupManga in jsonMangas) { | ||||
|             // Map every entry to objects | ||||
|             val element = backupManga.asJsonObject | ||||
|             val manga = gson.fromJson(element.get(MANGA), MangaImpl::class.java) | ||||
|             val chapters = gson.fromJson<List<ChapterImpl>>(element.get(CHAPTERS) ?: JsonArray()) | ||||
|             val tracks = gson.fromJson<List<TrackImpl>>(element.get(TRACK) ?: JsonArray()) | ||||
|             val categories = gson.fromJson<List<String>>(element.get(CATEGORIES) ?: JsonArray()) | ||||
|  | ||||
|             // Restore everything related to this manga | ||||
|             restoreManga(manga) | ||||
|             restoreChaptersForManga(manga, chapters) | ||||
|             restoreSyncForManga(manga, tracks) | ||||
|             restoreCategoriesForManga(manga, categories) | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Restores a manga. | ||||
|      * | ||||
|      * @param manga the manga to restore. | ||||
|      */ | ||||
|     private fun restoreManga(manga: Manga) { | ||||
|         // Try to find existing manga in db | ||||
|         val dbManga = db.getManga(manga.url, manga.source).executeAsBlocking() | ||||
|         if (dbManga == null) { | ||||
|             // Let the db assign the id | ||||
|             manga.id = null | ||||
|             val result = db.insertManga(manga).executeAsBlocking() | ||||
|             manga.id = result.insertedId() | ||||
|         } else { | ||||
|             // If it exists already, we copy only the values related to the source from the db | ||||
|             // (they can be up to date). Local values (flags) are kept from the backup. | ||||
|             manga.id = dbManga.id | ||||
|             manga.copyFrom(dbManga) | ||||
|             manga.favorite = true | ||||
|             db.insertManga(manga).executeAsBlocking() | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Restores the chapters of a manga. | ||||
|      * | ||||
|      * @param manga the manga whose chapters have to be restored. | ||||
|      * @param chapters the chapters to restore. | ||||
|      */ | ||||
|     private fun restoreChaptersForManga(manga: Manga, chapters: List<Chapter>) { | ||||
|         // Fix foreign keys with the current manga id | ||||
|         for (chapter in chapters) { | ||||
|             chapter.manga_id = manga.id | ||||
|         } | ||||
|  | ||||
|         val dbChapters = db.getChapters(manga).executeAsBlocking() | ||||
|         val chaptersToUpdate = ArrayList<Chapter>() | ||||
|         for (backupChapter in chapters) { | ||||
|             // Try to find existing chapter in db | ||||
|             val pos = dbChapters.indexOf(backupChapter) | ||||
|             if (pos != -1) { | ||||
|                 // The chapter is already in the db, only update its fields | ||||
|                 val dbChapter = dbChapters[pos] | ||||
|                 // If one of them was read, the chapter will be marked as read | ||||
|                 dbChapter.read = backupChapter.read || dbChapter.read | ||||
|                 dbChapter.last_page_read = Math.max(backupChapter.last_page_read, dbChapter.last_page_read) | ||||
|                 chaptersToUpdate.add(dbChapter) | ||||
|             } else { | ||||
|                 // Insert new chapter. Let the db assign the id | ||||
|                 backupChapter.id = null | ||||
|                 chaptersToUpdate.add(backupChapter) | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         // Update database | ||||
|         if (!chaptersToUpdate.isEmpty()) { | ||||
|             db.insertChapters(chaptersToUpdate).executeAsBlocking() | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Restores the categories a manga is in. | ||||
|      * | ||||
|      * @param manga the manga whose categories have to be restored. | ||||
|      * @param categories the categories to restore. | ||||
|      */ | ||||
|     private fun restoreCategoriesForManga(manga: Manga, categories: List<String>) { | ||||
|         val dbCategories = db.getCategories().executeAsBlocking() | ||||
|     internal fun restoreCategoriesForManga(manga: Manga, categories: List<String>) { | ||||
|         val dbCategories = databaseHelper.getCategories().executeAsBlocking() | ||||
|         val mangaCategoriesToUpdate = ArrayList<MangaCategory>() | ||||
|         for (backupCategoryStr in categories) { | ||||
|             for (dbCategory in dbCategories) { | ||||
| @@ -324,45 +252,151 @@ class BackupManager(private val db: DatabaseHelper) { | ||||
|         if (!mangaCategoriesToUpdate.isEmpty()) { | ||||
|             val mangaAsList = ArrayList<Manga>() | ||||
|             mangaAsList.add(manga) | ||||
|             db.deleteOldMangasCategories(mangaAsList).executeAsBlocking() | ||||
|             db.insertMangasCategories(mangaCategoriesToUpdate).executeAsBlocking() | ||||
|             databaseHelper.deleteOldMangasCategories(mangaAsList).executeAsBlocking() | ||||
|             databaseHelper.insertMangasCategories(mangaCategoriesToUpdate).executeAsBlocking() | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Restore history from Json | ||||
|      * | ||||
|      * @param history list containing history to be restored | ||||
|      */ | ||||
|     internal fun restoreHistoryForManga(history: List<DHistory>) { | ||||
|         // List containing history to be updated | ||||
|         val historyToBeUpdated = ArrayList<History>() | ||||
|         for ((url, lastRead) in history) { | ||||
|             val dbHistory = databaseHelper.getHistoryByChapterUrl(url).executeAsBlocking() | ||||
|             // Check if history already in database and update | ||||
|             if (dbHistory != null) { | ||||
|                 dbHistory.apply { | ||||
|                     last_read = Math.max(lastRead, dbHistory.last_read) | ||||
|                 } | ||||
|                 historyToBeUpdated.add(dbHistory) | ||||
|             } else { | ||||
|                 // If not in database create | ||||
|                 databaseHelper.getChapter(url).executeAsBlocking()?.let { | ||||
|                     val historyToAdd = History.create(it).apply { | ||||
|                         last_read = lastRead | ||||
|                     } | ||||
|                     historyToBeUpdated.add(historyToAdd) | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|         databaseHelper.updateHistoryLastRead(historyToBeUpdated).executeAsBlocking() | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * 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<Track>) { | ||||
|     internal fun restoreTrackForManga(manga: Manga, tracks: List<Track>) { | ||||
|         // Fix foreign keys with the current manga id | ||||
|         for (track in tracks) { | ||||
|             track.manga_id = manga.id!! | ||||
|         } | ||||
|         tracks.map { it.manga_id = manga.id!! } | ||||
|  | ||||
|         val dbTracks = db.getTracks(manga).executeAsBlocking() | ||||
|         // Get tracks from database | ||||
|         val dbTracks = databaseHelper.getTracks(manga).executeAsBlocking() | ||||
|         val trackToUpdate = ArrayList<Track>() | ||||
|         for (backupTrack in tracks) { | ||||
|             // Try to find existing chapter in db | ||||
|             val pos = dbTracks.indexOf(backupTrack) | ||||
|             if (pos != -1) { | ||||
|                 // The sync is already in the db, only update its fields | ||||
|                 val dbSync = dbTracks[pos] | ||||
|                 // Mark the max chapter as read and nothing else | ||||
|                 dbSync.last_chapter_read = Math.max(backupTrack.last_chapter_read, dbSync.last_chapter_read) | ||||
|                 trackToUpdate.add(dbSync) | ||||
|             } else { | ||||
|  | ||||
|         for (track in tracks) { | ||||
|             var isInDatabase = false | ||||
|             for (dbTrack in dbTracks) { | ||||
|                 if (track.sync_id == dbTrack.sync_id) { | ||||
|                     // The sync is already in the db, only update its fields | ||||
|                     if (track.remote_id != dbTrack.remote_id) { | ||||
|                         dbTrack.remote_id = track.remote_id | ||||
|                     } | ||||
|                     dbTrack.last_chapter_read = Math.max(dbTrack.last_chapter_read, track.last_chapter_read) | ||||
|                     isInDatabase = true | ||||
|                     trackToUpdate.add(dbTrack) | ||||
|                     break | ||||
|                 } | ||||
|             } | ||||
|             if (!isInDatabase) { | ||||
|                 // Insert new sync. Let the db assign the id | ||||
|                 backupTrack.id = null | ||||
|                 trackToUpdate.add(backupTrack) | ||||
|                 track.id = null | ||||
|                 trackToUpdate.add(track) | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         // Update database | ||||
|         if (!trackToUpdate.isEmpty()) { | ||||
|             db.insertTracks(trackToUpdate).executeAsBlocking() | ||||
|             databaseHelper.insertTracks(trackToUpdate).executeAsBlocking() | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Restore the chapters for manga if chapters already in database | ||||
|      * | ||||
|      * @param manga manga of chapters | ||||
|      * @param chapters list containing chapters that get restored | ||||
|      * @return boolean answering if chapter fetch is not needed | ||||
|      */ | ||||
|     internal fun restoreChaptersForManga(manga: Manga, chapters: List<Chapter>): Boolean { | ||||
|         val dbChapters = databaseHelper.getChapters(manga).executeAsBlocking() | ||||
|  | ||||
|         // Return if fetch is needed | ||||
|         if (dbChapters.isEmpty() || dbChapters.size < chapters.size) | ||||
|             return false | ||||
|  | ||||
|         for (chapter in chapters) { | ||||
|             val pos = dbChapters.indexOf(chapter) | ||||
|             if (pos != -1) { | ||||
|                 val dbChapter = dbChapters[pos] | ||||
|                 chapter.id = dbChapter.id | ||||
|                 chapter.copyFrom(dbChapter) | ||||
|                 break | ||||
|             } | ||||
|         } | ||||
|         // Filter the chapters that couldn't be found. | ||||
|         chapters.filter { it.id != null } | ||||
|         chapters.map { it.manga_id = manga.id } | ||||
|  | ||||
|         insertChapters(chapters) | ||||
|         return true | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Returns manga | ||||
|      * | ||||
|      * @return [Manga], null if not found | ||||
|      */ | ||||
|     internal fun getMangaFromDatabase(manga: Manga): Manga? { | ||||
|         return databaseHelper.getManga(manga.url, manga.source).executeAsBlocking() | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Returns list containing manga from library | ||||
|      * | ||||
|      * @return [Manga] from library | ||||
|      */ | ||||
|     internal fun getFavoriteManga(): List<Manga> { | ||||
|         return databaseHelper.getFavoriteMangas().executeAsBlocking() | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Inserts manga and returns id | ||||
|      * | ||||
|      * @return id of [Manga], null if not found | ||||
|      */ | ||||
|     internal fun insertManga(manga: Manga): Long? { | ||||
|         return databaseHelper.insertManga(manga).executeAsBlocking().insertedId() | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Inserts list of chapters | ||||
|      */ | ||||
|     internal fun insertChapters(chapters: List<Chapter>) { | ||||
|         databaseHelper.updateChaptersBackup(chapters).executeAsBlocking() | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Return number of backups. | ||||
|      * | ||||
|      * @return number of backups selected by user | ||||
|      */ | ||||
|     fun numberOfBackups(): Int { | ||||
|         return preferences.numberOfBackups().getOrDefault() | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -0,0 +1,413 @@ | ||||
| package eu.kanade.tachiyomi.data.backup | ||||
|  | ||||
| import android.app.Service | ||||
| import android.content.Context | ||||
| import android.content.Intent | ||||
| import android.net.Uri | ||||
| import android.os.IBinder | ||||
| import android.os.PowerManager | ||||
| import com.github.salomonbrys.kotson.fromJson | ||||
| import com.google.gson.JsonArray | ||||
| import com.google.gson.JsonParser | ||||
| import com.google.gson.stream.JsonReader | ||||
| import com.hippo.unifile.UniFile | ||||
| import eu.kanade.tachiyomi.R | ||||
| import eu.kanade.tachiyomi.data.backup.models.Backup.CATEGORIES | ||||
| import eu.kanade.tachiyomi.data.backup.models.Backup.CHAPTERS | ||||
| import eu.kanade.tachiyomi.data.backup.models.Backup.HISTORY | ||||
| import eu.kanade.tachiyomi.data.backup.models.Backup.MANGA | ||||
| import eu.kanade.tachiyomi.data.backup.models.Backup.MANGAS | ||||
| import eu.kanade.tachiyomi.data.backup.models.Backup.TRACK | ||||
| import eu.kanade.tachiyomi.data.backup.models.Backup.VERSION | ||||
| import eu.kanade.tachiyomi.data.backup.models.DHistory | ||||
| import eu.kanade.tachiyomi.data.database.DatabaseHelper | ||||
| import eu.kanade.tachiyomi.data.database.models.* | ||||
| import eu.kanade.tachiyomi.source.Source | ||||
| import eu.kanade.tachiyomi.ui.setting.SettingsBackupFragment | ||||
| import eu.kanade.tachiyomi.util.AndroidComponentUtil | ||||
| import eu.kanade.tachiyomi.util.chop | ||||
| import eu.kanade.tachiyomi.util.sendLocalBroadcast | ||||
| import rx.Observable | ||||
| import rx.Subscription | ||||
| import rx.schedulers.Schedulers | ||||
| import timber.log.Timber | ||||
| import uy.kohesive.injekt.injectLazy | ||||
| import java.io.File | ||||
| import java.text.SimpleDateFormat | ||||
| import java.util.* | ||||
| import eu.kanade.tachiyomi.BuildConfig.APPLICATION_ID as ID | ||||
|  | ||||
| /** | ||||
|  * Restores backup from json file | ||||
|  */ | ||||
| class BackupRestoreService : Service() { | ||||
|  | ||||
|     companion object { | ||||
|         // Name of service | ||||
|         private const val NAME = "BackupRestoreService" | ||||
|  | ||||
|         // Uri as string | ||||
|         private const val EXTRA_URI = "$ID.$NAME.EXTRA_URI" | ||||
|  | ||||
|         /** | ||||
|          * Returns the status of the service. | ||||
|          * | ||||
|          * @param context the application context. | ||||
|          * @return true if the service is running, false otherwise. | ||||
|          */ | ||||
|         fun isRunning(context: Context): Boolean { | ||||
|             return AndroidComponentUtil.isServiceRunning(context, BackupRestoreService::class.java) | ||||
|         } | ||||
|  | ||||
|         /** | ||||
|          * Starts a service to restore a backup from Json | ||||
|          * | ||||
|          * @param context context of application | ||||
|          * @param uri path of Uri | ||||
|          */ | ||||
|         fun start(context: Context, uri: String) { | ||||
|             if (!isRunning(context)) { | ||||
|                 val intent = Intent(context, BackupRestoreService::class.java).apply { | ||||
|                     putExtra(EXTRA_URI, uri) | ||||
|                 } | ||||
|                 context.startService(intent) | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         /** | ||||
|          * Stops the service. | ||||
|          * | ||||
|          * @param context the application context. | ||||
|          */ | ||||
|         fun stop(context: Context) { | ||||
|             context.stopService(Intent(context, BackupRestoreService::class.java)) | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Wake lock that will be held until the service is destroyed. | ||||
|      */ | ||||
|     private lateinit var wakeLock: PowerManager.WakeLock | ||||
|  | ||||
|     /** | ||||
|      * Subscription where the update is done. | ||||
|      */ | ||||
|     private var subscription: Subscription? = null | ||||
|  | ||||
|     /** | ||||
|      * The progress of a backup restore | ||||
|      */ | ||||
|     private var restoreProgress = 0 | ||||
|  | ||||
|     /** | ||||
|      * Amount of manga in Json file (needed for restore) | ||||
|      */ | ||||
|     private var restoreAmount = 0 | ||||
|  | ||||
|     /** | ||||
|      * List containing errors | ||||
|      */ | ||||
|     private val errors = mutableListOf<Pair<Date, String>>() | ||||
|  | ||||
|     /** | ||||
|      * Backup manager | ||||
|      */ | ||||
|     private lateinit var backupManager: BackupManager | ||||
|  | ||||
|     /** | ||||
|      * Database | ||||
|      */ | ||||
|     private val db: DatabaseHelper by injectLazy() | ||||
|  | ||||
|     /** | ||||
|      * Method called when the service is created. It injects dependencies and acquire the wake lock. | ||||
|      */ | ||||
|     override fun onCreate() { | ||||
|         super.onCreate() | ||||
|         wakeLock = (getSystemService(Context.POWER_SERVICE) as PowerManager).newWakeLock( | ||||
|                 PowerManager.PARTIAL_WAKE_LOCK, "BackupRestoreService:WakeLock") | ||||
|         wakeLock.acquire() | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Method called when the service is destroyed. It destroys the running subscription and | ||||
|      * releases the wake lock. | ||||
|      */ | ||||
|     override fun onDestroy() { | ||||
|         subscription?.unsubscribe() | ||||
|         if (wakeLock.isHeld) { | ||||
|             wakeLock.release() | ||||
|         } | ||||
|         super.onDestroy() | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * This method needs to be implemented, but it's not used/needed. | ||||
|      */ | ||||
|     override fun onBind(intent: Intent): IBinder? { | ||||
|         return null | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Method called when the service receives an intent. | ||||
|      * | ||||
|      * @param intent the start intent from. | ||||
|      * @param flags the flags of the command. | ||||
|      * @param startId the start id of this command. | ||||
|      * @return the start value of the command. | ||||
|      */ | ||||
|     override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { | ||||
|         if (intent == null) return Service.START_NOT_STICKY | ||||
|  | ||||
|         // Unsubscribe from any previous subscription if needed. | ||||
|         subscription?.unsubscribe() | ||||
|  | ||||
|         val startTime = System.currentTimeMillis() | ||||
|         subscription = Observable.defer { | ||||
|             // Get URI | ||||
|             val uri = Uri.parse(intent.getStringExtra(EXTRA_URI)) | ||||
|             // Get file from Uri | ||||
|             val file = UniFile.fromUri(this, uri) | ||||
|  | ||||
|             // Clear errors | ||||
|             errors.clear() | ||||
|  | ||||
|             // Reset progress | ||||
|             restoreProgress = 0 | ||||
|  | ||||
|             db.lowLevel().beginTransaction() | ||||
|             getRestoreObservable(file) | ||||
|         } | ||||
|         .subscribeOn(Schedulers.io()) | ||||
|         .subscribe({ | ||||
|         }, { error -> | ||||
|             db.lowLevel().endTransaction() | ||||
|             Timber.e(error) | ||||
|             writeErrorLog() | ||||
|             val errorIntent = Intent(SettingsBackupFragment.INTENT_FILTER).apply { | ||||
|                 putExtra(SettingsBackupFragment.ACTION, SettingsBackupFragment.ACTION_ERROR_RESTORE_DIALOG) | ||||
|                 putExtra(SettingsBackupFragment.EXTRA_ERROR_MESSAGE, error.message) | ||||
|             } | ||||
|             sendLocalBroadcast(errorIntent) | ||||
|             stopSelf(startId) | ||||
|         }, { | ||||
|             db.lowLevel().setTransactionSuccessful() | ||||
|             db.lowLevel().endTransaction() | ||||
|             val endTime = System.currentTimeMillis() | ||||
|             val time = endTime - startTime | ||||
|             val file = writeErrorLog() | ||||
|             val completeIntent = Intent(SettingsBackupFragment.INTENT_FILTER).apply { | ||||
|                 putExtra(SettingsBackupFragment.EXTRA_TIME, time) | ||||
|                 putExtra(SettingsBackupFragment.EXTRA_ERRORS, errors.size) | ||||
|                 putExtra(SettingsBackupFragment.EXTRA_ERROR_FILE_PATH, file.parent) | ||||
|                 putExtra(SettingsBackupFragment.EXTRA_ERROR_FILE, file.name) | ||||
|                 putExtra(SettingsBackupFragment.ACTION, SettingsBackupFragment.ACTION_RESTORE_COMPLETED_DIALOG) | ||||
|             } | ||||
|             sendLocalBroadcast(completeIntent) | ||||
|             stopSelf(startId) | ||||
|         }) | ||||
|         return Service.START_NOT_STICKY | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Returns an [Observable] containing restore process. | ||||
|      * | ||||
|      * @param file restore file | ||||
|      * @return [Observable<Manga>] | ||||
|      */ | ||||
|     private fun getRestoreObservable(file: UniFile): Observable<Manga> { | ||||
|         val reader = JsonReader(file.openInputStream().bufferedReader()) | ||||
|         val json = JsonParser().parse(reader).asJsonObject | ||||
|  | ||||
|         // Get parser version | ||||
|         val version = json.get(VERSION)?.asInt ?: 1 | ||||
|  | ||||
|         // Initialize manager | ||||
|         backupManager = BackupManager(this, version) | ||||
|  | ||||
|         val mangasJson = json.get(MANGAS).asJsonArray | ||||
|  | ||||
|         restoreAmount = mangasJson.size() + 1 // +1 for categories | ||||
|  | ||||
|         // Restore categories | ||||
|         json.get(CATEGORIES)?.let { | ||||
|             backupManager.restoreCategories(it.asJsonArray) | ||||
|             restoreProgress += 1 | ||||
|             showRestoreProgress(restoreProgress, restoreAmount, "Categories added", errors.size) | ||||
|         } | ||||
|  | ||||
|         return Observable.from(mangasJson) | ||||
|                 .concatMap { | ||||
|                     val obj = it.asJsonObject | ||||
|                     val manga = backupManager.parser.fromJson<MangaImpl>(obj.get(MANGA)) | ||||
|                     val chapters = backupManager.parser.fromJson<List<ChapterImpl>>(obj.get(CHAPTERS) ?: JsonArray()) | ||||
|                     val categories = backupManager.parser.fromJson<List<String>>(obj.get(CATEGORIES) ?: JsonArray()) | ||||
|                     val history = backupManager.parser.fromJson<List<DHistory>>(obj.get(HISTORY) ?: JsonArray()) | ||||
|                     val tracks = backupManager.parser.fromJson<List<TrackImpl>>(obj.get(TRACK) ?: JsonArray()) | ||||
|  | ||||
|                     val observable = getMangaRestoreObservable(manga, chapters, categories, history, tracks) | ||||
|                     if (observable != null) { | ||||
|                         observable | ||||
|                     } else { | ||||
|                         errors.add(Date() to "${manga.title} - ${getString(R.string.source_not_found)}") | ||||
|                         restoreProgress += 1 | ||||
|                         val content = getString(R.string.dialog_restoring_source_not_found, manga.title.chop(15)) | ||||
|                         showRestoreProgress(restoreProgress, restoreAmount, manga.title, errors.size, content) | ||||
|                         Observable.just(manga) | ||||
|                     } | ||||
|                 } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Write errors to error log | ||||
|      */ | ||||
|     private fun writeErrorLog(): File { | ||||
|         try { | ||||
|             if (errors.isNotEmpty()) { | ||||
|                 val destFile = File(externalCacheDir, "tachiyomi_restore.log") | ||||
|                 val sdf = SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS", Locale.getDefault()) | ||||
|  | ||||
|                 destFile.bufferedWriter().use { out -> | ||||
|                     errors.forEach { (date, message) -> | ||||
|                         out.write("[${sdf.format(date)}] $message\n") | ||||
|                     } | ||||
|                 } | ||||
|                 return destFile | ||||
|             } | ||||
|         } catch (e: Exception) { | ||||
|             // Empty | ||||
|         } | ||||
|         return File("") | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Returns a manga restore observable | ||||
|      * | ||||
|      * @param manga manga data from json | ||||
|      * @param chapters chapters data from json | ||||
|      * @param categories categories data from json | ||||
|      * @param history history data from json | ||||
|      * @param tracks tracking data from json | ||||
|      * @return [Observable] containing manga restore information | ||||
|      */ | ||||
|     private fun getMangaRestoreObservable(manga: Manga, chapters: List<Chapter>, | ||||
|                                           categories: List<String>, history: List<DHistory>, | ||||
|                                           tracks: List<Track>): Observable<Manga>? { | ||||
|         // Get source | ||||
|         val source = backupManager.sourceManager.get(manga.source) ?: return null | ||||
|         val dbManga = backupManager.getMangaFromDatabase(manga) | ||||
|  | ||||
|         if (dbManga == null) { | ||||
|             // Manga not in database | ||||
|             return mangaFetchObservable(source, manga, chapters, categories, history, tracks) | ||||
|         } else { // Manga in database | ||||
|             // Copy information from manga already in database | ||||
|             backupManager.restoreMangaNoFetch(manga, dbManga) | ||||
|             // Fetch rest of manga information | ||||
|             return mangaNoFetchObservable(source, manga, chapters, categories, history, tracks) | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * [Observable] that fetches manga information | ||||
|      * | ||||
|      * @param manga manga that needs updating | ||||
|      * @param chapters chapters of manga that needs updating | ||||
|      * @param categories categories that need updating | ||||
|      */ | ||||
|     private fun mangaFetchObservable(source: Source, manga: Manga, chapters: List<Chapter>, | ||||
|                                      categories: List<String>, history: List<DHistory>, | ||||
|                                      tracks: List<Track>): Observable<Manga> { | ||||
|         return backupManager.restoreMangaFetchObservable(source, manga) | ||||
|                 .onErrorReturn { | ||||
|                     errors.add(Date() to "${manga.title} - ${it.message}") | ||||
|                     manga | ||||
|                 } | ||||
|                 .filter { it.id != null } | ||||
|                 .flatMap { manga -> | ||||
|                     chapterFetchObservable(source, manga, chapters) | ||||
|                             // Convert to the manga that contains new chapters. | ||||
|                             .map { manga } | ||||
|                 } | ||||
|                 .doOnNext { | ||||
|                     // Restore categories | ||||
|                     backupManager.restoreCategoriesForManga(it, categories) | ||||
|  | ||||
|                     // Restore history | ||||
|                     backupManager.restoreHistoryForManga(history) | ||||
|  | ||||
|                     // Restore tracking | ||||
|                     backupManager.restoreTrackForManga(it, tracks) | ||||
|                 } | ||||
|                 .doOnCompleted { | ||||
|                     restoreProgress += 1 | ||||
|                     showRestoreProgress(restoreProgress, restoreAmount, manga.title, errors.size) | ||||
|                 } | ||||
|     } | ||||
|  | ||||
|     private fun mangaNoFetchObservable(source: Source, backupManga: Manga, chapters: List<Chapter>, | ||||
|                                        categories: List<String>, history: List<DHistory>, | ||||
|                                        tracks: List<Track>): Observable<Manga> { | ||||
|  | ||||
|         return Observable.just(backupManga) | ||||
|                 .flatMap { manga -> | ||||
|                     if (!backupManager.restoreChaptersForManga(manga, chapters)) { | ||||
|                         chapterFetchObservable(source, manga, chapters) | ||||
|                                 .map { manga } | ||||
|                     } else { | ||||
|                         Observable.just(manga) | ||||
|                     } | ||||
|                 } | ||||
|                 .doOnNext { | ||||
|                     // Restore categories | ||||
|                     backupManager.restoreCategoriesForManga(it, categories) | ||||
|  | ||||
|                     // Restore history | ||||
|                     backupManager.restoreHistoryForManga(history) | ||||
|  | ||||
|                     // Restore tracking | ||||
|                     backupManager.restoreTrackForManga(it, tracks) | ||||
|                 } | ||||
|                 .doOnCompleted { | ||||
|                     restoreProgress += 1 | ||||
|                     showRestoreProgress(restoreProgress, restoreAmount, backupManga.title, errors.size) | ||||
|                 } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * [Observable] that fetches chapter information | ||||
|      * | ||||
|      * @param source source of manga | ||||
|      * @param manga manga that needs updating | ||||
|      * @return [Observable] that contains manga | ||||
|      */ | ||||
|     private fun chapterFetchObservable(source: Source, manga: Manga, chapters: List<Chapter>): Observable<Pair<List<Chapter>, List<Chapter>>> { | ||||
|         return backupManager.restoreChapterFetchObservable(source, manga, chapters) | ||||
|                 // If there's any error, return empty update and continue. | ||||
|                 .onErrorReturn { | ||||
|                     errors.add(Date() to "${manga.title} - ${it.message}") | ||||
|                     Pair(emptyList<Chapter>(), emptyList<Chapter>()) | ||||
|                 } | ||||
|     } | ||||
|  | ||||
|  | ||||
|     /** | ||||
|      * Called to update dialog in [SettingsBackupFragment] | ||||
|      * | ||||
|      * @param progress restore progress | ||||
|      * @param amount total restoreAmount of manga | ||||
|      * @param title title of restored manga | ||||
|      */ | ||||
|     private fun showRestoreProgress(progress: Int, amount: Int, title: String, errors: Int, | ||||
|                                     content: String = getString(R.string.dialog_restoring_backup, title.chop(15))) { | ||||
|         val intent = Intent(SettingsBackupFragment.INTENT_FILTER).apply { | ||||
|             putExtra(SettingsBackupFragment.EXTRA_PROGRESS, progress) | ||||
|             putExtra(SettingsBackupFragment.EXTRA_AMOUNT, amount) | ||||
|             putExtra(SettingsBackupFragment.EXTRA_CONTENT, content) | ||||
|             putExtra(SettingsBackupFragment.EXTRA_ERRORS, errors) | ||||
|             putExtra(SettingsBackupFragment.ACTION, SettingsBackupFragment.ACTION_SET_PROGRESS_DIALOG) | ||||
|         } | ||||
|         sendLocalBroadcast(intent) | ||||
|     } | ||||
|  | ||||
| } | ||||
| @@ -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" | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,3 @@ | ||||
| package eu.kanade.tachiyomi.data.backup.models | ||||
|  | ||||
| data class DHistory(val url: String,val lastRead: Long) | ||||
| @@ -1,16 +0,0 @@ | ||||
| package eu.kanade.tachiyomi.data.backup.serializer | ||||
|  | ||||
| import com.google.gson.JsonElement | ||||
| import com.google.gson.JsonPrimitive | ||||
| import com.google.gson.JsonSerializationContext | ||||
| import com.google.gson.JsonSerializer | ||||
| import java.lang.reflect.Type | ||||
|  | ||||
| class BooleanSerializer : JsonSerializer<Boolean> { | ||||
|  | ||||
|     override fun serialize(value: Boolean?, type: Type, context: JsonSerializationContext): JsonElement? { | ||||
|         if (value != null && value != false) | ||||
|             return JsonPrimitive(value) | ||||
|         return null | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,31 @@ | ||||
| package eu.kanade.tachiyomi.data.backup.serializer | ||||
|  | ||||
| import com.github.salomonbrys.kotson.typeAdapter | ||||
| import com.google.gson.TypeAdapter | ||||
| import eu.kanade.tachiyomi.data.database.models.CategoryImpl | ||||
|  | ||||
| /** | ||||
|  * JSON Serializer used to write / read [CategoryImpl] to / from json | ||||
|  */ | ||||
| object CategoryTypeAdapter { | ||||
|  | ||||
|     fun build(): TypeAdapter<CategoryImpl> { | ||||
|         return typeAdapter { | ||||
|             write { | ||||
|                 beginArray() | ||||
|                 value(it.name) | ||||
|                 value(it.order) | ||||
|                 endArray() | ||||
|             } | ||||
|  | ||||
|             read { | ||||
|                 beginArray() | ||||
|                 val category = CategoryImpl() | ||||
|                 category.name = nextString() | ||||
|                 category.order = nextInt() | ||||
|                 endArray() | ||||
|                 category | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,61 @@ | ||||
| package eu.kanade.tachiyomi.data.backup.serializer | ||||
|  | ||||
| import com.github.salomonbrys.kotson.typeAdapter | ||||
| import com.google.gson.TypeAdapter | ||||
| import com.google.gson.stream.JsonToken | ||||
| import eu.kanade.tachiyomi.data.database.models.ChapterImpl | ||||
|  | ||||
| /** | ||||
|  * JSON Serializer used to write / read [ChapterImpl] to / from json | ||||
|  */ | ||||
| object ChapterTypeAdapter { | ||||
|  | ||||
|     private const val URL = "u" | ||||
|     private const val READ = "r" | ||||
|     private const val BOOKMARK = "b" | ||||
|     private const val LAST_READ = "l" | ||||
|  | ||||
|     fun build(): TypeAdapter<ChapterImpl> { | ||||
|         return typeAdapter { | ||||
|             write { | ||||
|                 if (it.read || it.bookmark || it.last_page_read != 0) { | ||||
|                     beginObject() | ||||
|                     name(URL) | ||||
|                     value(it.url) | ||||
|                     if (it.read) { | ||||
|                         name(READ) | ||||
|                         value(1) | ||||
|                     } | ||||
|                     if (it.bookmark) { | ||||
|                         name(BOOKMARK) | ||||
|                         value(1) | ||||
|                     } | ||||
|                     if (it.last_page_read != 0) { | ||||
|                         name(LAST_READ) | ||||
|                         value(it.last_page_read) | ||||
|                     } | ||||
|                     endObject() | ||||
|                 } | ||||
|             } | ||||
|  | ||||
|             read { | ||||
|                 val chapter = ChapterImpl() | ||||
|                 beginObject() | ||||
|                 while (hasNext()) { | ||||
|                     if (peek() == JsonToken.NAME) { | ||||
|                         val name = nextName() | ||||
|  | ||||
|                         when (name) { | ||||
|                             URL -> chapter.url = nextString() | ||||
|                             READ -> chapter.read = nextInt() == 1 | ||||
|                             BOOKMARK -> chapter.bookmark = nextInt() == 1 | ||||
|                             LAST_READ -> chapter.last_page_read = nextInt() | ||||
|                         } | ||||
|                     } | ||||
|                 } | ||||
|                 endObject() | ||||
|                 chapter | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,32 @@ | ||||
| package eu.kanade.tachiyomi.data.backup.serializer | ||||
|  | ||||
| import com.github.salomonbrys.kotson.typeAdapter | ||||
| import com.google.gson.TypeAdapter | ||||
| import eu.kanade.tachiyomi.data.backup.models.DHistory | ||||
|  | ||||
| /** | ||||
|  * JSON Serializer used to write / read [DHistory] to / from json | ||||
|  */ | ||||
| object HistoryTypeAdapter { | ||||
|  | ||||
|     fun build(): TypeAdapter<DHistory> { | ||||
|         return typeAdapter { | ||||
|             write { | ||||
|                 if (it.lastRead != 0L) { | ||||
|                     beginArray() | ||||
|                     value(it.url) | ||||
|                     value(it.lastRead) | ||||
|                     endArray() | ||||
|                 } | ||||
|             } | ||||
|  | ||||
|             read { | ||||
|                 beginArray() | ||||
|                 val url = nextString() | ||||
|                 val lastRead = nextLong() | ||||
|                 endArray() | ||||
|                 DHistory(url, lastRead) | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -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 | ||||
|  | ||||
| } | ||||
| @@ -1,17 +0,0 @@ | ||||
| package eu.kanade.tachiyomi.data.backup.serializer | ||||
|  | ||||
| import com.google.gson.JsonElement | ||||
| import com.google.gson.JsonPrimitive | ||||
| import com.google.gson.JsonSerializationContext | ||||
| import com.google.gson.JsonSerializer | ||||
|  | ||||
| import java.lang.reflect.Type | ||||
|  | ||||
| class IntegerSerializer : JsonSerializer<Int> { | ||||
|  | ||||
|     override fun serialize(value: Int?, type: Type, context: JsonSerializationContext): JsonElement? { | ||||
|         if (value != null && value !== 0) | ||||
|             return JsonPrimitive(value) | ||||
|         return null | ||||
|     } | ||||
| } | ||||
| @@ -1,16 +0,0 @@ | ||||
| package eu.kanade.tachiyomi.data.backup.serializer | ||||
|  | ||||
| import com.google.gson.JsonElement | ||||
| import com.google.gson.JsonPrimitive | ||||
| import com.google.gson.JsonSerializationContext | ||||
| import com.google.gson.JsonSerializer | ||||
| import java.lang.reflect.Type | ||||
|  | ||||
| class LongSerializer : JsonSerializer<Long> { | ||||
|  | ||||
|     override fun serialize(value: Long?, type: Type, context: JsonSerializationContext): JsonElement? { | ||||
|         if (value != null && value !== 0L) | ||||
|             return JsonPrimitive(value) | ||||
|         return null | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,37 @@ | ||||
| package eu.kanade.tachiyomi.data.backup.serializer | ||||
|  | ||||
| import com.github.salomonbrys.kotson.typeAdapter | ||||
| import com.google.gson.TypeAdapter | ||||
| import eu.kanade.tachiyomi.data.database.models.MangaImpl | ||||
|  | ||||
| /** | ||||
|  * JSON Serializer used to write / read [MangaImpl] to / from json | ||||
|  */ | ||||
| object MangaTypeAdapter { | ||||
|  | ||||
|     fun build(): TypeAdapter<MangaImpl> { | ||||
|         return typeAdapter { | ||||
|             write { | ||||
|                 beginArray() | ||||
|                 value(it.url) | ||||
|                 value(it.title) | ||||
|                 value(it.source) | ||||
|                 value(it.viewer) | ||||
|                 value(it.chapter_flags) | ||||
|                 endArray() | ||||
|             } | ||||
|  | ||||
|             read { | ||||
|                 beginArray() | ||||
|                 val manga = MangaImpl() | ||||
|                 manga.url = nextString() | ||||
|                 manga.title = nextString() | ||||
|                 manga.source = nextLong() | ||||
|                 manga.viewer = nextInt() | ||||
|                 manga.chapter_flags = nextInt() | ||||
|                 endArray() | ||||
|                 manga | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,53 @@ | ||||
| package eu.kanade.tachiyomi.data.backup.serializer | ||||
|  | ||||
| import com.github.salomonbrys.kotson.typeAdapter | ||||
| import com.google.gson.TypeAdapter | ||||
| import com.google.gson.stream.JsonToken | ||||
| import eu.kanade.tachiyomi.data.database.models.TrackImpl | ||||
|  | ||||
| /** | ||||
|  * JSON Serializer used to write / read [TrackImpl] to / from json | ||||
|  */ | ||||
| object TrackTypeAdapter { | ||||
|  | ||||
|     private const val SYNC = "s" | ||||
|     private const val REMOTE = "r" | ||||
|     private const val TITLE = "t" | ||||
|     private const val LAST_READ = "l" | ||||
|  | ||||
|     fun build(): TypeAdapter<TrackImpl> { | ||||
|         return typeAdapter { | ||||
|             write { | ||||
|                 beginObject() | ||||
|                 name(TITLE) | ||||
|                 value(it.title) | ||||
|                 name(SYNC) | ||||
|                 value(it.sync_id) | ||||
|                 name(REMOTE) | ||||
|                 value(it.remote_id) | ||||
|                 name(LAST_READ) | ||||
|                 value(it.last_chapter_read) | ||||
|                 endObject() | ||||
|             } | ||||
|  | ||||
|             read { | ||||
|                 val track = TrackImpl() | ||||
|                 beginObject() | ||||
|                 while (hasNext()) { | ||||
|                     if (peek() == JsonToken.NAME) { | ||||
|                         val name = nextName() | ||||
|  | ||||
|                         when (name) { | ||||
|                             TITLE -> track.title = nextString() | ||||
|                             SYNC -> track.sync_id = nextInt() | ||||
|                             REMOTE -> track.remote_id = nextInt() | ||||
|                             LAST_READ -> track.last_chapter_read = nextInt() | ||||
|                         } | ||||
|                     } | ||||
|                 } | ||||
|                 endObject() | ||||
|                 track | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -24,4 +24,6 @@ open class DatabaseHelper(context: Context) | ||||
|  | ||||
|     inline fun inTransaction(block: () -> Unit) = db.inTransaction(block) | ||||
|  | ||||
|     fun lowLevel() = db.lowLevel() | ||||
|  | ||||
| } | ||||
|   | ||||
| @@ -6,6 +6,7 @@ import eu.kanade.tachiyomi.data.database.DbProvider | ||||
| import eu.kanade.tachiyomi.data.database.models.Chapter | ||||
| import eu.kanade.tachiyomi.data.database.models.Manga | ||||
| import eu.kanade.tachiyomi.data.database.models.MangaChapter | ||||
| import eu.kanade.tachiyomi.data.database.resolvers.ChapterBackupPutResolver | ||||
| import eu.kanade.tachiyomi.data.database.resolvers.ChapterProgressPutResolver | ||||
| import eu.kanade.tachiyomi.data.database.resolvers.ChapterSourceOrderPutResolver | ||||
| import eu.kanade.tachiyomi.data.database.resolvers.MangaChapterGetResolver | ||||
| @@ -60,6 +61,11 @@ interface ChapterQueries : DbProvider { | ||||
|  | ||||
|     fun deleteChapters(chapters: List<Chapter>) = db.delete().objects(chapters).prepare() | ||||
|  | ||||
|     fun updateChaptersBackup(chapters: List<Chapter>) = db.put() | ||||
|             .objects(chapters) | ||||
|             .withPutResolver(ChapterBackupPutResolver()) | ||||
|             .prepare() | ||||
|  | ||||
|     fun updateChapterProgress(chapter: Chapter) = db.put() | ||||
|             .`object`(chapter) | ||||
|             .withPutResolver(ChapterProgressPutResolver()) | ||||
|   | ||||
| @@ -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() | ||||
| } | ||||
|   | ||||
| @@ -0,0 +1,35 @@ | ||||
| package eu.kanade.tachiyomi.data.database.resolvers | ||||
|  | ||||
| import android.content.ContentValues | ||||
| import com.pushtorefresh.storio.sqlite.StorIOSQLite | ||||
| import com.pushtorefresh.storio.sqlite.operations.put.PutResolver | ||||
| import com.pushtorefresh.storio.sqlite.operations.put.PutResult | ||||
| import com.pushtorefresh.storio.sqlite.queries.UpdateQuery | ||||
| import eu.kanade.tachiyomi.data.database.inTransactionReturn | ||||
| import eu.kanade.tachiyomi.data.database.models.Chapter | ||||
| import eu.kanade.tachiyomi.data.database.tables.ChapterTable | ||||
|  | ||||
| class ChapterBackupPutResolver : PutResolver<Chapter>() { | ||||
|  | ||||
|     override fun performPut(db: StorIOSQLite, chapter: Chapter) = db.inTransactionReturn { | ||||
|         val updateQuery = mapToUpdateQuery(chapter) | ||||
|         val contentValues = mapToContentValues(chapter) | ||||
|  | ||||
|         val numberOfRowsUpdated = db.lowLevel().update(updateQuery, contentValues) | ||||
|         PutResult.newUpdateResult(numberOfRowsUpdated, updateQuery.table()) | ||||
|     } | ||||
|  | ||||
|     fun mapToUpdateQuery(chapter: Chapter) = UpdateQuery.builder() | ||||
|             .table(ChapterTable.TABLE) | ||||
|             .where("${ChapterTable.COL_URL} = ?") | ||||
|             .whereArgs(chapter.url) | ||||
|             .build() | ||||
|  | ||||
|     fun mapToContentValues(chapter: Chapter) = ContentValues(3).apply { | ||||
|         put(ChapterTable.COL_READ, chapter.read) | ||||
|         put(ChapterTable.COL_BOOKMARK, chapter.bookmark) | ||||
|         put(ChapterTable.COL_LAST_PAGE_READ, chapter.last_page_read) | ||||
|     } | ||||
|  | ||||
| } | ||||
|  | ||||
| @@ -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) | ||||
|   | ||||
| @@ -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) | ||||
|   | ||||
| @@ -1,163 +0,0 @@ | ||||
| package eu.kanade.tachiyomi.ui.backup | ||||
|  | ||||
| import android.app.Activity | ||||
| import android.app.Dialog | ||||
| import android.content.Intent | ||||
| import android.net.Uri | ||||
| import android.os.Bundle | ||||
| import android.view.LayoutInflater | ||||
| import android.view.View | ||||
| import android.view.ViewGroup | ||||
| import com.afollestad.materialdialogs.MaterialDialog | ||||
| import eu.kanade.tachiyomi.R | ||||
| import eu.kanade.tachiyomi.ui.base.activity.ActivityMixin | ||||
| import eu.kanade.tachiyomi.ui.base.fragment.BaseRxFragment | ||||
| import eu.kanade.tachiyomi.util.toast | ||||
| import kotlinx.android.synthetic.main.fragment_backup.* | ||||
| import nucleus.factory.RequiresPresenter | ||||
| import rx.Observable | ||||
| import rx.android.schedulers.AndroidSchedulers | ||||
| import rx.internal.util.SubscriptionList | ||||
| import rx.schedulers.Schedulers | ||||
| import timber.log.Timber | ||||
| import java.io.File | ||||
| import java.text.SimpleDateFormat | ||||
| import java.util.* | ||||
|  | ||||
| /** | ||||
|  * Fragment to create and restore backups of the application's data. | ||||
|  * Uses R.layout.fragment_backup. | ||||
|  */ | ||||
| @RequiresPresenter(BackupPresenter::class) | ||||
| class BackupFragment : BaseRxFragment<BackupPresenter>() { | ||||
|  | ||||
|     private var backupDialog: Dialog? = null | ||||
|     private var restoreDialog: Dialog? = null | ||||
|  | ||||
|     private lateinit var subscriptions: SubscriptionList | ||||
|  | ||||
|     override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedState: Bundle?): View { | ||||
|         return inflater.inflate(R.layout.fragment_backup, container, false) | ||||
|     } | ||||
|  | ||||
|     override fun onViewCreated(view: View, savedState: Bundle?) { | ||||
|         setToolbarTitle(getString(R.string.label_backup)) | ||||
|  | ||||
|         (activity as ActivityMixin).requestPermissionsOnMarshmallow() | ||||
|         subscriptions = SubscriptionList() | ||||
|  | ||||
|         backup_button.setOnClickListener { | ||||
|             val today = SimpleDateFormat("yyyy-MM-dd").format(Date()) | ||||
|             val file = File(activity.externalCacheDir, "tachiyomi-$today.json") | ||||
|             presenter.createBackup(file) | ||||
|  | ||||
|             backupDialog = MaterialDialog.Builder(activity) | ||||
|                     .content(R.string.backup_please_wait) | ||||
|                     .progress(true, 0) | ||||
|                     .show() | ||||
|         } | ||||
|  | ||||
|         restore_button.setOnClickListener { | ||||
|             val intent = Intent(Intent.ACTION_GET_CONTENT) | ||||
|             intent.addCategory(Intent.CATEGORY_OPENABLE) | ||||
|             intent.type = "application/*" | ||||
|             val chooser = Intent.createChooser(intent, getString(R.string.file_select_backup)) | ||||
|             startActivityForResult(chooser, REQUEST_BACKUP_OPEN) | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     override fun onDestroyView() { | ||||
|         subscriptions.unsubscribe() | ||||
|         super.onDestroyView() | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Called from the presenter when the backup is completed. | ||||
|      * | ||||
|      * @param file the file where the backup is saved. | ||||
|      */ | ||||
|     fun onBackupCompleted(file: File) { | ||||
|         dismissBackupDialog() | ||||
|         val intent = Intent(Intent.ACTION_SEND) | ||||
|         intent.type = "application/json" | ||||
|         intent.putExtra(Intent.EXTRA_STREAM, Uri.parse("file://" + file)) | ||||
|         startActivity(Intent.createChooser(intent, "")) | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Called from the presenter when the restore is completed. | ||||
|      */ | ||||
|     fun onRestoreCompleted() { | ||||
|         dismissRestoreDialog() | ||||
|         context.toast(R.string.backup_completed) | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Called from the presenter when there's an error doing the backup. | ||||
|      * @param error the exception thrown. | ||||
|      */ | ||||
|     fun onBackupError(error: Throwable) { | ||||
|         dismissBackupDialog() | ||||
|         context.toast(error.message) | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Called from the presenter when there's an error restoring the backup. | ||||
|      * @param error the exception thrown. | ||||
|      */ | ||||
|     fun onRestoreError(error: Throwable) { | ||||
|         dismissRestoreDialog() | ||||
|         context.toast(error.message) | ||||
|     } | ||||
|  | ||||
|     override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { | ||||
|         if (data != null && resultCode == Activity.RESULT_OK && requestCode == REQUEST_BACKUP_OPEN) { | ||||
|             restoreDialog = MaterialDialog.Builder(activity) | ||||
|                     .content(R.string.restore_please_wait) | ||||
|                     .progress(true, 0) | ||||
|                     .show() | ||||
|  | ||||
|             // When using cloud services, we have to open the input stream in a background thread. | ||||
|             Observable.fromCallable { context.contentResolver.openInputStream(data.data) } | ||||
|                     .subscribeOn(Schedulers.io()) | ||||
|                     .observeOn(AndroidSchedulers.mainThread()) | ||||
|                     .subscribe({ | ||||
|                         presenter.restoreBackup(it) | ||||
|                     }, { error -> | ||||
|                         context.toast(error.message) | ||||
|                         Timber.e(error) | ||||
|                     }) | ||||
|                     .apply { subscriptions.add(this) } | ||||
|  | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Dismisses the backup dialog. | ||||
|      */ | ||||
|     fun dismissBackupDialog() { | ||||
|         backupDialog?.let { | ||||
|             it.dismiss() | ||||
|             backupDialog = null | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Dismisses the restore dialog. | ||||
|      */ | ||||
|     fun dismissRestoreDialog() { | ||||
|         restoreDialog?.let { | ||||
|             it.dismiss() | ||||
|             restoreDialog = null | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     companion object { | ||||
|  | ||||
|         private val REQUEST_BACKUP_OPEN = 102 | ||||
|  | ||||
|         fun newInstance(): BackupFragment { | ||||
|             return BackupFragment() | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -1,94 +0,0 @@ | ||||
| package eu.kanade.tachiyomi.ui.backup | ||||
|  | ||||
| import android.os.Bundle | ||||
| import eu.kanade.tachiyomi.data.backup.BackupManager | ||||
| import eu.kanade.tachiyomi.data.database.DatabaseHelper | ||||
| import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter | ||||
| import eu.kanade.tachiyomi.util.isNullOrUnsubscribed | ||||
| import rx.Observable | ||||
| import rx.Subscription | ||||
| import rx.android.schedulers.AndroidSchedulers | ||||
| import rx.schedulers.Schedulers | ||||
| import uy.kohesive.injekt.injectLazy | ||||
| import java.io.File | ||||
| import java.io.InputStream | ||||
|  | ||||
| /** | ||||
|  * Presenter of [BackupFragment]. | ||||
|  */ | ||||
| class BackupPresenter : BasePresenter<BackupFragment>() { | ||||
|  | ||||
|     /** | ||||
|      * Database. | ||||
|      */ | ||||
|     val db: DatabaseHelper by injectLazy() | ||||
|  | ||||
|     /** | ||||
|      * Backup manager. | ||||
|      */ | ||||
|     private lateinit var backupManager: BackupManager | ||||
|  | ||||
|     /** | ||||
|      * Subscription where the backup is restored. | ||||
|      */ | ||||
|     private var restoreSubscription: Subscription? = null | ||||
|  | ||||
|     /** | ||||
|      * Subscription where the backup is created. | ||||
|      */ | ||||
|     private var backupSubscription: Subscription? = null | ||||
|  | ||||
|     override fun onCreate(savedState: Bundle?) { | ||||
|         super.onCreate(savedState) | ||||
|         backupManager = BackupManager(db) | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Creates a backup and saves it to a file. | ||||
|      * | ||||
|      * @param file the path where the file will be saved. | ||||
|      */ | ||||
|     fun createBackup(file: File) { | ||||
|         if (backupSubscription.isNullOrUnsubscribed()) { | ||||
|             backupSubscription = getBackupObservable(file) | ||||
|                     .subscribeOn(Schedulers.io()) | ||||
|                     .observeOn(AndroidSchedulers.mainThread()) | ||||
|                     .subscribeFirst( | ||||
|                             { view, result -> view.onBackupCompleted(file) }, | ||||
|                             BackupFragment::onBackupError) | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Restores a backup from a stream. | ||||
|      * | ||||
|      * @param stream the input stream of the backup file. | ||||
|      */ | ||||
|     fun restoreBackup(stream: InputStream) { | ||||
|         if (restoreSubscription.isNullOrUnsubscribed()) { | ||||
|             restoreSubscription = getRestoreObservable(stream) | ||||
|                     .subscribeOn(Schedulers.io()) | ||||
|                     .observeOn(AndroidSchedulers.mainThread()) | ||||
|                     .subscribeFirst( | ||||
|                             { view, result -> view.onRestoreCompleted() }, | ||||
|                             BackupFragment::onRestoreError) | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Returns the observable to save a backup. | ||||
|      */ | ||||
|     private fun getBackupObservable(file: File) = Observable.fromCallable { | ||||
|         backupManager.backupToFile(file) | ||||
|         true | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Returns the observable to restore a backup. | ||||
|      */ | ||||
|     private fun getRestoreObservable(stream: InputStream) = Observable.fromCallable { | ||||
|         backupManager.restoreFromStream(stream) | ||||
|         true | ||||
|     } | ||||
|  | ||||
| } | ||||
| @@ -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) | ||||
|   | ||||
| @@ -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) | ||||
|   | ||||
| @@ -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() | ||||
|   | ||||
| @@ -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() | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
| } | ||||
| @@ -9,21 +9,16 @@ import android.os.Environment | ||||
| import android.support.v4.content.ContextCompat | ||||
| import android.support.v7.preference.Preference | ||||
| import android.support.v7.preference.XpPreferenceFragment | ||||
| import android.support.v7.widget.RecyclerView | ||||
| import android.view.View | ||||
| import android.view.ViewGroup | ||||
| import com.afollestad.materialdialogs.MaterialDialog | ||||
| import com.hippo.unifile.UniFile | ||||
| import com.nononsenseapps.filepicker.AbstractFilePickerFragment | ||||
| import com.nononsenseapps.filepicker.FilePickerActivity | ||||
| import com.nononsenseapps.filepicker.FilePickerFragment | ||||
| import com.nononsenseapps.filepicker.LogicHandler | ||||
| import eu.kanade.tachiyomi.R | ||||
| import eu.kanade.tachiyomi.data.database.DatabaseHelper | ||||
| import eu.kanade.tachiyomi.data.preference.PreferencesHelper | ||||
| import eu.kanade.tachiyomi.data.preference.getOrDefault | ||||
| import eu.kanade.tachiyomi.util.inflate | ||||
| import eu.kanade.tachiyomi.util.plusAssign | ||||
| import eu.kanade.tachiyomi.widget.CustomLayoutPickerActivity | ||||
| import net.xpece.android.support.preference.MultiSelectListPreference | ||||
| import uy.kohesive.injekt.injectLazy | ||||
| import java.io.File | ||||
| @@ -151,27 +146,4 @@ class SettingsDownloadsFragment : SettingsFragment() { | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     class CustomLayoutPickerActivity : FilePickerActivity() { | ||||
|  | ||||
|         override fun getFragment(startPath: String?, mode: Int, allowMultiple: Boolean, allowCreateDir: Boolean): | ||||
|                 AbstractFilePickerFragment<File> { | ||||
|             val fragment = CustomLayoutFilePickerFragment() | ||||
|             fragment.setArgs(startPath, mode, allowMultiple, allowCreateDir) | ||||
|             return fragment | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     class CustomLayoutFilePickerFragment : FilePickerFragment() { | ||||
|         override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { | ||||
|             when (viewType) { | ||||
|                 LogicHandler.VIEWTYPE_DIR -> { | ||||
|                     val view = parent.inflate(R.layout.listitem_dir) | ||||
|                     return DirViewHolder(view) | ||||
|                 } | ||||
|                 else -> return super.onCreateViewHolder(parent, viewType) | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
| } | ||||
|   | ||||
| @@ -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) | ||||
|  | ||||
|   | ||||
| @@ -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 | ||||
| } | ||||
|   | ||||
| @@ -0,0 +1,33 @@ | ||||
| package eu.kanade.tachiyomi.widget | ||||
|  | ||||
| import android.support.v7.widget.RecyclerView | ||||
| import android.view.ViewGroup | ||||
| import com.nononsenseapps.filepicker.AbstractFilePickerFragment | ||||
| import com.nononsenseapps.filepicker.FilePickerActivity | ||||
| import com.nononsenseapps.filepicker.FilePickerFragment | ||||
| import com.nononsenseapps.filepicker.LogicHandler | ||||
| import eu.kanade.tachiyomi.R | ||||
| import eu.kanade.tachiyomi.util.inflate | ||||
| import java.io.File | ||||
|  | ||||
| class CustomLayoutPickerActivity : FilePickerActivity() { | ||||
|  | ||||
|     override fun getFragment(startPath: String?, mode: Int, allowMultiple: Boolean, allowCreateDir: Boolean): | ||||
|             AbstractFilePickerFragment<File> { | ||||
|         val fragment = CustomLayoutFilePickerFragment() | ||||
|         fragment.setArgs(startPath, mode, allowMultiple, allowCreateDir) | ||||
|         return fragment | ||||
|     } | ||||
| } | ||||
|  | ||||
| class CustomLayoutFilePickerFragment : FilePickerFragment() { | ||||
|     override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { | ||||
|         when (viewType) { | ||||
|             LogicHandler.VIEWTYPE_DIR -> { | ||||
|                 val view = parent.inflate(R.layout.listitem_dir) | ||||
|                 return DirViewHolder(view) | ||||
|             } | ||||
|             else -> return super.onCreateViewHolder(parent, viewType) | ||||
|         } | ||||
|     } | ||||
| } | ||||
		Reference in New Issue
	
	Block a user