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:
Bram van de Kerkhof 2017-04-04 17:42:17 +02:00 committed by inorichi
parent 3094d084d6
commit 0642889b64
39 changed files with 2166 additions and 1149 deletions

View File

@ -45,7 +45,7 @@
android:label="@string/label_categories"
android:parentActivityName=".ui.main.MainActivity" />
<activity
android:name=".ui.setting.SettingsDownloadsFragment$CustomLayoutPickerActivity"
android:name=".widget.CustomLayoutPickerActivity"
android:label="@string/app_name"
android:theme="@style/FilePickerTheme" />
<activity
@ -102,6 +102,14 @@
android:name=".data.updater.UpdateDownloaderService"
android:exported="false" />
<service
android:name=".data.backup.BackupCreateService"
android:exported="false"/>
<service
android:name=".data.backup.BackupRestoreService"
android:exported="false"/>
<meta-data
android:name="eu.kanade.tachiyomi.data.glide.AppGlideModule"
android:value="GlideModule" />

View File

@ -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
}
}

View File

@ -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)
}
}
}
}

View File

@ -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)
}
}
}

View File

@ -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()
}
}

View File

@ -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)
}
}

View File

@ -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"
}
}

View File

@ -0,0 +1,3 @@
package eu.kanade.tachiyomi.data.backup.models
data class DHistory(val url: String,val lastRead: Long)

View File

@ -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
}
}

View File

@ -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
}
}
}
}

View File

@ -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
}
}
}
}

View File

@ -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)
}
}
}
}

View File

@ -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
}

View File

@ -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
}
}

View File

@ -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
}
}

View File

@ -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
}
}
}
}

View File

@ -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
}
}
}
}

View File

@ -24,4 +24,6 @@ open class DatabaseHelper(context: Context)
inline fun inTransaction(block: () -> Unit) = db.inTransaction(block)
fun lowLevel() = db.lowLevel()
}

View File

@ -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())

View File

@ -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()
}

View File

@ -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)
}
}

View File

@ -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)

View File

@ -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)

View File

@ -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()
}
}
}

View File

@ -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
}
}

View File

@ -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)

View File

@ -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)

View File

@ -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()

View File

@ -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()
}
}
}
}
}

View File

@ -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)
}
}
}
}

View File

@ -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)

View File

@ -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
}

View File

@ -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)
}
}
}

View File

@ -36,9 +36,5 @@
android:icon="@drawable/ic_settings_black_24dp"
android:title="@string/label_settings"
android:checkable="false" />
<item
android:id="@+id/nav_drawer_backup"
android:icon="@drawable/ic_backup_black_24dp"
android:title="@string/label_backup" />
</group>
</menu>

View File

@ -48,6 +48,14 @@
<item>3</item>
</string-array>
<string-array name="backup_slots">
<item>1</item>
<item>2</item>
<item>3</item>
<item>4</item>
<item>5</item>
</string-array>
<string-array name="remove_after_read_slots">
<item>@string/disabled</item>
<item>@string/last_read_chapter</item>
@ -146,6 +154,24 @@
<item>48</item>
</string-array>
<string-array name="backup_update_interval">
<item>@string/update_never</item>
<item>@string/update_6hour</item>
<item>@string/update_12hour</item>
<item>@string/update_24hour</item>
<item>@string/update_48hour</item>
<item>@string/update_weekly</item>
</string-array>
<string-array name="backup_update_interval_values">
<item>0</item>
<item>6</item>
<item>12</item>
<item>24</item>
<item>48</item>
<item>168</item>
</string-array>
<string-array name="library_update_restrictions">
<item>@string/wifi</item>
<item>@string/charging</item>
@ -188,6 +214,22 @@
<item>2</item>
</string-array>
<string-array name="backup_options">
<item>@string/manga</item>
<item>@string/categories</item>
<item>@string/chapters</item>
<item>@string/track</item>
<item>@string/history</item>
</string-array>
<string-array name="backup_options_values">
<item>0</item>
<item>1</item>
<item>2</item>
<item>3</item>
<item>4</item>
</string-array>
<string-array name="languages_values">
<item/> <!-- system language -->
<item>bg</item>

View File

@ -52,6 +52,12 @@
<string name="pref_remove_after_marked_as_read_key" translatable="false">pref_remove_after_marked_as_read_key</string>
<string name="pref_last_used_category_key" translatable="false">last_used_category</string>
<string name="pref_create_local_backup_key" translatable="false">create_local_backup</string>
<string name="pref_restore_local_backup_key" translatable="false">restore_local_backup</string>
<string name="pref_backup_interval_key" translatable="false">backup_interval</string>
<string name="pref_backup_directory_key" translatable="false">backup_directory</string>
<string name="pref_backup_slots_key" translatable="false">backup_slots</string>
<string name="pref_source_languages" translatable="false">source_languages</string>
<string name="pref_category_tracking_accounts_key" translatable="false">category_tracking_accounts</string>
@ -73,4 +79,4 @@
<!-- String Fonts -->
<string name="font_roboto_medium" translatable="false">sans-serif</string>
<string name="font_roboto_regular" translatable="false">sans-serif</string>
</resources>
</resources>

View File

@ -1,7 +1,13 @@
<resources>
<string name="app_name" translatable="false">Tachiyomi</string>
<!--Models-->
<string name="name">Name</string>
<string name="categories">Categories</string>
<string name="manga">Manga</string>
<string name="chapters">Chapters</string>
<string name="track">Tracking</string>
<string name="history">History</string>
<!-- Activities and fragments labels (toolbar title) -->
<string name="label_settings">Settings</string>
@ -53,11 +59,13 @@
<string name="action_stop">Stop</string>
<string name="action_pause">Pause</string>
<string name="action_clear">Clear</string>
<string name="action_close">Close</string>
<string name="action_previous_chapter">Previous chapter</string>
<string name="action_next_chapter">Next chapter</string>
<string name="action_retry">Retry</string>
<string name="action_remove">Remove</string>
<string name="action_resume">Resume</string>
<string name="action_move">Move</string>
<string name="action_open_in_browser">Open in browser</string>
<string name="action_add_to_home_screen">Add to home screen</string>
<string name="action_display_mode">Change display mode</string>
@ -72,6 +80,10 @@
<string name="action_save">Save</string>
<string name="action_reset">Reset</string>
<string name="action_undo">Undo</string>
<string name="action_export">Export</string>
<string name="action_open_log">Open log</string>
<string name="action_create">Create</string>
<string name="action_restore">Restore</string>
<!-- Operations -->
<string name="deleting">Deleting…</string>
@ -101,6 +113,8 @@
<string name="update_12hour">Every 12 hours</string>
<string name="update_24hour">Daily</string>
<string name="update_48hour">Every 2 days</string>
<string name="update_weekly">Weekly</string>
<string name="update_monthly">Monthly</string>
<string name="pref_library_update_categories">Categories to include in global update</string>
<string name="all">All</string>
<string name="pref_library_update_restriction">Library update restrictions</string>
@ -181,6 +195,29 @@
<!-- Sync section -->
<string name="services">Services</string>
<!-- Backup section -->
<string name="backup">Backup</string>
<string name="pref_create_backup">Create backup</string>
<string name="pref_create_backup_summ">Can be used to restore current library</string>
<string name="pref_restore_backup">Restore backup</string>
<string name="pref_restore_backup_summ">Restore library from backup file</string>
<string name="pref_backup_directory">Backup directory</string>
<string name="pref_backup_service_category">Service</string>
<string name="pref_backup_interval">Backup frequency</string>
<string name="pref_backup_slots">Max automatic backups</string>
<string name="dialog_restoring_backup">Restoring backup\n%1$s added to library</string>
<string name="source_not_found">Source not found</string>
<string name="dialog_restoring_source_not_found">Restoring backup\n%1$s source not found</string>
<string name="backup_created">Backup created</string>
<string name="restore_completed">Restore completed</string>
<string name="error_opening_log">Could not open log</string>
<string name="restore_completed_content">Restore took %1$s.\n%2$s errors found.</string>
<string name="backup_restore_content">Restore uses source to fetch data, carrier costs may apply.\nAlso make sure you are properly logged in sources that require so before restoring.</string>
<string name="file_saved">File saved at %1$s</string>
<string name="backup_choice">What do you want to backup?</string>
<string name="restoring_backup">Restoring backup</string>
<string name="creating_backup">Creating backup</string>
<!-- Advanced section -->
<string name="pref_clear_chapter_cache">Clear chapter cache</string>
<string name="used_cache">Used: %1$s</string>
@ -290,7 +327,6 @@
<string name="score">Score</string>
<string name="title">Title</string>
<string name="status">Status</string>
<string name="chapters">Chapters</string>
<!-- Category activity -->
<string name="error_category_exists">A category with this name already exists!</string>
@ -324,13 +360,6 @@
<string name="confirm_set_image_as_cover">Do you want to set this image as the cover?</string>
<string name="viewer_for_this_series">Viewer for this series</string>
<!-- Backup fragment -->
<string name="backup">Backup</string>
<string name="restore">Restore</string>
<string name="backup_please_wait">Backup in progress. Please wait…</string>
<string name="backup_completed">Backup successfully restored</string>
<string name="restore_please_wait">Restoring backup. Please wait…</string>
<!-- Recent manga fragment -->
<string name="recent_manga_source">%1$s - Ch.%2$s</string>

View File

@ -0,0 +1,48 @@
<?xml version="1.0" encoding="utf-8"?>
<PreferenceScreen xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<PreferenceScreen
android:icon="@drawable/ic_backup_black_24dp"
android:key="backup_screen"
android:persistent="false"
android:title="Backup"
app:asp_tintEnabled="true">
<Preference
android:key="@string/pref_create_local_backup_key"
android:summary="@string/pref_create_backup_summ"
android:title="@string/pref_create_backup" />
<Preference
android:key="@string/pref_restore_local_backup_key"
android:summary="@string/pref_restore_backup_summ"
android:title="@string/pref_restore_backup" />
<PreferenceCategory
android:persistent="false"
android:title="@string/pref_backup_service_category" />
<eu.kanade.tachiyomi.widget.preference.IntListPreference
android:defaultValue="0"
android:entries="@array/backup_update_interval"
android:entryValues="@array/backup_update_interval_values"
android:key="@string/pref_backup_interval_key"
android:summary="%s"
android:title="@string/pref_backup_interval"/>
<Preference
android:key="@string/pref_backup_directory_key"
android:title="@string/pref_backup_directory" />
<eu.kanade.tachiyomi.widget.preference.IntListPreference
android:defaultValue="1"
android:entries="@array/backup_slots"
android:entryValues="@array/backup_slots"
android:key="@string/pref_backup_slots_key"
android:summary="%s"
android:title="@string/pref_backup_slots" />
</PreferenceScreen>
</PreferenceScreen>

View File

@ -1,568 +1,412 @@
package eu.kanade.tachiyomi.data.backup
import android.app.Application
import android.content.Context
import android.os.Build
import com.google.gson.Gson
import com.google.gson.JsonElement
import com.github.salomonbrys.kotson.fromJson
import com.google.gson.JsonArray
import com.google.gson.JsonObject
import eu.kanade.tachiyomi.BuildConfig
import eu.kanade.tachiyomi.CustomRobolectricGradleTestRunner
import eu.kanade.tachiyomi.data.backup.models.Backup
import eu.kanade.tachiyomi.data.backup.models.DHistory
import eu.kanade.tachiyomi.data.database.DatabaseHelper
import eu.kanade.tachiyomi.data.database.models.*
import eu.kanade.tachiyomi.source.SourceManager
import eu.kanade.tachiyomi.source.online.HttpSource
import org.assertj.core.api.Assertions.assertThat
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.Mockito
import org.mockito.Mockito.*
import org.robolectric.RuntimeEnvironment
import org.robolectric.annotation.Config
import uy.kohesive.injekt.injectLazy
import java.util.*
import rx.Observable
import rx.observers.TestSubscriber
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.InjektModule
import uy.kohesive.injekt.api.InjektRegistrar
import uy.kohesive.injekt.api.addSingleton
/**
* Test class for the [BackupManager].
* Note that this does not include the backup create/restore services.
*/
@Config(constants = BuildConfig::class, sdk = intArrayOf(Build.VERSION_CODES.LOLLIPOP))
@RunWith(CustomRobolectricGradleTestRunner::class)
class BackupTest {
// Create root object
var root = JsonObject()
val gson: Gson by injectLazy()
// Create information object
var information = JsonObject()
lateinit var db: DatabaseHelper
// Create manga array
var mangaEntries = JsonArray()
// Create category array
var categoryEntries = JsonArray()
lateinit var app: Application
lateinit var context: Context
lateinit var source: HttpSource
lateinit var backupManager: BackupManager
lateinit var root: JsonObject
lateinit var db: DatabaseHelper
@Before
fun setup() {
val app = RuntimeEnvironment.application
db = DatabaseHelper(app)
backupManager = BackupManager(db)
root = JsonObject()
}
@Test
fun testRestoreCategory() {
val catName = "cat"
root = createRootJson(null, toJson(createCategories(catName)))
backupManager.restoreFromJson(root)
val dbCats = db.getCategories().executeAsBlocking()
assertThat(dbCats).hasSize(1)
assertThat(dbCats[0].name).isEqualTo(catName)
app = RuntimeEnvironment.application
context = app.applicationContext
backupManager = BackupManager(context)
db = backupManager.databaseHelper
// Mock the source manager
val module = object : InjektModule {
override fun InjektRegistrar.registerInjectables() {
addSingleton(Mockito.mock(SourceManager::class.java, RETURNS_DEEP_STUBS))
}
}
Injekt.importModule(module)
source = mock(HttpSource::class.java)
`when`(backupManager.sourceManager.get(anyLong())).thenReturn(source)
root.add(Backup.MANGAS, mangaEntries)
root.add(Backup.CATEGORIES, categoryEntries)
}
/**
* Test that checks if no crashes when no categories in library.
*/
@Test
fun testRestoreEmptyCategory() {
root = createRootJson(null, toJson(ArrayList<Any>()))
backupManager.restoreFromJson(root)
// Initialize json with version 2
initializeJsonTest(2)
// Create backup of empty database
backupManager.backupCategories(categoryEntries)
// Restore Json
backupManager.restoreCategories(categoryEntries)
// Check if empty
val dbCats = db.getCategories().executeAsBlocking()
assertThat(dbCats).isEmpty()
}
/**
* Test to check if single category gets restored
*/
@Test
fun testRestoreExistingCategory() {
val catName = "cat"
db.insertCategory(createCategory(catName)).executeAsBlocking()
fun testRestoreSingleCategory() {
// Initialize json with version 2
initializeJsonTest(2)
root = createRootJson(null, toJson(createCategories(catName)))
backupManager.restoreFromJson(root)
// Create category and add to json
val category = addSingleCategory("category")
val dbCats = db.getCategories().executeAsBlocking()
// Restore Json
backupManager.restoreCategories(categoryEntries)
// Check if successful
val dbCats = backupManager.databaseHelper.getCategories().executeAsBlocking()
assertThat(dbCats).hasSize(1)
assertThat(dbCats[0].name).isEqualTo(catName)
assertThat(dbCats[0].name).isEqualTo(category.name)
}
/**
* Test to check if multiple categories get restored.
*/
@Test
fun testRestoreCategories() {
root = createRootJson(null, toJson(createCategories("cat", "cat2", "cat3")))
backupManager.restoreFromJson(root)
fun testRestoreMultipleCategories() {
// Initialize json with version 2
initializeJsonTest(2)
val dbCats = db.getCategories().executeAsBlocking()
assertThat(dbCats).hasSize(3)
}
@Test
fun testRestoreExistingCategories() {
db.insertCategories(createCategories("cat", "cat2")).executeAsBlocking()
root = createRootJson(null, toJson(createCategories("cat", "cat2", "cat3")))
backupManager.restoreFromJson(root)
val dbCats = db.getCategories().executeAsBlocking()
assertThat(dbCats).hasSize(3)
}
@Test
fun testRestoreExistingCategoriesAlt() {
db.insertCategories(createCategories("cat", "cat2", "cat3")).executeAsBlocking()
root = createRootJson(null, toJson(createCategories("cat", "cat2")))
backupManager.restoreFromJson(root)
val dbCats = db.getCategories().executeAsBlocking()
assertThat(dbCats).hasSize(3)
// Create category and add to json
val category = addSingleCategory("category")
val category2 = addSingleCategory("category2")
val category3 = addSingleCategory("category3")
val category4 = addSingleCategory("category4")
val category5 = addSingleCategory("category5")
// Insert category to test if no duplicates on restore.
db.insertCategory(category).executeAsBlocking()
// Restore Json
backupManager.restoreCategories(categoryEntries)
// Check if successful
val dbCats = backupManager.databaseHelper.getCategories().executeAsBlocking()
assertThat(dbCats).hasSize(5)
assertThat(dbCats[0].name).isEqualTo(category.name)
assertThat(dbCats[1].name).isEqualTo(category2.name)
assertThat(dbCats[2].name).isEqualTo(category3.name)
assertThat(dbCats[3].name).isEqualTo(category4.name)
assertThat(dbCats[4].name).isEqualTo(category5.name)
}
/**
* Test if restore of manga is successful
*/
@Test
fun testRestoreManga() {
val mangaName = "title"
val mangas = createMangas(mangaName)
val elements = ArrayList<JsonElement>()
for (manga in mangas) {
val entry = JsonObject()
entry.add("manga", toJson(manga))
elements.add(entry)
}
root = createRootJson(toJson(elements), null)
backupManager.restoreFromJson(root)
// Initialize json with version 2
initializeJsonTest(2)
val dbMangas = db.getMangas().executeAsBlocking()
assertThat(dbMangas).hasSize(1)
assertThat(dbMangas[0].title).isEqualTo(mangaName)
// Add manga to database
val manga = getSingleManga("One Piece")
manga.viewer = 3
manga.id = db.insertManga(manga).executeAsBlocking().insertedId()
var favoriteManga = backupManager.databaseHelper.getFavoriteMangas().executeAsBlocking()
assertThat(favoriteManga).hasSize(1)
assertThat(favoriteManga[0].viewer).isEqualTo(3)
// Update json with all options enabled
mangaEntries.add(backupManager.backupMangaObject(manga,1))
// Change manga in database to default values
val dbManga = getSingleManga("One Piece")
dbManga.id = manga.id
db.insertManga(dbManga).executeAsBlocking()
favoriteManga = backupManager.databaseHelper.getFavoriteMangas().executeAsBlocking()
assertThat(favoriteManga).hasSize(1)
assertThat(favoriteManga[0].viewer).isEqualTo(0)
// Restore local manga
backupManager.restoreMangaNoFetch(manga,dbManga)
// Test if restore successful
favoriteManga = backupManager.databaseHelper.getFavoriteMangas().executeAsBlocking()
assertThat(favoriteManga).hasSize(1)
assertThat(favoriteManga[0].viewer).isEqualTo(3)
// Clear database to test manga fetch
clearDatabase()
// Test if successful
favoriteManga = backupManager.databaseHelper.getFavoriteMangas().executeAsBlocking()
assertThat(favoriteManga).hasSize(0)
// Restore Json
// Create JSON from manga to test parser
val json = backupManager.parser.toJsonTree(manga)
// Restore JSON from manga to test parser
val jsonManga = backupManager.parser.fromJson<MangaImpl>(json)
// Restore manga with fetch observable
val networkManga = getSingleManga("One Piece")
networkManga.description = "This is a description"
`when`(source.fetchMangaDetails(jsonManga)).thenReturn(Observable.just(networkManga))
val obs = backupManager.restoreMangaFetchObservable(source, jsonManga)
val testSubscriber = TestSubscriber<Manga>()
obs.subscribe(testSubscriber)
testSubscriber.assertNoErrors()
// Check if restore successful
val dbCats = backupManager.databaseHelper.getFavoriteMangas().executeAsBlocking()
assertThat(dbCats).hasSize(1)
assertThat(dbCats[0].viewer).isEqualTo(3)
assertThat(dbCats[0].description).isEqualTo("This is a description")
}
/**
* Test if chapter restore is successful
*/
@Test
fun testRestoreExistingManga() {
val mangaName = "title"
val manga = createManga(mangaName)
fun testRestoreChapters() {
// Initialize json with version 2
initializeJsonTest(2)
db.insertManga(manga).executeAsBlocking()
// Insert manga
val manga = getSingleManga("One Piece")
manga.id = backupManager.databaseHelper.insertManga(manga).executeAsBlocking().insertedId()
val elements = ArrayList<JsonElement>()
val entry = JsonObject()
entry.add("manga", toJson(manga))
elements.add(entry)
root = createRootJson(toJson(elements), null)
backupManager.restoreFromJson(root)
val dbMangas = db.getMangas().executeAsBlocking()
assertThat(dbMangas).hasSize(1)
}
@Test
fun testRestoreExistingMangaWithUpdatedFields() {
// Store a manga in db
val mangaName = "title"
val updatedThumbnailUrl = "updated thumbnail url"
var manga = createManga(mangaName)
manga.chapter_flags = 1024
manga.thumbnail_url = updatedThumbnailUrl
db.insertManga(manga).executeAsBlocking()
// Add an entry for a new manga with different attributes
manga = createManga(mangaName)
manga.chapter_flags = 512
val entry = JsonObject()
entry.add("manga", toJson(manga))
// Append the entry to the backup list
val elements = ArrayList<JsonElement>()
elements.add(entry)
// Restore from json
root = createRootJson(toJson(elements), null)
backupManager.restoreFromJson(root)
val dbMangas = db.getMangas().executeAsBlocking()
assertThat(dbMangas).hasSize(1)
assertThat(dbMangas[0].thumbnail_url).isEqualTo(updatedThumbnailUrl)
assertThat(dbMangas[0].chapter_flags).isEqualTo(512)
}
@Test
fun testRestoreChaptersForManga() {
// Create a manga and 3 chapters
val manga = createManga("title")
manga.id = 1L
val chapters = createChapters(manga, "1", "2", "3")
// Add an entry for the manga
val entry = JsonObject()
entry.add("manga", toJson(manga))
entry.add("chapters", toJson(chapters))
// Append the entry to the backup list
val mangas = ArrayList<JsonElement>()
mangas.add(entry)
// Restore from json
root = createRootJson(toJson(mangas), null)
backupManager.restoreFromJson(root)
val dbManga = db.getManga(1).executeAsBlocking()
assertThat(dbManga).isNotNull()
val dbChapters = db.getChapters(dbManga!!).executeAsBlocking()
assertThat(dbChapters).hasSize(3)
}
@Test
fun testRestoreChaptersForExistingManga() {
val mangaId: Long = 3
// Create a manga and 3 chapters
val manga = createManga("title")
manga.id = mangaId
val chapters = createChapters(manga, "1", "2", "3")
db.insertManga(manga).executeAsBlocking()
// Add an entry for the manga
val entry = JsonObject()
entry.add("manga", toJson(manga))
entry.add("chapters", toJson(chapters))
// Append the entry to the backup list
val mangas = ArrayList<JsonElement>()
mangas.add(entry)
// Restore from json
root = createRootJson(toJson(mangas), null)
backupManager.restoreFromJson(root)
val dbManga = db.getManga(mangaId).executeAsBlocking()
assertThat(dbManga).isNotNull()
val dbChapters = db.getChapters(dbManga!!).executeAsBlocking()
assertThat(dbChapters).hasSize(3)
}
@Test
fun testRestoreExistingChaptersForExistingManga() {
val mangaId: Long = 5
// Store a manga and 3 chapters
val manga = createManga("title")
manga.id = mangaId
var chapters = createChapters(manga, "1", "2", "3")
db.insertManga(manga).executeAsBlocking()
db.insertChapters(chapters).executeAsBlocking()
// The backup contains a existing chapter and a new one, so it should have 4 chapters
chapters = createChapters(manga, "3", "4")
// Add an entry for the manga
val entry = JsonObject()
entry.add("manga", toJson(manga))
entry.add("chapters", toJson(chapters))
// Append the entry to the backup list
val mangas = ArrayList<JsonElement>()
mangas.add(entry)
// Restore from json
root = createRootJson(toJson(mangas), null)
backupManager.restoreFromJson(root)
val dbManga = db.getManga(mangaId).executeAsBlocking()
assertThat(dbManga).isNotNull()
val dbChapters = db.getChapters(dbManga!!).executeAsBlocking()
assertThat(dbChapters).hasSize(4)
}
@Test
fun testRestoreCategoriesForManga() {
// Create a manga
val manga = createManga("title")
// Create categories
val categories = createCategories("cat1", "cat2", "cat3")
// Add an entry for the manga
val entry = JsonObject()
entry.add("manga", toJson(manga))
entry.add("categories", toJson(createStringCategories("cat1")))
// Append the entry to the backup list
val mangas = ArrayList<JsonElement>()
mangas.add(entry)
// Restore from json
root = createRootJson(toJson(mangas), toJson(categories))
backupManager.restoreFromJson(root)
val dbManga = db.getManga(1).executeAsBlocking()
assertThat(dbManga).isNotNull()
val result = db.getCategoriesForManga(dbManga!!).executeAsBlocking()
assertThat(result).hasSize(1)
assertThat(result).contains(Category.create("cat1"))
assertThat(result).doesNotContain(Category.create("cat2"))
}
@Test
fun testRestoreCategoriesForExistingManga() {
// Store a manga
val manga = createManga("title")
db.insertManga(manga).executeAsBlocking()
// Create categories
val categories = createCategories("cat1", "cat2", "cat3")
// Add an entry for the manga
val entry = JsonObject()
entry.add("manga", toJson(manga))
entry.add("categories", toJson(createStringCategories("cat1")))
// Append the entry to the backup list
val mangas = ArrayList<JsonElement>()
mangas.add(entry)
// Restore from json
root = createRootJson(toJson(mangas), toJson(categories))
backupManager.restoreFromJson(root)
val dbManga = db.getManga(1).executeAsBlocking()
assertThat(dbManga).isNotNull()
val result = db.getCategoriesForManga(dbManga!!).executeAsBlocking()
assertThat(result).hasSize(1)
assertThat(result).contains(Category.create("cat1"))
assertThat(result).doesNotContain(Category.create("cat2"))
}
@Test
fun testRestoreMultipleCategoriesForManga() {
// Create a manga
val manga = createManga("title")
// Create categories
val categories = createCategories("cat1", "cat2", "cat3")
// Add an entry for the manga
val entry = JsonObject()
entry.add("manga", toJson(manga))
entry.add("categories", toJson(createStringCategories("cat1", "cat3")))
// Append the entry to the backup list
val mangas = ArrayList<JsonElement>()
mangas.add(entry)
// Restore from json
root = createRootJson(toJson(mangas), toJson(categories))
backupManager.restoreFromJson(root)
val dbManga = db.getManga(1).executeAsBlocking()
assertThat(dbManga).isNotNull()
val result = db.getCategoriesForManga(dbManga!!).executeAsBlocking()
assertThat(result).hasSize(2)
assertThat(result).contains(Category.create("cat1"), Category.create("cat3"))
assertThat(result).doesNotContain(Category.create("cat2"))
}
@Test
fun testRestoreMultipleCategoriesForExistingMangaAndCategory() {
// Store a manga and a category
val manga = createManga("title")
manga.id = 1L
db.insertManga(manga).executeAsBlocking()
val cat = createCategory("cat1")
cat.id = 1
db.insertCategory(cat).executeAsBlocking()
db.insertMangaCategory(MangaCategory.create(manga, cat)).executeAsBlocking()
// Create categories
val categories = createCategories("cat1", "cat2", "cat3")
// Add an entry for the manga
val entry = JsonObject()
entry.add("manga", toJson(manga))
entry.add("categories", toJson(createStringCategories("cat1", "cat2")))
// Append the entry to the backup list
val mangas = ArrayList<JsonElement>()
mangas.add(entry)
// Restore from json
root = createRootJson(toJson(mangas), toJson(categories))
backupManager.restoreFromJson(root)
val dbManga = db.getManga(1).executeAsBlocking()
assertThat(dbManga).isNotNull()
val result = db.getCategoriesForManga(dbManga!!).executeAsBlocking()
assertThat(result).hasSize(2)
assertThat(result).contains(Category.create("cat1"), Category.create("cat2"))
assertThat(result).doesNotContain(Category.create("cat3"))
}
@Test
fun testRestoreSyncForManga() {
// Create a manga and track
val manga = createManga("title")
manga.id = 1L
val track = createTrack(manga, 1, 2, 3)
// Add an entry for the manga
val entry = JsonObject()
entry.add("manga", toJson(manga))
entry.add("sync", toJson(track))
// Append the entry to the backup list
val mangas = ArrayList<JsonElement>()
mangas.add(entry)
// Restore from json
root = createRootJson(toJson(mangas), null)
backupManager.restoreFromJson(root)
val dbManga = db.getManga(1).executeAsBlocking()
assertThat(dbManga).isNotNull()
val dbSync = db.getTracks(dbManga!!).executeAsBlocking()
assertThat(dbSync).hasSize(3)
}
@Test
fun testRestoreSyncForExistingManga() {
val mangaId: Long = 3
// Create a manga and 3 sync
val manga = createManga("title")
manga.id = mangaId
val track = createTrack(manga, 1, 2, 3)
db.insertManga(manga).executeAsBlocking()
// Add an entry for the manga
val entry = JsonObject()
entry.add("manga", toJson(manga))
entry.add("sync", toJson(track))
// Append the entry to the backup list
val mangas = ArrayList<JsonElement>()
mangas.add(entry)
// Restore from json
root = createRootJson(toJson(mangas), null)
backupManager.restoreFromJson(root)
val dbManga = db.getManga(mangaId).executeAsBlocking()
assertThat(dbManga).isNotNull()
val dbSync = db.getTracks(dbManga!!).executeAsBlocking()
assertThat(dbSync).hasSize(3)
}
@Test
fun testRestoreExistingSyncForExistingManga() {
val mangaId: Long = 5
// Store a manga and 3 sync
val manga = createManga("title")
manga.id = mangaId
var track = createTrack(manga, 1, 2, 3)
db.insertManga(manga).executeAsBlocking()
db.insertTracks(track).executeAsBlocking()
// The backup contains a existing sync and a new one, so it should have 4 sync
track = createTrack(manga, 3, 4)
// Add an entry for the manga
val entry = JsonObject()
entry.add("manga", toJson(manga))
entry.add("sync", toJson(track))
// Append the entry to the backup list
val mangas = ArrayList<JsonElement>()
mangas.add(entry)
// Restore from json
root = createRootJson(toJson(mangas), null)
backupManager.restoreFromJson(root)
val dbManga = db.getManga(mangaId).executeAsBlocking()
assertThat(dbManga).isNotNull()
val dbSync = db.getTracks(dbManga!!).executeAsBlocking()
assertThat(dbSync).hasSize(4)
}
private fun createRootJson(mangas: JsonElement?, categories: JsonElement?): JsonObject {
val root = JsonObject()
if (mangas != null)
root.add("mangas", mangas)
if (categories != null)
root.add("categories", categories)
return root
}
private fun createCategory(name: String): Category {
val c = CategoryImpl()
c.name = name
return c
}
private fun createCategories(vararg names: String): List<Category> {
val cats = ArrayList<Category>()
for (name in names) {
cats.add(createCategory(name))
}
return cats
}
private fun createStringCategories(vararg names: String): List<String> {
val cats = ArrayList<String>()
for (name in names) {
cats.add(name)
}
return cats
}
private fun createManga(title: String): Manga {
val m = Manga.create(1)
m.title = title
m.author = ""
m.artist = ""
m.thumbnail_url = ""
m.genre = "a list of genres"
m.description = "long description"
m.url = "url to manga"
m.favorite = true
return m
}
private fun createMangas(vararg titles: String): List<Manga> {
val mangas = ArrayList<Manga>()
for (title in titles) {
mangas.add(createManga(title))
}
return mangas
}
private fun createChapter(manga: Manga, url: String): Chapter {
val c = Chapter.create()
c.url = url
c.name = url
c.manga_id = manga.id
return c
}
private fun createChapters(manga: Manga, vararg urls: String): List<Chapter> {
// Create restore list
val chapters = ArrayList<Chapter>()
for (url in urls) {
chapters.add(createChapter(manga, url))
for (i in 1..8){
val chapter = getSingleChapter("Chapter $i")
chapter.read = true
chapters.add(chapter)
}
return chapters
// Check parser
val chaptersJson = backupManager.parser.toJsonTree(chapters)
val restoredChapters = backupManager.parser.fromJson<List<ChapterImpl>>(chaptersJson)
// Fetch chapters from upstream
// Create list
val chaptersRemote = ArrayList<Chapter>()
(1..10).mapTo(chaptersRemote) { getSingleChapter("Chapter $it") }
`when`(source.fetchChapterList(manga)).thenReturn(Observable.just(chaptersRemote))
// Call restoreChapterFetchObservable
val obs = backupManager.restoreChapterFetchObservable(source, manga, restoredChapters)
val testSubscriber = TestSubscriber<Pair<List<Chapter>, List<Chapter>>>()
obs.subscribe(testSubscriber)
testSubscriber.assertNoErrors()
val dbCats = backupManager.databaseHelper.getChapters(manga).executeAsBlocking()
assertThat(dbCats).hasSize(10)
assertThat(dbCats[0].read).isEqualTo(true)
}
private fun createTrack(manga: Manga, syncId: Int): Track {
val m = Track.create(syncId)
m.manga_id = manga.id!!
m.title = "title"
return m
/**
* Test to check if history restore works
*/
@Test
fun restoreHistoryForManga(){
// Initialize json with version 2
initializeJsonTest(2)
val manga = getSingleManga("One Piece")
manga.id = backupManager.databaseHelper.insertManga(manga).executeAsBlocking().insertedId()
// Create chapter
val chapter = getSingleChapter("Chapter 1")
chapter.manga_id = manga.id
chapter.read = true
chapter.id = backupManager.databaseHelper.insertChapter(chapter).executeAsBlocking().insertedId()
val historyJson = getSingleHistory(chapter)
val historyList = ArrayList<DHistory>()
historyList.add(historyJson)
// Check parser
val historyListJson = backupManager.parser.toJsonTree(historyList)
val history = backupManager.parser.fromJson<List<DHistory>>(historyListJson)
// Restore categories
backupManager.restoreHistoryForManga(history)
val historyDB = backupManager.databaseHelper.getHistoryByMangaId(manga.id!!).executeAsBlocking()
assertThat(historyDB).hasSize(1)
assertThat(historyDB[0].last_read).isEqualTo(1000)
}
private fun createTrack(manga: Manga, vararg syncIds: Int): List<Track> {
val ms = ArrayList<Track>()
for (title in syncIds) {
ms.add(createTrack(manga, title))
}
return ms
/**
* Test to check if tracking restore works
*/
@Test
fun restoreTrackForManga() {
// Initialize json with version 2
initializeJsonTest(2)
// Create mangas
val manga = getSingleManga("One Piece")
val manga2 = getSingleManga("Bleach")
manga.id = backupManager.databaseHelper.insertManga(manga).executeAsBlocking().insertedId()
manga2.id = backupManager.databaseHelper.insertManga(manga2).executeAsBlocking().insertedId()
// Create track and add it to database
// This tests duplicate errors.
val track = getSingleTrack(manga)
track.last_chapter_read = 5
backupManager.databaseHelper.insertTrack(track).executeAsBlocking()
var trackDB = backupManager.databaseHelper.getTracks(manga).executeAsBlocking()
assertThat(trackDB).hasSize(1)
assertThat(trackDB[0].last_chapter_read).isEqualTo(5)
track.last_chapter_read = 7
// Create track for different manga to test track not in database
val track2 = getSingleTrack(manga2)
track2.last_chapter_read = 10
// Check parser and restore already in database
var trackList = listOf(track)
//Check parser
var trackListJson = backupManager.parser.toJsonTree(trackList)
var trackListRestore = backupManager.parser.fromJson<List<TrackImpl>>(trackListJson)
backupManager.restoreTrackForManga(manga, trackListRestore)
// Assert if restore works.
trackDB = backupManager.databaseHelper.getTracks(manga).executeAsBlocking()
assertThat(trackDB).hasSize(1)
assertThat(trackDB[0].last_chapter_read).isEqualTo(7)
// Check parser and restore already in database with lower chapter_read
track.last_chapter_read = 5
trackList = listOf(track)
backupManager.restoreTrackForManga(manga, trackList)
// Assert if restore works.
trackDB = backupManager.databaseHelper.getTracks(manga).executeAsBlocking()
assertThat(trackDB).hasSize(1)
assertThat(trackDB[0].last_chapter_read).isEqualTo(7)
// Check parser and restore, track not in database
trackList = listOf(track2)
//Check parser
trackListJson = backupManager.parser.toJsonTree(trackList)
trackListRestore = backupManager.parser.fromJson<List<TrackImpl>>(trackListJson)
backupManager.restoreTrackForManga(manga2, trackListRestore)
// Assert if restore works.
trackDB = backupManager.databaseHelper.getTracks(manga2).executeAsBlocking()
assertThat(trackDB).hasSize(1)
assertThat(trackDB[0].last_chapter_read).isEqualTo(10)
}
private fun toJson(element: Any): JsonElement {
return gson.toJsonTree(element)
fun clearJson() {
root = JsonObject()
information = JsonObject()
mangaEntries = JsonArray()
categoryEntries = JsonArray()
}
fun initializeJsonTest(version: Int) {
clearJson()
backupManager.setVersion(version)
}
fun addSingleCategory(name: String): Category {
val category = Category.create(name)
val catJson = backupManager.parser.toJsonTree(category)
categoryEntries.add(catJson)
return category
}
fun clearDatabase(){
db.deleteMangas().executeAsBlocking()
db.deleteHistory().executeAsBlocking()
}
fun getSingleHistory(chapter: Chapter): DHistory {
return DHistory(chapter.url, 1000)
}
private fun getSingleTrack(manga: Manga): TrackImpl {
val track = TrackImpl()
track.title = manga.title
track.manga_id = manga.id!!
track.remote_id = 1
track.sync_id = 1
return track
}
private fun getSingleManga(title: String): MangaImpl {
val manga = MangaImpl()
manga.source = 1
manga.title = title
manga.url = "/manga/$title"
manga.favorite = true
return manga
}
private fun getSingleChapter(name: String): ChapterImpl {
val chapter = ChapterImpl()
chapter.name = name
chapter.url = "/read-online/$name-page-1.html"
return chapter
}
}