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 changespull/724/head
parent
3094d084d6
commit
0642889b64
@ -0,0 +1,166 @@
|
||||
package eu.kanade.tachiyomi.data.backup
|
||||
|
||||
import android.app.IntentService
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import com.github.salomonbrys.kotson.set
|
||||
import com.google.gson.JsonArray
|
||||
import com.google.gson.JsonObject
|
||||
import com.hippo.unifile.UniFile
|
||||
import eu.kanade.tachiyomi.data.backup.models.Backup
|
||||
import eu.kanade.tachiyomi.data.backup.models.Backup.CATEGORIES
|
||||
import eu.kanade.tachiyomi.data.backup.models.Backup.MANGAS
|
||||
import eu.kanade.tachiyomi.data.backup.models.Backup.VERSION
|
||||
import eu.kanade.tachiyomi.data.database.models.Manga
|
||||
import eu.kanade.tachiyomi.ui.setting.SettingsBackupFragment
|
||||
import eu.kanade.tachiyomi.util.sendLocalBroadcast
|
||||
import timber.log.Timber
|
||||
import eu.kanade.tachiyomi.BuildConfig.APPLICATION_ID as ID
|
||||
|
||||
/**
|
||||
* [IntentService] used to backup [Manga] information to [JsonArray]
|
||||
*/
|
||||
class BackupCreateService : IntentService(NAME) {
|
||||
|
||||
companion object {
|
||||
// Name of class
|
||||
private const val NAME = "BackupCreateService"
|
||||
|
||||
// Uri as string
|
||||
private const val EXTRA_URI = "$ID.$NAME.EXTRA_URI"
|
||||
// Backup called from job
|
||||
private const val EXTRA_IS_JOB = "$ID.$NAME.EXTRA_IS_JOB"
|
||||
// Options for backup
|
||||
private const val EXTRA_FLAGS = "$ID.$NAME.EXTRA_FLAGS"
|
||||
|
||||
// Filter options
|
||||
internal const val BACKUP_CATEGORY = 0x1
|
||||
internal const val BACKUP_CATEGORY_MASK = 0x1
|
||||
internal const val BACKUP_CHAPTER = 0x2
|
||||
internal const val BACKUP_CHAPTER_MASK = 0x2
|
||||
internal const val BACKUP_HISTORY = 0x4
|
||||
internal const val BACKUP_HISTORY_MASK = 0x4
|
||||
internal const val BACKUP_TRACK = 0x8
|
||||
internal const val BACKUP_TRACK_MASK = 0x8
|
||||
internal const val BACKUP_ALL = 0xF
|
||||
|
||||
/**
|
||||
* Make a backup from library
|
||||
*
|
||||
* @param context context of application
|
||||
* @param path path of Uri
|
||||
* @param flags determines what to backup
|
||||
* @param isJob backup called from job
|
||||
*/
|
||||
fun makeBackup(context: Context, path: String, flags: Int, isJob: Boolean = false) {
|
||||
val intent = Intent(context, BackupCreateService::class.java).apply {
|
||||
putExtra(EXTRA_URI, path)
|
||||
putExtra(EXTRA_IS_JOB, isJob)
|
||||
putExtra(EXTRA_FLAGS, flags)
|
||||
}
|
||||
context.startService(intent)
|
||||
}
|
||||
}
|
||||
|
||||
private val backupManager by lazy { BackupManager(this) }
|
||||
|
||||
override fun onHandleIntent(intent: Intent?) {
|
||||
if (intent == null) return
|
||||
|
||||
// Get values
|
||||
val uri = intent.getStringExtra(EXTRA_URI)
|
||||
val isJob = intent.getBooleanExtra(EXTRA_IS_JOB, false)
|
||||
val flags = intent.getIntExtra(EXTRA_FLAGS, 0)
|
||||
// Create backup
|
||||
createBackupFromApp(Uri.parse(uri), flags, isJob)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create backup Json file from database
|
||||
*
|
||||
* @param uri path of Uri
|
||||
* @param isJob backup called from job
|
||||
*/
|
||||
fun createBackupFromApp(uri: Uri, flags: Int, isJob: Boolean) {
|
||||
// Create root object
|
||||
val root = JsonObject()
|
||||
|
||||
// Create information object
|
||||
val information = JsonObject()
|
||||
|
||||
// Create manga array
|
||||
val mangaEntries = JsonArray()
|
||||
|
||||
// Create category array
|
||||
val categoryEntries = JsonArray()
|
||||
|
||||
// Add value's to root
|
||||
root[VERSION] = Backup.CURRENT_VERSION
|
||||
root[MANGAS] = mangaEntries
|
||||
root[CATEGORIES] = categoryEntries
|
||||
|
||||
backupManager.databaseHelper.inTransaction {
|
||||
// Get manga from database
|
||||
val mangas = backupManager.getFavoriteManga()
|
||||
|
||||
// Backup library manga and its dependencies
|
||||
mangas.forEach { manga ->
|
||||
mangaEntries.add(backupManager.backupMangaObject(manga, flags))
|
||||
}
|
||||
|
||||
// Backup categories
|
||||
if ((flags and BACKUP_CATEGORY_MASK) == BACKUP_CATEGORY) {
|
||||
backupManager.backupCategories(categoryEntries)
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// When BackupCreatorJob
|
||||
if (isJob) {
|
||||
// Get dir of file
|
||||
val dir = UniFile.fromUri(this, uri)
|
||||
|
||||
// Delete older backups
|
||||
val numberOfBackups = backupManager.numberOfBackups()
|
||||
val backupRegex = Regex("""tachiyomi_\d+-\d+-\d+_\d+-\d+.json""")
|
||||
dir.listFiles { _, filename -> backupRegex.matches(filename) }
|
||||
.orEmpty()
|
||||
.sortedByDescending { it.name }
|
||||
.drop(numberOfBackups - 1)
|
||||
.forEach { it.delete() }
|
||||
|
||||
// Create new file to place backup
|
||||
val newFile = dir.createFile(Backup.getDefaultFilename())
|
||||
?: throw Exception("Couldn't create backup file")
|
||||
|
||||
newFile.openOutputStream().bufferedWriter().use {
|
||||
backupManager.parser.toJson(root, it)
|
||||
}
|
||||
} else {
|
||||
val file = UniFile.fromUri(this, uri)
|
||||
?: throw Exception("Couldn't create backup file")
|
||||
file.openOutputStream().bufferedWriter().use {
|
||||
backupManager.parser.toJson(root, it)
|
||||
}
|
||||
|
||||
// Show completed dialog
|
||||
val intent = Intent(SettingsBackupFragment.INTENT_FILTER).apply {
|
||||
putExtra(SettingsBackupFragment.ACTION, SettingsBackupFragment.ACTION_BACKUP_COMPLETED_DIALOG)
|
||||
putExtra(SettingsBackupFragment.EXTRA_URI, file.uri.toString())
|
||||
}
|
||||
sendLocalBroadcast(intent)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e)
|
||||
if (!isJob) {
|
||||
// Show error dialog
|
||||
val intent = Intent(SettingsBackupFragment.INTENT_FILTER).apply {
|
||||
putExtra(SettingsBackupFragment.ACTION, SettingsBackupFragment.ACTION_ERROR_BACKUP_DIALOG)
|
||||
putExtra(SettingsBackupFragment.EXTRA_ERROR_MESSAGE, e.message)
|
||||
}
|
||||
sendLocalBroadcast(intent)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,41 @@
|
||||
package eu.kanade.tachiyomi.data.backup
|
||||
|
||||
import com.evernote.android.job.Job
|
||||
import com.evernote.android.job.JobManager
|
||||
import com.evernote.android.job.JobRequest
|
||||
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
|
||||
import eu.kanade.tachiyomi.data.preference.getOrDefault
|
||||
import uy.kohesive.injekt.Injekt
|
||||
import uy.kohesive.injekt.api.get
|
||||
|
||||
class BackupCreatorJob : Job() {
|
||||
|
||||
override fun onRunJob(params: Params): Result {
|
||||
val preferences = Injekt.get<PreferencesHelper>()
|
||||
val path = preferences.backupsDirectory().getOrDefault()
|
||||
val flags = BackupCreateService.BACKUP_ALL
|
||||
BackupCreateService.makeBackup(context,path,flags,true)
|
||||
return Result.SUCCESS
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val TAG = "BackupCreator"
|
||||
|
||||
fun setupTask(prefInterval: Int? = null) {
|
||||
val preferences = Injekt.get<PreferencesHelper>()
|
||||
val interval = prefInterval ?: preferences.backupInterval().getOrDefault()
|
||||
if (interval > 0) {
|
||||
JobRequest.Builder(TAG)
|
||||
.setPeriodic(interval * 60 * 60 * 1000L, 10 * 60 * 1000)
|
||||
.setPersisted(true)
|
||||
.setUpdateCurrent(true)
|
||||
.build()
|
||||
.schedule()
|
||||
}
|
||||
}
|
||||
|
||||
fun cancelTask() {
|
||||
JobManager.instance().cancelAllForTag(TAG)
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,413 @@
|
||||
package eu.kanade.tachiyomi.data.backup
|
||||
|
||||
import android.app.Service
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.IBinder
|
||||
import android.os.PowerManager
|
||||
import com.github.salomonbrys.kotson.fromJson
|
||||
import com.google.gson.JsonArray
|
||||
import com.google.gson.JsonParser
|
||||
import com.google.gson.stream.JsonReader
|
||||
import com.hippo.unifile.UniFile
|
||||
import eu.kanade.tachiyomi.R
|
||||
import eu.kanade.tachiyomi.data.backup.models.Backup.CATEGORIES
|
||||
import eu.kanade.tachiyomi.data.backup.models.Backup.CHAPTERS
|
||||
import eu.kanade.tachiyomi.data.backup.models.Backup.HISTORY
|
||||
import eu.kanade.tachiyomi.data.backup.models.Backup.MANGA
|
||||
import eu.kanade.tachiyomi.data.backup.models.Backup.MANGAS
|
||||
import eu.kanade.tachiyomi.data.backup.models.Backup.TRACK
|
||||
import eu.kanade.tachiyomi.data.backup.models.Backup.VERSION
|
||||
import eu.kanade.tachiyomi.data.backup.models.DHistory
|
||||
import eu.kanade.tachiyomi.data.database.DatabaseHelper
|
||||
import eu.kanade.tachiyomi.data.database.models.*
|
||||
import eu.kanade.tachiyomi.source.Source
|
||||
import eu.kanade.tachiyomi.ui.setting.SettingsBackupFragment
|
||||
import eu.kanade.tachiyomi.util.AndroidComponentUtil
|
||||
import eu.kanade.tachiyomi.util.chop
|
||||
import eu.kanade.tachiyomi.util.sendLocalBroadcast
|
||||
import rx.Observable
|
||||
import rx.Subscription
|
||||
import rx.schedulers.Schedulers
|
||||
import timber.log.Timber
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
import java.io.File
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
import eu.kanade.tachiyomi.BuildConfig.APPLICATION_ID as ID
|
||||
|
||||
/**
|
||||
* Restores backup from json file
|
||||
*/
|
||||
class BackupRestoreService : Service() {
|
||||
|
||||
companion object {
|
||||
// Name of service
|
||||
private const val NAME = "BackupRestoreService"
|
||||
|
||||
// Uri as string
|
||||
private const val EXTRA_URI = "$ID.$NAME.EXTRA_URI"
|
||||
|
||||
/**
|
||||
* Returns the status of the service.
|
||||
*
|
||||
* @param context the application context.
|
||||
* @return true if the service is running, false otherwise.
|
||||
*/
|
||||
fun isRunning(context: Context): Boolean {
|
||||
return AndroidComponentUtil.isServiceRunning(context, BackupRestoreService::class.java)
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts a service to restore a backup from Json
|
||||
*
|
||||
* @param context context of application
|
||||
* @param uri path of Uri
|
||||
*/
|
||||
fun start(context: Context, uri: String) {
|
||||
if (!isRunning(context)) {
|
||||
val intent = Intent(context, BackupRestoreService::class.java).apply {
|
||||
putExtra(EXTRA_URI, uri)
|
||||
}
|
||||
context.startService(intent)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops the service.
|
||||
*
|
||||
* @param context the application context.
|
||||
*/
|
||||
fun stop(context: Context) {
|
||||
context.stopService(Intent(context, BackupRestoreService::class.java))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wake lock that will be held until the service is destroyed.
|
||||
*/
|
||||
private lateinit var wakeLock: PowerManager.WakeLock
|
||||
|
||||
/**
|
||||
* Subscription where the update is done.
|
||||
*/
|
||||
private var subscription: Subscription? = null
|
||||
|
||||
/**
|
||||
* The progress of a backup restore
|
||||
*/
|
||||
private var restoreProgress = 0
|
||||
|
||||
/**
|
||||
* Amount of manga in Json file (needed for restore)
|
||||
*/
|
||||
private var restoreAmount = 0
|
||||
|
||||
/**
|
||||
* List containing errors
|
||||
*/
|
||||
private val errors = mutableListOf<Pair<Date, String>>()
|
||||
|
||||
/**
|
||||
* Backup manager
|
||||
*/
|
||||
private lateinit var backupManager: BackupManager
|
||||
|
||||
/**
|
||||
* Database
|
||||
*/
|
||||
private val db: DatabaseHelper by injectLazy()
|
||||
|
||||
/**
|
||||
* Method called when the service is created. It injects dependencies and acquire the wake lock.
|
||||
*/
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
wakeLock = (getSystemService(Context.POWER_SERVICE) as PowerManager).newWakeLock(
|
||||
PowerManager.PARTIAL_WAKE_LOCK, "BackupRestoreService:WakeLock")
|
||||
wakeLock.acquire()
|
||||
}
|
||||
|
||||
/**
|
||||
* Method called when the service is destroyed. It destroys the running subscription and
|
||||
* releases the wake lock.
|
||||
*/
|
||||
override fun onDestroy() {
|
||||
subscription?.unsubscribe()
|
||||
if (wakeLock.isHeld) {
|
||||
wakeLock.release()
|
||||
}
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
/**
|
||||
* This method needs to be implemented, but it's not used/needed.
|
||||
*/
|
||||
override fun onBind(intent: Intent): IBinder? {
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Method called when the service receives an intent.
|
||||
*
|
||||
* @param intent the start intent from.
|
||||
* @param flags the flags of the command.
|
||||
* @param startId the start id of this command.
|
||||
* @return the start value of the command.
|
||||
*/
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
if (intent == null) return Service.START_NOT_STICKY
|
||||
|
||||
// Unsubscribe from any previous subscription if needed.
|
||||
subscription?.unsubscribe()
|
||||
|
||||
val startTime = System.currentTimeMillis()
|
||||
subscription = Observable.defer {
|
||||
// Get URI
|
||||
val uri = Uri.parse(intent.getStringExtra(EXTRA_URI))
|
||||
// Get file from Uri
|
||||
val file = UniFile.fromUri(this, uri)
|
||||
|
||||
// Clear errors
|
||||
errors.clear()
|
||||
|
||||
// Reset progress
|
||||
restoreProgress = 0
|
||||
|
||||
db.lowLevel().beginTransaction()
|
||||
getRestoreObservable(file)
|
||||
}
|
||||
.subscribeOn(Schedulers.io())
|
||||
.subscribe({
|
||||
}, { error ->
|
||||
db.lowLevel().endTransaction()
|
||||
Timber.e(error)
|
||||
writeErrorLog()
|
||||
val errorIntent = Intent(SettingsBackupFragment.INTENT_FILTER).apply {
|
||||
putExtra(SettingsBackupFragment.ACTION, SettingsBackupFragment.ACTION_ERROR_RESTORE_DIALOG)
|
||||
putExtra(SettingsBackupFragment.EXTRA_ERROR_MESSAGE, error.message)
|
||||
}
|
||||
sendLocalBroadcast(errorIntent)
|
||||
stopSelf(startId)
|
||||
}, {
|
||||
db.lowLevel().setTransactionSuccessful()
|
||||
db.lowLevel().endTransaction()
|
||||
val endTime = System.currentTimeMillis()
|
||||
val time = endTime - startTime
|
||||
val file = writeErrorLog()
|
||||
val completeIntent = Intent(SettingsBackupFragment.INTENT_FILTER).apply {
|
||||
putExtra(SettingsBackupFragment.EXTRA_TIME, time)
|
||||
putExtra(SettingsBackupFragment.EXTRA_ERRORS, errors.size)
|
||||
putExtra(SettingsBackupFragment.EXTRA_ERROR_FILE_PATH, file.parent)
|
||||
putExtra(SettingsBackupFragment.EXTRA_ERROR_FILE, file.name)
|
||||
putExtra(SettingsBackupFragment.ACTION, SettingsBackupFragment.ACTION_RESTORE_COMPLETED_DIALOG)
|
||||
}
|
||||
sendLocalBroadcast(completeIntent)
|
||||
stopSelf(startId)
|
||||
})
|
||||
return Service.START_NOT_STICKY
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an [Observable] containing restore process.
|
||||
*
|
||||
* @param file restore file
|
||||
* @return [Observable<Manga>]
|
||||
*/
|
||||
private fun getRestoreObservable(file: UniFile): Observable<Manga> {
|
||||
val reader = JsonReader(file.openInputStream().bufferedReader())
|
||||
val json = JsonParser().parse(reader).asJsonObject
|
||||
|
||||
// Get parser version
|
||||
val version = json.get(VERSION)?.asInt ?: 1
|
||||
|
||||
// Initialize manager
|
||||
backupManager = BackupManager(this, version)
|
||||
|
||||
val mangasJson = json.get(MANGAS).asJsonArray
|
||||
|
||||
restoreAmount = mangasJson.size() + 1 // +1 for categories
|
||||
|
||||
// Restore categories
|
||||
json.get(CATEGORIES)?.let {
|
||||
backupManager.restoreCategories(it.asJsonArray)
|
||||
restoreProgress += 1
|
||||
showRestoreProgress(restoreProgress, restoreAmount, "Categories added", errors.size)
|
||||
}
|
||||
|
||||
return Observable.from(mangasJson)
|
||||
.concatMap {
|
||||
val obj = it.asJsonObject
|
||||
val manga = backupManager.parser.fromJson<MangaImpl>(obj.get(MANGA))
|
||||
val chapters = backupManager.parser.fromJson<List<ChapterImpl>>(obj.get(CHAPTERS) ?: JsonArray())
|
||||
val categories = backupManager.parser.fromJson<List<String>>(obj.get(CATEGORIES) ?: JsonArray())
|
||||
val history = backupManager.parser.fromJson<List<DHistory>>(obj.get(HISTORY) ?: JsonArray())
|
||||
val tracks = backupManager.parser.fromJson<List<TrackImpl>>(obj.get(TRACK) ?: JsonArray())
|
||||
|
||||
val observable = getMangaRestoreObservable(manga, chapters, categories, history, tracks)
|
||||
if (observable != null) {
|
||||
observable
|
||||
} else {
|
||||
errors.add(Date() to "${manga.title} - ${getString(R.string.source_not_found)}")
|
||||
restoreProgress += 1
|
||||
val content = getString(R.string.dialog_restoring_source_not_found, manga.title.chop(15))
|
||||
showRestoreProgress(restoreProgress, restoreAmount, manga.title, errors.size, content)
|
||||
Observable.just(manga)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write errors to error log
|
||||
*/
|
||||
private fun writeErrorLog(): File {
|
||||
try {
|
||||
if (errors.isNotEmpty()) {
|
||||
val destFile = File(externalCacheDir, "tachiyomi_restore.log")
|
||||
val sdf = SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS", Locale.getDefault())
|
||||
|
||||
destFile.bufferedWriter().use { out ->
|
||||
errors.forEach { (date, message) ->
|
||||
out.write("[${sdf.format(date)}] $message\n")
|
||||
}
|
||||
}
|
||||
return destFile
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
// Empty
|
||||
}
|
||||
return File("")
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a manga restore observable
|
||||
*
|
||||
* @param manga manga data from json
|
||||
* @param chapters chapters data from json
|
||||
* @param categories categories data from json
|
||||
* @param history history data from json
|
||||
* @param tracks tracking data from json
|
||||
* @return [Observable] containing manga restore information
|
||||
*/
|
||||
private fun getMangaRestoreObservable(manga: Manga, chapters: List<Chapter>,
|
||||
categories: List<String>, history: List<DHistory>,
|
||||
tracks: List<Track>): Observable<Manga>? {
|
||||
// Get source
|
||||
val source = backupManager.sourceManager.get(manga.source) ?: return null
|
||||
val dbManga = backupManager.getMangaFromDatabase(manga)
|
||||
|
||||
if (dbManga == null) {
|
||||
// Manga not in database
|
||||
return mangaFetchObservable(source, manga, chapters, categories, history, tracks)
|
||||
} else { // Manga in database
|
||||
// Copy information from manga already in database
|
||||
backupManager.restoreMangaNoFetch(manga, dbManga)
|
||||
// Fetch rest of manga information
|
||||
return mangaNoFetchObservable(source, manga, chapters, categories, history, tracks)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* [Observable] that fetches manga information
|
||||
*
|
||||
* @param manga manga that needs updating
|
||||
* @param chapters chapters of manga that needs updating
|
||||
* @param categories categories that need updating
|
||||
*/
|
||||
private fun mangaFetchObservable(source: Source, manga: Manga, chapters: List<Chapter>,
|
||||
categories: List<String>, history: List<DHistory>,
|
||||
tracks: List<Track>): Observable<Manga> {
|
||||
return backupManager.restoreMangaFetchObservable(source, manga)
|
||||
.onErrorReturn {
|
||||
errors.add(Date() to "${manga.title} - ${it.message}")
|
||||
manga
|
||||
}
|
||||
.filter { it.id != null }
|
||||
.flatMap { manga ->
|
||||
chapterFetchObservable(source, manga, chapters)
|
||||
// Convert to the manga that contains new chapters.
|
||||
.map { manga }
|
||||
}
|
||||
.doOnNext {
|
||||
// Restore categories
|
||||
backupManager.restoreCategoriesForManga(it, categories)
|
||||
|
||||
// Restore history
|
||||
backupManager.restoreHistoryForManga(history)
|
||||
|
||||
// Restore tracking
|
||||
backupManager.restoreTrackForManga(it, tracks)
|
||||
}
|
||||
.doOnCompleted {
|
||||
restoreProgress += 1
|
||||
showRestoreProgress(restoreProgress, restoreAmount, manga.title, errors.size)
|
||||
}
|
||||
}
|
||||
|
||||
private fun mangaNoFetchObservable(source: Source, backupManga: Manga, chapters: List<Chapter>,
|
||||
categories: List<String>, history: List<DHistory>,
|
||||
tracks: List<Track>): Observable<Manga> {
|
||||
|
||||
return Observable.just(backupManga)
|
||||
.flatMap { manga ->
|
||||
if (!backupManager.restoreChaptersForManga(manga, chapters)) {
|
||||
chapterFetchObservable(source, manga, chapters)
|
||||
.map { manga }
|
||||
} else {
|
||||
Observable.just(manga)
|
||||
}
|
||||
}
|
||||
.doOnNext {
|
||||
// Restore categories
|
||||
backupManager.restoreCategoriesForManga(it, categories)
|
||||
|
||||
// Restore history
|
||||
backupManager.restoreHistoryForManga(history)
|
||||
|
||||
// Restore tracking
|
||||
backupManager.restoreTrackForManga(it, tracks)
|
||||
}
|
||||
.doOnCompleted {
|
||||
restoreProgress += 1
|
||||
showRestoreProgress(restoreProgress, restoreAmount, backupManga.title, errors.size)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* [Observable] that fetches chapter information
|
||||
*
|
||||
* @param source source of manga
|
||||
* @param manga manga that needs updating
|
||||
* @return [Observable] that contains manga
|
||||
*/
|
||||
private fun chapterFetchObservable(source: Source, manga: Manga, chapters: List<Chapter>): Observable<Pair<List<Chapter>, List<Chapter>>> {
|
||||
return backupManager.restoreChapterFetchObservable(source, manga, chapters)
|
||||
// If there's any error, return empty update and continue.
|
||||
.onErrorReturn {
|
||||
errors.add(Date() to "${manga.title} - ${it.message}")
|
||||
Pair(emptyList<Chapter>(), emptyList<Chapter>())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Called to update dialog in [SettingsBackupFragment]
|
||||
*
|
||||
* @param progress restore progress
|
||||
* @param amount total restoreAmount of manga
|
||||
* @param title title of restored manga
|
||||
*/
|
||||
private fun showRestoreProgress(progress: Int, amount: Int, title: String, errors: Int,
|
||||
content: String = getString(R.string.dialog_restoring_backup, title.chop(15))) {
|
||||
val intent = Intent(SettingsBackupFragment.INTENT_FILTER).apply {
|
||||
putExtra(SettingsBackupFragment.EXTRA_PROGRESS, progress)
|
||||
putExtra(SettingsBackupFragment.EXTRA_AMOUNT, amount)
|
||||
putExtra(SettingsBackupFragment.EXTRA_CONTENT, content)
|
||||
putExtra(SettingsBackupFragment.EXTRA_ERRORS, errors)
|
||||
putExtra(SettingsBackupFragment.ACTION, SettingsBackupFragment.ACTION_SET_PROGRESS_DIALOG)
|
||||
}
|
||||
sendLocalBroadcast(intent)
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,23 @@
|
||||
package eu.kanade.tachiyomi.data.backup.models
|
||||
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
|
||||
/**
|
||||
* Json values
|
||||
*/
|
||||
object Backup {
|
||||
const val CURRENT_VERSION = 2
|
||||
const val MANGA = "manga"
|
||||
const val MANGAS = "mangas"
|
||||
const val TRACK = "track"
|
||||
const val CHAPTERS = "chapters"
|
||||
const val CATEGORIES = "categories"
|
||||
const val HISTORY = "history"
|
||||
const val VERSION = "version"
|
||||
|
||||
fun getDefaultFilename(): String {
|
||||
val date = SimpleDateFormat("yyyy-MM-dd_HH-mm", Locale.getDefault()).format(Date())
|
||||
return "tachiyomi_$date.json"
|
||||
}
|
||||
}
|
@ -0,0 +1,3 @@
|
||||
package eu.kanade.tachiyomi.data.backup.models
|
||||
|
||||
data class DHistory(val url: String,val lastRead: Long)
|
@ -1,16 +0,0 @@
|
||||
package eu.kanade.tachiyomi.data.backup.serializer
|
||||
|
||||
import com.google.gson.JsonElement
|
||||
import com.google.gson.JsonPrimitive
|
||||
import com.google.gson.JsonSerializationContext
|
||||
import com.google.gson.JsonSerializer
|
||||
import java.lang.reflect.Type
|
||||
|
||||
class BooleanSerializer : JsonSerializer<Boolean> {
|
||||
|
||||
override fun serialize(value: Boolean?, type: Type, context: JsonSerializationContext): JsonElement? {
|
||||
if (value != null && value != false)
|
||||
return JsonPrimitive(value)
|
||||
return null
|
||||
}
|
||||
}
|
@ -0,0 +1,31 @@
|
||||
package eu.kanade.tachiyomi.data.backup.serializer
|
||||
|
||||
import com.github.salomonbrys.kotson.typeAdapter
|
||||
import com.google.gson.TypeAdapter
|
||||
import eu.kanade.tachiyomi.data.database.models.CategoryImpl
|
||||
|
||||
/**
|
||||
* JSON Serializer used to write / read [CategoryImpl] to / from json
|
||||
*/
|
||||
object CategoryTypeAdapter {
|
||||
|
||||
fun build(): TypeAdapter<CategoryImpl> {
|
||||
return typeAdapter {
|
||||
write {
|
||||
beginArray()
|
||||
value(it.name)
|
||||
value(it.order)
|
||||
endArray()
|
||||
}
|
||||
|
||||
read {
|
||||
beginArray()
|
||||
val category = CategoryImpl()
|
||||
category.name = nextString()
|
||||
category.order = nextInt()
|
||||
endArray()
|
||||
category
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,61 @@
|
||||
package eu.kanade.tachiyomi.data.backup.serializer
|
||||
|
||||
import com.github.salomonbrys.kotson.typeAdapter
|
||||
import com.google.gson.TypeAdapter
|
||||
import com.google.gson.stream.JsonToken
|
||||
import eu.kanade.tachiyomi.data.database.models.ChapterImpl
|
||||
|
||||
/**
|
||||
* JSON Serializer used to write / read [ChapterImpl] to / from json
|
||||
*/
|
||||
object ChapterTypeAdapter {
|
||||
|
||||
private const val URL = "u"
|
||||
private const val READ = "r"
|
||||
private const val BOOKMARK = "b"
|
||||
private const val LAST_READ = "l"
|
||||
|
||||
fun build(): TypeAdapter<ChapterImpl> {
|
||||
return typeAdapter {
|
||||
write {
|
||||
if (it.read || it.bookmark || it.last_page_read != 0) {
|
||||
beginObject()
|
||||
name(URL)
|
||||
value(it.url)
|
||||
if (it.read) {
|
||||
name(READ)
|
||||
value(1)
|
||||
}
|
||||
if (it.bookmark) {
|
||||
name(BOOKMARK)
|
||||
value(1)
|
||||
}
|
||||
if (it.last_page_read != 0) {
|
||||
name(LAST_READ)
|
||||
value(it.last_page_read)
|
||||
}
|
||||
endObject()
|
||||
}
|
||||
}
|
||||
|
||||
read {
|
||||
val chapter = ChapterImpl()
|
||||
beginObject()
|
||||
while (hasNext()) {
|
||||
if (peek() == JsonToken.NAME) {
|
||||
val name = nextName()
|
||||
|
||||
when (name) {
|
||||
URL -> chapter.url = nextString()
|
||||
READ -> chapter.read = nextInt() == 1
|
||||
BOOKMARK -> chapter.bookmark = nextInt() == 1
|
||||
LAST_READ -> chapter.last_page_read = nextInt()
|
||||
}
|
||||
}
|
||||
}
|
||||
endObject()
|
||||
chapter
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,32 @@
|
||||
package eu.kanade.tachiyomi.data.backup.serializer
|
||||
|
||||
import com.github.salomonbrys.kotson.typeAdapter
|
||||
import com.google.gson.TypeAdapter
|
||||
import eu.kanade.tachiyomi.data.backup.models.DHistory
|
||||
|
||||
/**
|
||||
* JSON Serializer used to write / read [DHistory] to / from json
|
||||
*/
|
||||
object HistoryTypeAdapter {
|
||||
|
||||
fun build(): TypeAdapter<DHistory> {
|
||||
return typeAdapter {
|
||||
write {
|
||||
if (it.lastRead != 0L) {
|
||||
beginArray()
|
||||
value(it.url)
|
||||
value(it.lastRead)
|
||||
endArray()
|
||||
}
|
||||
}
|
||||
|
||||
read {
|
||||
beginArray()
|
||||
val url = nextString()
|
||||
val lastRead = nextLong()
|
||||
endArray()
|
||||
DHistory(url, lastRead)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -1,27 +0,0 @@
|
||||
package eu.kanade.tachiyomi.data.backup.serializer
|
||||
|
||||
import com.google.gson.ExclusionStrategy
|
||||
import com.google.gson.FieldAttributes
|
||||
import eu.kanade.tachiyomi.data.database.models.CategoryImpl
|
||||
import eu.kanade.tachiyomi.data.database.models.ChapterImpl
|
||||
import eu.kanade.tachiyomi.data.database.models.MangaImpl
|
||||
import eu.kanade.tachiyomi.data.database.models.TrackImpl
|
||||
|
||||
class IdExclusion : ExclusionStrategy {
|
||||
|
||||
private val categoryExclusions = listOf("id")
|
||||
private val mangaExclusions = listOf("id")
|
||||
private val chapterExclusions = listOf("id", "manga_id")
|
||||
private val syncExclusions = listOf("id", "manga_id", "update")
|
||||
|
||||
override fun shouldSkipField(f: FieldAttributes) = when (f.declaringClass) {
|
||||
MangaImpl::class.java -> mangaExclusions.contains(f.name)
|
||||
ChapterImpl::class.java -> chapterExclusions.contains(f.name)
|
||||
TrackImpl::class.java -> syncExclusions.contains(f.name)
|
||||
CategoryImpl::class.java -> categoryExclusions.contains(f.name)
|
||||
else -> false
|
||||
}
|
||||
|
||||
override fun shouldSkipClass(clazz: Class<*>) = false
|
||||
|
||||
}
|
@ -1,17 +0,0 @@
|
||||
package eu.kanade.tachiyomi.data.backup.serializer
|
||||
|
||||
import com.google.gson.JsonElement
|
||||
import com.google.gson.JsonPrimitive
|
||||
import com.google.gson.JsonSerializationContext
|
||||
import com.google.gson.JsonSerializer
|
||||
|
||||
import java.lang.reflect.Type
|
||||
|
||||
class IntegerSerializer : JsonSerializer<Int> {
|
||||
|
||||
override fun serialize(value: Int?, type: Type, context: JsonSerializationContext): JsonElement? {
|
||||
if (value != null && value !== 0)
|
||||
return JsonPrimitive(value)
|
||||
return null
|
||||
}
|
||||
}
|
@ -1,16 +0,0 @@
|
||||
package eu.kanade.tachiyomi.data.backup.serializer
|
||||
|
||||
import com.google.gson.JsonElement
|
||||
import com.google.gson.JsonPrimitive
|
||||
import com.google.gson.JsonSerializationContext
|
||||
import com.google.gson.JsonSerializer
|
||||
import java.lang.reflect.Type
|
||||
|
||||
class LongSerializer : JsonSerializer<Long> {
|
||||
|
||||
override fun serialize(value: Long?, type: Type, context: JsonSerializationContext): JsonElement? {
|
||||
if (value != null && value !== 0L)
|
||||
return JsonPrimitive(value)
|
||||
return null
|
||||
}
|
||||
}
|
@ -0,0 +1,37 @@
|
||||
package eu.kanade.tachiyomi.data.backup.serializer
|
||||
|
||||
import com.github.salomonbrys.kotson.typeAdapter
|
||||
import com.google.gson.TypeAdapter
|
||||
import eu.kanade.tachiyomi.data.database.models.MangaImpl
|
||||
|
||||
/**
|
||||
* JSON Serializer used to write / read [MangaImpl] to / from json
|
||||
*/
|
||||
object MangaTypeAdapter {
|
||||
|
||||
fun build(): TypeAdapter<MangaImpl> {
|
||||
return typeAdapter {
|
||||
write {
|
||||
beginArray()
|
||||
value(it.url)
|
||||
value(it.title)
|
||||
value(it.source)
|
||||
value(it.viewer)
|
||||
value(it.chapter_flags)
|
||||
endArray()
|
||||
}
|
||||
|
||||
read {
|
||||
beginArray()
|
||||
val manga = MangaImpl()
|
||||
manga.url = nextString()
|
||||
manga.title = nextString()
|
||||
manga.source = nextLong()
|
||||
manga.viewer = nextInt()
|
||||
manga.chapter_flags = nextInt()
|
||||
endArray()
|
||||
manga
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,53 @@
|
||||
package eu.kanade.tachiyomi.data.backup.serializer
|
||||
|
||||
import com.github.salomonbrys.kotson.typeAdapter
|
||||
import com.google.gson.TypeAdapter
|
||||
import com.google.gson.stream.JsonToken
|
||||
import eu.kanade.tachiyomi.data.database.models.TrackImpl
|
||||
|
||||
/**
|
||||
* JSON Serializer used to write / read [TrackImpl] to / from json
|
||||
*/
|
||||
object TrackTypeAdapter {
|
||||
|
||||
private const val SYNC = "s"
|
||||
private const val REMOTE = "r"
|
||||
private const val TITLE = "t"
|
||||
private const val LAST_READ = "l"
|
||||
|
||||
fun build(): TypeAdapter<TrackImpl> {
|
||||
return typeAdapter {
|
||||
write {
|
||||
beginObject()
|
||||
name(TITLE)
|
||||
value(it.title)
|
||||
name(SYNC)
|
||||
value(it.sync_id)
|
||||
name(REMOTE)
|
||||
value(it.remote_id)
|
||||
name(LAST_READ)
|
||||
value(it.last_chapter_read)
|
||||
endObject()
|
||||
}
|
||||
|
||||
read {
|
||||
val track = TrackImpl()
|
||||
beginObject()
|
||||
while (hasNext()) {
|
||||
if (peek() == JsonToken.NAME) {
|
||||
val name = nextName()
|
||||
|
||||
when (name) {
|
||||
TITLE -> track.title = nextString()
|
||||
SYNC -> track.sync_id = nextInt()
|
||||
REMOTE -> track.remote_id = nextInt()
|
||||
LAST_READ -> track.last_chapter_read = nextInt()
|
||||
}
|
||||
}
|
||||
}
|
||||
endObject()
|
||||
track
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -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)
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -1,163 +0,0 @@
|
||||
package eu.kanade.tachiyomi.ui.backup
|
||||
|
||||
import android.app.Activity
|
||||
import android.app.Dialog
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import com.afollestad.materialdialogs.MaterialDialog
|
||||
import eu.kanade.tachiyomi.R
|
||||
import eu.kanade.tachiyomi.ui.base.activity.ActivityMixin
|
||||
import eu.kanade.tachiyomi.ui.base.fragment.BaseRxFragment
|
||||
import eu.kanade.tachiyomi.util.toast
|
||||
import kotlinx.android.synthetic.main.fragment_backup.*
|
||||
import nucleus.factory.RequiresPresenter
|
||||
import rx.Observable
|
||||
import rx.android.schedulers.AndroidSchedulers
|
||||
import rx.internal.util.SubscriptionList
|
||||
import rx.schedulers.Schedulers
|
||||
import timber.log.Timber
|
||||
import java.io.File
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
|
||||
/**
|
||||
* Fragment to create and restore backups of the application's data.
|
||||
* Uses R.layout.fragment_backup.
|
||||
*/
|
||||
@RequiresPresenter(BackupPresenter::class)
|
||||
class BackupFragment : BaseRxFragment<BackupPresenter>() {
|
||||
|
||||
private var backupDialog: Dialog? = null
|
||||
private var restoreDialog: Dialog? = null
|
||||
|
||||
private lateinit var subscriptions: SubscriptionList
|
||||
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedState: Bundle?): View {
|
||||
return inflater.inflate(R.layout.fragment_backup, container, false)
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedState: Bundle?) {
|
||||
setToolbarTitle(getString(R.string.label_backup))
|
||||
|
||||
(activity as ActivityMixin).requestPermissionsOnMarshmallow()
|
||||
subscriptions = SubscriptionList()
|
||||
|
||||
backup_button.setOnClickListener {
|
||||
val today = SimpleDateFormat("yyyy-MM-dd").format(Date())
|
||||
val file = File(activity.externalCacheDir, "tachiyomi-$today.json")
|
||||
presenter.createBackup(file)
|
||||
|
||||
backupDialog = MaterialDialog.Builder(activity)
|
||||
.content(R.string.backup_please_wait)
|
||||
.progress(true, 0)
|
||||
.show()
|
||||
}
|
||||
|
||||
restore_button.setOnClickListener {
|
||||
val intent = Intent(Intent.ACTION_GET_CONTENT)
|
||||
intent.addCategory(Intent.CATEGORY_OPENABLE)
|
||||
intent.type = "application/*"
|
||||
val chooser = Intent.createChooser(intent, getString(R.string.file_select_backup))
|
||||
startActivityForResult(chooser, REQUEST_BACKUP_OPEN)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
subscriptions.unsubscribe()
|
||||
super.onDestroyView()
|
||||
}
|
||||
|
||||
/**
|
||||
* Called from the presenter when the backup is completed.
|
||||
*
|
||||
* @param file the file where the backup is saved.
|
||||
*/
|
||||
fun onBackupCompleted(file: File) {
|
||||
dismissBackupDialog()
|
||||
val intent = Intent(Intent.ACTION_SEND)
|
||||
intent.type = "application/json"
|
||||
intent.putExtra(Intent.EXTRA_STREAM, Uri.parse("file://" + file))
|
||||
startActivity(Intent.createChooser(intent, ""))
|
||||
}
|
||||
|
||||
/**
|
||||
* Called from the presenter when the restore is completed.
|
||||
*/
|
||||
fun onRestoreCompleted() {
|
||||
dismissRestoreDialog()
|
||||
context.toast(R.string.backup_completed)
|
||||
}
|
||||
|
||||
/**
|
||||
* Called from the presenter when there's an error doing the backup.
|
||||
* @param error the exception thrown.
|
||||
*/
|
||||
fun onBackupError(error: Throwable) {
|
||||
dismissBackupDialog()
|
||||
context.toast(error.message)
|
||||
}
|
||||
|
||||
/**
|
||||
* Called from the presenter when there's an error restoring the backup.
|
||||
* @param error the exception thrown.
|
||||
*/
|
||||
fun onRestoreError(error: Throwable) {
|
||||
dismissRestoreDialog()
|
||||
context.toast(error.message)
|
||||
}
|
||||
|
||||
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
|
||||
if (data != null && resultCode == Activity.RESULT_OK && requestCode == REQUEST_BACKUP_OPEN) {
|
||||
restoreDialog = MaterialDialog.Builder(activity)
|
||||
.content(R.string.restore_please_wait)
|
||||
.progress(true, 0)
|
||||
.show()
|
||||
|
||||
// When using cloud services, we have to open the input stream in a background thread.
|
||||
Observable.fromCallable { context.contentResolver.openInputStream(data.data) }
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe({
|
||||
presenter.restoreBackup(it)
|
||||
}, { error ->
|
||||
context.toast(error.message)
|
||||
Timber.e(error)
|
||||
})
|
||||
.apply { subscriptions.add(this) }
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Dismisses the backup dialog.
|
||||
*/
|
||||
fun dismissBackupDialog() {
|
||||
backupDialog?.let {
|
||||
it.dismiss()
|
||||
backupDialog = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Dismisses the restore dialog.
|
||||
*/
|
||||
fun dismissRestoreDialog() {
|
||||
restoreDialog?.let {
|
||||
it.dismiss()
|
||||
restoreDialog = null
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
private val REQUEST_BACKUP_OPEN = 102
|
||||
|
||||
fun newInstance(): BackupFragment {
|
||||
return BackupFragment()
|
||||
}
|
||||
}
|
||||
}
|
@ -1,94 +0,0 @@
|
||||
package eu.kanade.tachiyomi.ui.backup
|
||||
|
||||
import android.os.Bundle
|
||||
import eu.kanade.tachiyomi.data.backup.BackupManager
|
||||
import eu.kanade.tachiyomi.data.database.DatabaseHelper
|
||||
import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter
|
||||
import eu.kanade.tachiyomi.util.isNullOrUnsubscribed
|
||||
import rx.Observable
|
||||
import rx.Subscription
|
||||
import rx.android.schedulers.AndroidSchedulers
|
||||
import rx.schedulers.Schedulers
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
import java.io.File
|
||||
import java.io.InputStream
|
||||
|
||||
/**
|
||||
* Presenter of [BackupFragment].
|
||||
*/
|
||||
class BackupPresenter : BasePresenter<BackupFragment>() {
|
||||
|
||||
/**
|
||||
* Database.
|
||||
*/
|
||||
val db: DatabaseHelper by injectLazy()
|
||||
|
||||
/**
|
||||
* Backup manager.
|
||||
*/
|
||||
private lateinit var backupManager: BackupManager
|
||||
|
||||
/**
|
||||
* Subscription where the backup is restored.
|
||||
*/
|
||||
private var restoreSubscription: Subscription? = null
|
||||
|
||||
/**
|
||||
* Subscription where the backup is created.
|
||||
*/
|
||||
private var backupSubscription: Subscription? = null
|
||||
|
||||
override fun onCreate(savedState: Bundle?) {
|
||||
super.onCreate(savedState)
|
||||
backupManager = BackupManager(db)
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a backup and saves it to a file.
|
||||
*
|
||||
* @param file the path where the file will be saved.
|
||||
*/
|
||||
fun createBackup(file: File) {
|
||||
if (backupSubscription.isNullOrUnsubscribed()) {
|
||||
backupSubscription = getBackupObservable(file)
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribeFirst(
|
||||
{ view, result -> view.onBackupCompleted(file) },
|
||||
BackupFragment::onBackupError)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Restores a backup from a stream.
|
||||
*
|
||||
* @param stream the input stream of the backup file.
|
||||
*/
|
||||
fun restoreBackup(stream: InputStream) {
|
||||
if (restoreSubscription.isNullOrUnsubscribed()) {
|
||||
restoreSubscription = getRestoreObservable(stream)
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribeFirst(
|
||||
{ view, result -> view.onRestoreCompleted() },
|
||||
BackupFragment::onRestoreError)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the observable to save a backup.
|
||||
*/
|
||||
private fun getBackupObservable(file: File) = Observable.fromCallable {
|
||||
backupManager.backupToFile(file)
|
||||
true
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the observable to restore a backup.
|
||||
*/
|
||||
private fun getRestoreObservable(stream: InputStream) = Observable.fromCallable {
|
||||
backupManager.restoreFromStream(stream)
|
||||
true
|
||||
}
|
||||
|
||||
}
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
@ -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>
|
@ -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()
|
||||
}
|
||||
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)
|
||||
|
||||
@Test
|
||||
fun testRestoreCategory() {
|
||||
val catName = "cat"
|
||||
root = createRootJson(null, toJson(createCategories(catName)))
|
||||
backupManager.restoreFromJson(root)
|
||||
source = mock(HttpSource::class.java)
|
||||
`when`(backupManager.sourceManager.get(anyLong())).thenReturn(source)
|
||||
|
||||
val dbCats = db.getCategories().executeAsBlocking()
|
||||
assertThat(dbCats).hasSize(1)
|
||||
assertThat(dbCats[0].name).isEqualTo(catName)
|
||||
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)
|
||||
val dbCats = db.getCategories().executeAsBlocking()
|
||||
assertThat(dbCats).isEmpty()
|
||||
}
|
||||
// Initialize json with version 2
|
||||
initializeJsonTest(2)
|
||||
|
||||
@Test
|
||||
fun testRestoreExistingCategory() {
|
||||
val catName = "cat"
|
||||
db.insertCategory(createCategory(catName)).executeAsBlocking()
|
||||
// Create backup of empty database
|
||||
backupManager.backupCategories(categoryEntries)
|
||||
|
||||
root = createRootJson(null, toJson(createCategories(catName)))
|
||||
backupManager.restoreFromJson(root)
|
||||
// Restore Json
|
||||
backupManager.restoreCategories(categoryEntries)
|
||||
|
||||
// Check if empty
|
||||
val dbCats = db.getCategories().executeAsBlocking()
|
||||
assertThat(dbCats).hasSize(1)
|
||||
assertThat(dbCats[0].name).isEqualTo(catName)
|
||||
assertThat(dbCats).isEmpty()
|
||||
}
|
||||
|
||||
/**
|
||||
* Test to check if single category gets restored
|
||||
*/
|
||||
@Test
|
||||
fun testRestoreCategories() {
|
||||
root = createRootJson(null, toJson(createCategories("cat", "cat2", "cat3")))
|
||||
backupManager.restoreFromJson(root)
|
||||
fun testRestoreSingleCategory() {
|
||||
// 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()
|
||||
// Create category and add to json
|
||||
val category = addSingleCategory("category")
|
||||
|
||||
root = createRootJson(null, toJson(createCategories("cat", "cat2", "cat3")))
|
||||
backupManager.restoreFromJson(root)
|
||||
// Restore Json
|
||||
backupManager.restoreCategories(categoryEntries)
|
||||
|
||||
val dbCats = db.getCategories().executeAsBlocking()
|
||||
assertThat(dbCats).hasSize(3)
|
||||
// Check if successful
|
||||
val dbCats = backupManager.databaseHelper.getCategories().executeAsBlocking()
|
||||
assertThat(dbCats).hasSize(1)
|
||||
assertThat(dbCats[0].name).isEqualTo(category.name)
|
||||
}
|
||||
|
||||
/**
|
||||
* Test to check if multiple categories get restored.
|
||||
*/
|
||||
@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)
|
||||
fun testRestoreMultipleCategories() {
|
||||
// Initialize json with version 2
|
||||
initializeJsonTest(2)
|
||||
|
||||
// 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)
|
||||
|
||||
val dbMangas = db.getMangas().executeAsBlocking()
|
||||
assertThat(dbMangas).hasSize(1)
|
||||
assertThat(dbMangas[0].title).isEqualTo(mangaName)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testRestoreExistingManga() {
|
||||
val mangaName = "title"
|
||||
val manga = createManga(mangaName)
|
||||
|
||||
db.insertManga(manga).executeAsBlocking()
|
||||
|
||||
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)
|
||||
}
|
||||
// Initialize json with version 2
|
||||
initializeJsonTest(2)
|
||||
|
||||
@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)
|
||||
}
|
||||
// Add manga to database
|
||||
val manga = getSingleManga("One Piece")
|
||||
manga.viewer = 3
|
||||
manga.id = db.insertManga(manga).executeAsBlocking().insertedId()
|
||||
|
||||
@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)
|
||||
}
|
||||
var favoriteManga = backupManager.databaseHelper.getFavoriteMangas().executeAsBlocking()
|
||||
assertThat(favoriteManga).hasSize(1)
|
||||
assertThat(favoriteManga[0].viewer).isEqualTo(3)
|
||||
|
||||
@Test
|
||||
fun testRestoreCategoriesForManga() {
|
||||
// Create a manga
|
||||
val manga = createManga("title")
|
||||
// Update json with all options enabled
|
||||
mangaEntries.add(backupManager.backupMangaObject(manga,1))
|
||||
|
||||
// Create categories
|
||||
val categories = createCategories("cat1", "cat2", "cat3")
|
||||
// Change manga in database to default values
|
||||
val dbManga = getSingleManga("One Piece")
|
||||
dbManga.id = manga.id
|
||||
db.insertManga(dbManga).executeAsBlocking()
|
||||
|
||||
// Add an entry for the manga
|
||||
val entry = JsonObject()
|
||||
entry.add("manga", toJson(manga))
|
||||
entry.add("categories", toJson(createStringCategories("cat1")))
|
||||
favoriteManga = backupManager.databaseHelper.getFavoriteMangas().executeAsBlocking()
|
||||
assertThat(favoriteManga).hasSize(1)
|
||||
assertThat(favoriteManga[0].viewer).isEqualTo(0)
|
||||
|
||||
// Append the entry to the backup list
|
||||
val mangas = ArrayList<JsonElement>()
|
||||
mangas.add(entry)
|
||||
// Restore local manga
|
||||
backupManager.restoreMangaNoFetch(manga,dbManga)
|
||||
|
||||
// Restore from json
|
||||
root = createRootJson(toJson(mangas), toJson(categories))
|
||||
backupManager.restoreFromJson(root)
|
||||
// Test if restore successful
|
||||
favoriteManga = backupManager.databaseHelper.getFavoriteMangas().executeAsBlocking()
|
||||
assertThat(favoriteManga).hasSize(1)
|
||||
assertThat(favoriteManga[0].viewer).isEqualTo(3)
|
||||
|
||||
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")
|
||||
// Clear database to test manga fetch
|
||||
clearDatabase()
|
||||
|
||||
// Create categories
|
||||
val categories = createCategories("cat1", "cat2", "cat3")
|
||||
// Test if successful
|
||||
favoriteManga = backupManager.databaseHelper.getFavoriteMangas().executeAsBlocking()
|
||||
assertThat(favoriteManga).hasSize(0)
|
||||
|
||||
// Add an entry for the manga
|
||||
val entry = JsonObject()
|
||||
entry.add("manga", toJson(manga))
|
||||
entry.add("categories", toJson(createStringCategories("cat1", "cat3")))
|
||||
// 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)
|
||||
|
||||
// Append the entry to the backup list
|
||||
val mangas = ArrayList<JsonElement>()
|
||||
mangas.add(entry)
|
||||
// Restore manga with fetch observable
|
||||
val networkManga = getSingleManga("One Piece")
|
||||
networkManga.description = "This is a description"
|
||||
`when`(source.fetchMangaDetails(jsonManga)).thenReturn(Observable.just(networkManga))
|
||||
|
||||
// Restore from json
|
||||
root = createRootJson(toJson(mangas), toJson(categories))
|
||||
backupManager.restoreFromJson(root)
|
||||
val obs = backupManager.restoreMangaFetchObservable(source, jsonManga)
|
||||
val testSubscriber = TestSubscriber<Manga>()
|
||||
obs.subscribe(testSubscriber)
|
||||
|
||||
val dbManga = db.getManga(1).executeAsBlocking()
|
||||
assertThat(dbManga).isNotNull()
|
||||
testSubscriber.assertNoErrors()
|
||||
|
||||
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"))
|
||||
// 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 testRestoreMultipleCategoriesForExistingMangaAndCategory() {
|
||||
// Store a manga and a category
|
||||
val manga = createManga("title")
|
||||
manga.id = 1L
|
||||
db.insertManga(manga).executeAsBlocking()
|
||||
fun testRestoreChapters() {
|
||||
// Initialize json with version 2
|
||||
initializeJsonTest(2)
|
||||
|
||||
val cat = createCategory("cat1")
|
||||
cat.id = 1
|
||||
db.insertCategory(cat).executeAsBlocking()
|
||||
db.insertMangaCategory(MangaCategory.create(manga, cat)).executeAsBlocking()
|
||||
// Insert manga
|
||||
val manga = getSingleManga("One Piece")
|
||||
manga.id = backupManager.databaseHelper.insertManga(manga).executeAsBlocking().insertedId()
|
||||
|
||||
// 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")))
|
||||
// Create restore list
|
||||
val chapters = ArrayList<Chapter>()
|
||||
for (i in 1..8){
|
||||
val chapter = getSingleChapter("Chapter $i")
|
||||
chapter.read = true
|
||||
chapters.add(chapter)
|
||||
}
|
||||
|
||||
// Append the entry to the backup list
|
||||
val mangas = ArrayList<JsonElement>()
|
||||
mangas.add(entry)
|
||||
// Check parser
|
||||
val chaptersJson = backupManager.parser.toJsonTree(chapters)
|
||||
val restoredChapters = backupManager.parser.fromJson<List<ChapterImpl>>(chaptersJson)
|
||||
|
||||
// Restore from json
|
||||
root = createRootJson(toJson(mangas), toJson(categories))
|
||||
backupManager.restoreFromJson(root)
|
||||
// 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))
|
||||
|
||||
val dbManga = db.getManga(1).executeAsBlocking()
|
||||
assertThat(dbManga).isNotNull()
|
||||
// Call restoreChapterFetchObservable
|
||||
val obs = backupManager.restoreChapterFetchObservable(source, manga, restoredChapters)
|
||||
val testSubscriber = TestSubscriber<Pair<List<Chapter>, List<Chapter>>>()
|
||||
obs.subscribe(testSubscriber)
|
||||
|
||||
val result = db.getCategoriesForManga(dbManga!!).executeAsBlocking()
|
||||
testSubscriber.assertNoErrors()
|
||||
|
||||
assertThat(result).hasSize(2)
|
||||
assertThat(result).contains(Category.create("cat1"), Category.create("cat2"))
|
||||
assertThat(result).doesNotContain(Category.create("cat3"))
|
||||
val dbCats = backupManager.databaseHelper.getChapters(manga).executeAsBlocking()
|
||||
assertThat(dbCats).hasSize(10)
|
||||
assertThat(dbCats[0].read).isEqualTo(true)
|
||||
}
|
||||
|
||||
/**
|
||||
* Test to check if history restore works
|
||||
*/
|
||||
@Test
|
||||
fun testRestoreSyncForManga() {
|
||||
// Create a manga and track
|
||||
val manga = createManga("title")
|
||||
manga.id = 1L
|
||||
fun restoreHistoryForManga(){
|
||||
// Initialize json with version 2
|
||||
initializeJsonTest(2)
|
||||
|
||||
val track = createTrack(manga, 1, 2, 3)
|
||||
val manga = getSingleManga("One Piece")
|
||||
manga.id = backupManager.databaseHelper.insertManga(manga).executeAsBlocking().insertedId()
|
||||
|
||||
// Add an entry for the manga
|
||||
val entry = JsonObject()
|
||||
entry.add("manga", toJson(manga))
|
||||
entry.add("sync", toJson(track))
|
||||
// Create chapter
|
||||
val chapter = getSingleChapter("Chapter 1")
|
||||
chapter.manga_id = manga.id
|
||||
chapter.read = true
|
||||
chapter.id = backupManager.databaseHelper.insertChapter(chapter).executeAsBlocking().insertedId()
|
||||
|
||||
// Append the entry to the backup list
|
||||
val mangas = ArrayList<JsonElement>()
|
||||
mangas.add(entry)
|
||||
val historyJson = getSingleHistory(chapter)
|
||||
|
||||
// Restore from json
|
||||
root = createRootJson(toJson(mangas), null)
|
||||
backupManager.restoreFromJson(root)
|
||||
val historyList = ArrayList<DHistory>()
|
||||
historyList.add(historyJson)
|
||||
|
||||
val dbManga = db.getManga(1).executeAsBlocking()
|
||||
assertThat(dbManga).isNotNull()
|
||||
// Check parser
|
||||
val historyListJson = backupManager.parser.toJsonTree(historyList)
|
||||
val history = backupManager.parser.fromJson<List<DHistory>>(historyListJson)
|
||||
|
||||
val dbSync = db.getTracks(dbManga!!).executeAsBlocking()
|
||||
assertThat(dbSync).hasSize(3)
|
||||
}
|
||||
// Restore categories
|
||||
backupManager.restoreHistoryForManga(history)
|
||||
|
||||
@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)
|
||||
val historyDB = backupManager.databaseHelper.getHistoryByMangaId(manga.id!!).executeAsBlocking()
|
||||
assertThat(historyDB).hasSize(1)
|
||||
assertThat(historyDB[0].last_read).isEqualTo(1000)
|
||||
}
|
||||
|
||||
/**
|
||||
* Test to check if tracking restore works
|
||||
*/
|
||||
@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
|
||||
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 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
|
||||
fun clearJson() {
|
||||
root = JsonObject()
|
||||
information = JsonObject()
|
||||
mangaEntries = JsonArray()
|
||||
categoryEntries = JsonArray()
|
||||
}
|
||||
|
||||
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
|
||||
fun initializeJsonTest(version: Int) {
|
||||
clearJson()
|
||||
backupManager.setVersion(version)
|
||||
}
|
||||
|
||||
private fun createMangas(vararg titles: String): List<Manga> {
|
||||
val mangas = ArrayList<Manga>()
|
||||
for (title in titles) {
|
||||
mangas.add(createManga(title))
|
||||
}
|
||||
return mangas
|
||||
fun addSingleCategory(name: String): Category {
|
||||
val category = Category.create(name)
|
||||
val catJson = backupManager.parser.toJsonTree(category)
|
||||
categoryEntries.add(catJson)
|
||||
return category
|
||||
}
|
||||
|
||||
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
|
||||
fun clearDatabase(){
|
||||
db.deleteMangas().executeAsBlocking()
|
||||
db.deleteHistory().executeAsBlocking()
|
||||
}
|
||||
|
||||
private fun createChapters(manga: Manga, vararg urls: String): List<Chapter> {
|
||||
val chapters = ArrayList<Chapter>()
|
||||
for (url in urls) {
|
||||
chapters.add(createChapter(manga, url))
|
||||
}
|
||||
return chapters
|
||||
fun getSingleHistory(chapter: Chapter): DHistory {
|
||||
return DHistory(chapter.url, 1000)
|
||||
}
|
||||
|
||||
private fun createTrack(manga: Manga, syncId: Int): Track {
|
||||
val m = Track.create(syncId)
|
||||
m.manga_id = manga.id!!
|
||||
m.title = "title"
|
||||
return m
|
||||
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 createTrack(manga: Manga, vararg syncIds: Int): List<Track> {
|
||||
val ms = ArrayList<Track>()
|
||||
for (title in syncIds) {
|
||||
ms.add(createTrack(manga, title))
|
||||
}
|
||||
return ms
|
||||
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 toJson(element: Any): JsonElement {
|
||||
return gson.toJsonTree(element)
|
||||
private fun getSingleChapter(name: String): ChapterImpl {
|
||||
val chapter = ChapterImpl()
|
||||
chapter.name = name
|
||||
chapter.url = "/read-online/$name-page-1.html"
|
||||
return chapter
|
||||
}
|
||||
|
||||
}
|
Loading…
Reference in new issue