Make a protobuf based backup system (#112)

* Make a protobuf based backup system, restore not tested

* Fix a number

* Remove uneeded change

* Remove more uneeded changes

* Use 1.x style models, backup should be 100% compatible with the 1.x backups

* Fix restore service not running

* Fix offline chapter restore
Cleanup saved searches restore(untested)

* Implement onlione/offline option, fix merged manga restore online, fix restore total

* Allow setting auto backup to use a full backup

* Fix for saved searches restore

* Edit some comments

* Convert flows back to observables

* Fix a model

* Fixes and comment only the SY specific things

* Move SY values range to 600 from 60

* Combine legacy and full backup services into one
Deduplicate a lot of code
Simplify a lot of stuff
Modify comments

* Cleanup

* Remove unneeded protobuf config edit because its now the default

* Migrate to kotlinx.serialization for backup saved searches

* Cleanup saved searches more, move gson type adapters to the legacy package
This commit is contained in:
jobobby04 2020-10-12 14:41:56 -04:00 committed by GitHub
parent f3365cef67
commit 445878794c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
40 changed files with 2121 additions and 598 deletions

View File

@ -7,4 +7,9 @@ object BackupConst {
private const val NAME = "BackupRestoreServices" private const val NAME = "BackupRestoreServices"
const val EXTRA_URI = "$ID.$NAME.EXTRA_URI" const val EXTRA_URI = "$ID.$NAME.EXTRA_URI"
const val EXTRA_FLAGS = "$ID.$NAME.EXTRA_FLAGS" const val EXTRA_FLAGS = "$ID.$NAME.EXTRA_FLAGS"
const val EXTRA_MODE = "$ID.$NAME.EXTRA_MODE"
const val EXTRA_TYPE = "$ID.$NAME.EXTRA_TYPE"
const val BACKUP_TYPE_LEGACY = 0
const val BACKUP_TYPE_FULL = 1
} }

View File

@ -9,6 +9,9 @@ import android.os.IBinder
import android.os.PowerManager import android.os.PowerManager
import androidx.core.net.toUri import androidx.core.net.toUri
import com.hippo.unifile.UniFile import com.hippo.unifile.UniFile
import eu.kanade.tachiyomi.data.backup.full.FullBackupManager
import eu.kanade.tachiyomi.data.backup.legacy.LegacyBackupManager
import eu.kanade.tachiyomi.data.backup.models.AbstractBackupManager
import eu.kanade.tachiyomi.data.notification.Notifications import eu.kanade.tachiyomi.data.notification.Notifications
import eu.kanade.tachiyomi.util.system.acquireWakeLock import eu.kanade.tachiyomi.util.system.acquireWakeLock
import eu.kanade.tachiyomi.util.system.isServiceRunning import eu.kanade.tachiyomi.util.system.isServiceRunning
@ -46,11 +49,12 @@ class BackupCreateService : Service() {
* @param uri path of Uri * @param uri path of Uri
* @param flags determines what to backup * @param flags determines what to backup
*/ */
fun start(context: Context, uri: Uri, flags: Int) { fun start(context: Context, uri: Uri, flags: Int, type: Int) {
if (!isRunning(context)) { if (!isRunning(context)) {
val intent = Intent(context, BackupCreateService::class.java).apply { val intent = Intent(context, BackupCreateService::class.java).apply {
putExtra(BackupConst.EXTRA_URI, uri) putExtra(BackupConst.EXTRA_URI, uri)
putExtra(BackupConst.EXTRA_FLAGS, flags) putExtra(BackupConst.EXTRA_FLAGS, flags)
putExtra(BackupConst.EXTRA_TYPE, type)
} }
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
context.startService(intent) context.startService(intent)
@ -66,7 +70,7 @@ class BackupCreateService : Service() {
*/ */
private lateinit var wakeLock: PowerManager.WakeLock private lateinit var wakeLock: PowerManager.WakeLock
private lateinit var backupManager: BackupManager private lateinit var backupManager: AbstractBackupManager
private lateinit var notifier: BackupNotifier private lateinit var notifier: BackupNotifier
override fun onCreate() { override fun onCreate() {
@ -105,7 +109,8 @@ class BackupCreateService : Service() {
try { try {
val uri = intent.getParcelableExtra<Uri>(BackupConst.EXTRA_URI) val uri = intent.getParcelableExtra<Uri>(BackupConst.EXTRA_URI)
val backupFlags = intent.getIntExtra(BackupConst.EXTRA_FLAGS, 0) val backupFlags = intent.getIntExtra(BackupConst.EXTRA_FLAGS, 0)
backupManager = BackupManager(this) val backupType = intent.getIntExtra(BackupConst.EXTRA_TYPE, BackupConst.BACKUP_TYPE_LEGACY)
backupManager = if (backupType == BackupConst.BACKUP_TYPE_FULL) FullBackupManager(this) else LegacyBackupManager(this)
val backupFileUri = backupManager.createBackup(uri, backupFlags, false)?.toUri() val backupFileUri = backupManager.createBackup(uri, backupFlags, false)?.toUri()
val unifile = UniFile.fromUri(this, backupFileUri) val unifile = UniFile.fromUri(this, backupFileUri)

View File

@ -7,6 +7,8 @@ import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkManager import androidx.work.WorkManager
import androidx.work.Worker import androidx.work.Worker
import androidx.work.WorkerParameters import androidx.work.WorkerParameters
import eu.kanade.tachiyomi.data.backup.full.FullBackupManager
import eu.kanade.tachiyomi.data.backup.legacy.LegacyBackupManager
import eu.kanade.tachiyomi.data.preference.PreferencesHelper import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get import uy.kohesive.injekt.api.get
@ -17,11 +19,13 @@ class BackupCreatorJob(private val context: Context, workerParams: WorkerParamet
override fun doWork(): Result { override fun doWork(): Result {
val preferences = Injekt.get<PreferencesHelper>() val preferences = Injekt.get<PreferencesHelper>()
val backupManager = BackupManager(context) val backupManager = FullBackupManager(context)
val legacyBackupManager = if (preferences.createLegacyBackup().get()) LegacyBackupManager(context) else null
val uri = preferences.backupsDirectory().get().toUri() val uri = preferences.backupsDirectory().get().toUri()
val flags = BackupCreateService.BACKUP_ALL val flags = BackupCreateService.BACKUP_ALL
return try { return try {
backupManager.createBackup(uri, flags, true) backupManager.createBackup(uri, flags, true)
legacyBackupManager?.createBackup(uri, flags, true)
Result.success() Result.success()
} catch (e: Exception) { } catch (e: Exception) {
Result.failure() Result.failure()

View File

@ -15,7 +15,7 @@ import uy.kohesive.injekt.injectLazy
import java.io.File import java.io.File
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
internal class BackupNotifier(private val context: Context) { class BackupNotifier(private val context: Context) {
private val preferences: PreferencesHelper by injectLazy() private val preferences: PreferencesHelper by injectLazy()

View File

@ -7,49 +7,17 @@ import android.net.Uri
import android.os.Build import android.os.Build
import android.os.IBinder import android.os.IBinder
import android.os.PowerManager import android.os.PowerManager
import com.github.salomonbrys.kotson.fromJson
import com.google.gson.JsonArray
import com.google.gson.JsonElement
import com.google.gson.JsonObject
import com.google.gson.JsonParser
import com.google.gson.stream.JsonReader
import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.backup.models.Backup.CATEGORIES import eu.kanade.tachiyomi.data.backup.full.FullBackupRestore
import eu.kanade.tachiyomi.data.backup.models.Backup.CHAPTERS import eu.kanade.tachiyomi.data.backup.legacy.LegacyBackupRestore
import eu.kanade.tachiyomi.data.backup.models.Backup.HISTORY import eu.kanade.tachiyomi.data.backup.models.AbstractBackupRestore
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.MERGEDMANGAREFERENCES
import eu.kanade.tachiyomi.data.backup.models.Backup.SAVEDSEARCHES
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.Chapter
import eu.kanade.tachiyomi.data.database.models.ChapterImpl
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.database.models.MangaImpl
import eu.kanade.tachiyomi.data.database.models.Track
import eu.kanade.tachiyomi.data.database.models.TrackImpl
import eu.kanade.tachiyomi.data.notification.Notifications import eu.kanade.tachiyomi.data.notification.Notifications
import eu.kanade.tachiyomi.data.track.TrackManager
import eu.kanade.tachiyomi.source.Source
import eu.kanade.tachiyomi.util.chapter.NoChaptersException
import eu.kanade.tachiyomi.util.system.acquireWakeLock import eu.kanade.tachiyomi.util.system.acquireWakeLock
import eu.kanade.tachiyomi.util.system.isServiceRunning import eu.kanade.tachiyomi.util.system.isServiceRunning
import exh.EXHMigrations
import exh.eh.EHentaiThrottleManager
import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import rx.Observable
import timber.log.Timber import timber.log.Timber
import uy.kohesive.injekt.injectLazy
import java.io.File
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
/** /**
* Restores backup from a JSON file. * Restores backup from a JSON file.
@ -73,10 +41,12 @@ class BackupRestoreService : Service() {
* @param context context of application * @param context context of application
* @param uri path of Uri * @param uri path of Uri
*/ */
fun start(context: Context, uri: Uri) { fun start(context: Context, uri: Uri, mode: Int, online: Boolean?) {
if (!isRunning(context)) { if (!isRunning(context)) {
val intent = Intent(context, BackupRestoreService::class.java).apply { val intent = Intent(context, BackupRestoreService::class.java).apply {
putExtra(BackupConst.EXTRA_URI, uri) putExtra(BackupConst.EXTRA_URI, uri)
putExtra(BackupConst.EXTRA_TYPE, mode)
online?.let { putExtra(BackupConst.EXTRA_TYPE, it) }
} }
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
context.startService(intent) context.startService(intent)
@ -103,43 +73,9 @@ class BackupRestoreService : Service() {
*/ */
private lateinit var wakeLock: PowerManager.WakeLock private lateinit var wakeLock: PowerManager.WakeLock
private var job: Job? = null private var backupRestore: AbstractBackupRestore? = null
// SY -->
private val throttleManager = EHentaiThrottleManager()
private var skippedAmount = 0
private var totalAmount = 0
// SY <--
/**
* The progress of a backup restore
*/
private var restoreProgress = 0
/**
* Amount of manga in Json file (needed for restore)
*/
private var restoreAmount = 0
/**
* Mapping of source ID to source name from backup data
*/
private var sourceMapping: Map<Long, String> = emptyMap()
/**
* List containing errors
*/
private val errors = mutableListOf<Pair<Date, String>>()
private lateinit var backupManager: BackupManager
private lateinit var notifier: BackupNotifier private lateinit var notifier: BackupNotifier
private val db: DatabaseHelper by injectLazy()
private val trackManager: TrackManager by injectLazy()
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
@ -160,7 +96,7 @@ class BackupRestoreService : Service() {
} }
private fun destroyJob() { private fun destroyJob() {
job?.cancel() backupRestore?.job?.cancel()
if (wakeLock.isHeld) { if (wakeLock.isHeld) {
wakeLock.release() wakeLock.release()
} }
@ -181,351 +117,30 @@ class BackupRestoreService : Service() {
*/ */
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
val uri = intent?.getParcelableExtra<Uri>(BackupConst.EXTRA_URI) ?: return START_NOT_STICKY val uri = intent?.getParcelableExtra<Uri>(BackupConst.EXTRA_URI) ?: return START_NOT_STICKY
val mode = intent.getIntExtra(BackupConst.EXTRA_MODE, BackupConst.BACKUP_TYPE_FULL)
// SY --> val online = intent.getBooleanExtra(BackupConst.EXTRA_TYPE, true)
throttleManager.resetThrottle()
// SY <--
// Cancel any previous job if needed. // Cancel any previous job if needed.
job?.cancel() backupRestore?.job?.cancel()
backupRestore = if (mode == BackupConst.BACKUP_TYPE_FULL) FullBackupRestore(this, notifier, online) else LegacyBackupRestore(this, notifier)
val handler = CoroutineExceptionHandler { _, exception -> val handler = CoroutineExceptionHandler { _, exception ->
Timber.e(exception) Timber.e(exception)
writeErrorLog() backupRestore?.writeErrorLog()
notifier.showRestoreError(exception.message) notifier.showRestoreError(exception.message)
stopSelf(startId) stopSelf(startId)
} }
job = GlobalScope.launch(handler) { backupRestore?.job = GlobalScope.launch(handler) {
if (!restoreBackup(uri)) { if (backupRestore?.restoreBackup(uri) == false) {
notifier.showRestoreError(getString(R.string.restoring_backup_canceled)) notifier.showRestoreError(getString(R.string.restoring_backup_canceled))
} }
} }
job?.invokeOnCompletion { backupRestore?.job?.invokeOnCompletion {
stopSelf(startId) stopSelf(startId)
} }
return START_NOT_STICKY return START_NOT_STICKY
} }
/**
* Restores data from backup file.
*
* @param uri backup file to restore
*/
private fun restoreBackup(uri: Uri): Boolean {
val startTime = System.currentTimeMillis()
val reader = JsonReader(contentResolver.openInputStream(uri)!!.bufferedReader())
val json = JsonParser.parseReader(reader).asJsonObject
// Get parser version
val version = json.get(VERSION)?.asInt ?: 1
// Initialize manager
backupManager = BackupManager(this, version)
val mangasJson = json.get(MANGAS).asJsonArray
// SY -->
val validManga = mangasJson.filter {
var manga = backupManager.parser.fromJson<MangaImpl>(it.asJsonObject.get(MANGA))
// EXH -->
manga = EXHMigrations.migrateBackupEntry(manga)
val sourced = backupManager.sourceManager.get(manga.source) != null
if (!sourced) {
restoreAmount -= 1
}
sourced
}
totalAmount = mangasJson.size()
restoreAmount = validManga.count() + 3 // +1 for categories, +1 for saved searches, +1 for merged manga references
skippedAmount = mangasJson.size() - validManga.count()
// SY <--
restoreProgress = 0
errors.clear()
// Restore categories
json.get(CATEGORIES)?.let { restoreCategories(it) }
// SY -->
json.get(SAVEDSEARCHES)?.let { restoreSavedSearches(it) }
json.get(MERGEDMANGAREFERENCES)?.let { restoreMergedMangaReferences(it) }
// SY <--
// Store source mapping for error messages
sourceMapping = BackupRestoreValidator.getSourceMapping(json)
// Restore individual manga
mangasJson.forEach {
if (job?.isActive != true) {
return false
}
restoreManga(it.asJsonObject)
}
val endTime = System.currentTimeMillis()
val time = endTime - startTime
val logFile = writeErrorLog()
notifier.showRestoreComplete(time, errors.size, logFile.parent, logFile.name)
return true
}
private fun restoreCategories(categoriesJson: JsonElement) {
db.inTransaction {
backupManager.restoreCategories(categoriesJson.asJsonArray)
}
restoreProgress += 1
showRestoreProgress(restoreProgress, restoreAmount, getString(R.string.categories))
}
// SY -->
private fun restoreSavedSearches(savedSearchesJson: JsonElement) {
backupManager.restoreSavedSearches(savedSearchesJson)
restoreProgress += 1
showRestoreProgress(restoreProgress, restoreAmount, getString(R.string.saved_searches))
}
private fun restoreMergedMangaReferences(mergedMangaReferencesJson: JsonElement) {
db.inTransaction {
backupManager.restoreMergedMangaReferences(mergedMangaReferencesJson.asJsonArray)
}
restoreProgress += 1
showRestoreProgress(restoreProgress, restoreAmount, getString(R.string.merged_references))
}
// SY <--
private fun restoreManga(mangaJson: JsonObject) {
/* SY --> */ var /* SY <-- */ manga = backupManager.parser.fromJson<MangaImpl>(mangaJson.get(MANGA))
val chapters = backupManager.parser.fromJson<List<ChapterImpl>>(
mangaJson.get(CHAPTERS)
?: JsonArray()
)
val categories = backupManager.parser.fromJson<List<String>>(
mangaJson.get(CATEGORIES)
?: JsonArray()
)
val history = backupManager.parser.fromJson<List<DHistory>>(
mangaJson.get(HISTORY)
?: JsonArray()
)
val tracks = backupManager.parser.fromJson<List<TrackImpl>>(
mangaJson.get(TRACK)
?: JsonArray()
)
// EXH -->
manga = EXHMigrations.migrateBackupEntry(manga)
// <-- EXH
try {
val source = backupManager.sourceManager.get(manga.source)
if (source != null) {
restoreMangaData(manga, source, chapters, categories, history, tracks)
} else {
val sourceName = sourceMapping[manga.source] ?: manga.source.toString()
errors.add(Date() to "${manga.title} - ${getString(R.string.source_not_found_name, sourceName)}")
}
} catch (e: Exception) {
errors.add(Date() to "${manga.title} - ${e.message}")
}
restoreProgress += 1
showRestoreProgress(restoreProgress, restoreAmount, manga.title)
}
/**
* Returns a manga restore observable
*
* @param manga manga data from json
* @param source source to get manga data from
* @param chapters chapters data from json
* @param categories categories data from json
* @param history history data from json
* @param tracks tracking data from json
*/
private fun restoreMangaData(
manga: Manga,
source: Source,
chapters: List<Chapter>,
categories: List<String>,
history: List<DHistory>,
tracks: List<Track>
) {
val dbManga = backupManager.getMangaFromDatabase(manga)
db.inTransaction {
if (dbManga == null) {
// Manga not in database
restoreMangaFetch(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
restoreMangaNoFetch(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 restoreMangaFetch(
source: Source,
manga: Manga,
chapters: List<Chapter>,
categories: List<String>,
history: List<DHistory>,
tracks: List<Track>
) {
backupManager.restoreMangaFetchObservable(source, manga)
.onErrorReturn {
errors.add(Date() to "${manga.title} - ${it.message}")
manga
}
.filter { it.id != null }
.flatMap {
chapterFetchObservable(source, it, chapters)
// Convert to the manga that contains new chapters.
.map { manga }
}
.doOnNext {
restoreExtraForManga(it, categories, history, tracks)
}
.flatMap {
trackingFetchObservable(it, tracks)
}
.subscribe()
}
private fun restoreMangaNoFetch(
source: Source,
backupManga: Manga,
chapters: List<Chapter>,
categories: List<String>,
history: List<DHistory>,
tracks: List<Track>
) {
Observable.just(backupManga)
.flatMap { manga ->
if (!backupManager.restoreChaptersForManga(manga, chapters)) {
chapterFetchObservable(source, manga, chapters)
.map { manga }
} else {
Observable.just(manga)
}
}
.doOnNext {
restoreExtraForManga(it, categories, history, tracks)
}
.flatMap { manga ->
trackingFetchObservable(manga, tracks)
}
.subscribe()
}
private fun restoreExtraForManga(manga: Manga, categories: List<String>, history: List<DHistory>, tracks: List<Track>) {
// Restore categories
backupManager.restoreCategoriesForManga(manga, categories)
// Restore history
backupManager.restoreHistoryForManga(history)
// Restore tracking
backupManager.restoreTrackForManga(manga, tracks)
}
/**
* [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 /* SY --> */, throttleManager /* SY <-- */)
// If there's any error, return empty update and continue.
.onErrorReturn {
val errorMessage = if (it is NoChaptersException) {
getString(R.string.no_chapters_error)
} else {
it.message
}
errors.add(Date() to "${manga.title} - $errorMessage")
Pair(emptyList(), emptyList())
}
}
/**
* [Observable] that refreshes tracking information
* @param manga manga that needs updating.
* @param tracks list containing tracks from restore file.
* @return [Observable] that contains updated track item
*/
private fun trackingFetchObservable(manga: Manga, tracks: List<Track>): Observable<Track> {
return Observable.from(tracks)
.flatMap { track ->
val service = trackManager.getService(track.sync_id)
if (service != null && service.isLogged) {
service.refresh(track)
.doOnNext { db.insertTrack(it).executeAsBlocking() }
.onErrorReturn {
errors.add(Date() to "${manga.title} - ${it.message}")
track
}
} else {
errors.add(Date() to "${manga.title} - ${getString(R.string.tracker_not_logged_in, service?.name)}")
Observable.empty()
}
}
}
/**
* Called to update dialog in [BackupConst]
*
* @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
) {
notifier.showRestoreProgress(title, progress, amount)
}
/**
* Write errors to error log
*/
private fun writeErrorLog(): File {
try {
if (errors.isNotEmpty()) {
val destFile = File(externalCacheDir, "tachiyomi_restore.txt")
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("")
}
} }

View File

@ -0,0 +1,612 @@
package eu.kanade.tachiyomi.data.backup.full
import android.content.Context
import android.net.Uri
import com.hippo.unifile.UniFile
import eu.kanade.tachiyomi.data.backup.BackupCreateService.Companion.BACKUP_CATEGORY
import eu.kanade.tachiyomi.data.backup.BackupCreateService.Companion.BACKUP_CATEGORY_MASK
import eu.kanade.tachiyomi.data.backup.BackupCreateService.Companion.BACKUP_CHAPTER
import eu.kanade.tachiyomi.data.backup.BackupCreateService.Companion.BACKUP_CHAPTER_MASK
import eu.kanade.tachiyomi.data.backup.BackupCreateService.Companion.BACKUP_HISTORY
import eu.kanade.tachiyomi.data.backup.BackupCreateService.Companion.BACKUP_HISTORY_MASK
import eu.kanade.tachiyomi.data.backup.BackupCreateService.Companion.BACKUP_TRACK
import eu.kanade.tachiyomi.data.backup.BackupCreateService.Companion.BACKUP_TRACK_MASK
import eu.kanade.tachiyomi.data.backup.full.models.Backup
import eu.kanade.tachiyomi.data.backup.full.models.BackupCategory
import eu.kanade.tachiyomi.data.backup.full.models.BackupChapter
import eu.kanade.tachiyomi.data.backup.full.models.BackupFull
import eu.kanade.tachiyomi.data.backup.full.models.BackupHistory
import eu.kanade.tachiyomi.data.backup.full.models.BackupManga
import eu.kanade.tachiyomi.data.backup.full.models.BackupMergedMangaReference
import eu.kanade.tachiyomi.data.backup.full.models.BackupSavedSearch
import eu.kanade.tachiyomi.data.backup.full.models.BackupSerializer
import eu.kanade.tachiyomi.data.backup.full.models.BackupSource
import eu.kanade.tachiyomi.data.backup.full.models.BackupTracking
import eu.kanade.tachiyomi.data.backup.models.AbstractBackupManager
import eu.kanade.tachiyomi.data.database.DatabaseHelper
import eu.kanade.tachiyomi.data.database.models.Chapter
import eu.kanade.tachiyomi.data.database.models.History
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.database.models.MangaCategory
import eu.kanade.tachiyomi.data.database.models.Track
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.data.track.TrackManager
import eu.kanade.tachiyomi.source.Source
import eu.kanade.tachiyomi.source.SourceManager
import eu.kanade.tachiyomi.source.online.all.EHentai
import eu.kanade.tachiyomi.source.online.all.MergedSource
import eu.kanade.tachiyomi.util.chapter.syncChaptersWithSource
import exh.MERGED_SOURCE_ID
import exh.eh.EHentaiThrottleManager
import exh.savedsearches.JsonSavedSearch
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.buildJsonArray
import kotlinx.serialization.protobuf.ProtoBuf
import okio.buffer
import okio.gzip
import okio.sink
import rx.Observable
import timber.log.Timber
import uy.kohesive.injekt.injectLazy
import kotlin.math.max
@OptIn(ExperimentalSerializationApi::class)
class FullBackupManager(val context: Context) : AbstractBackupManager() {
internal val databaseHelper: DatabaseHelper by injectLazy()
internal val sourceManager: SourceManager by injectLazy()
internal val trackManager: TrackManager by injectLazy()
private val preferences: PreferencesHelper by injectLazy()
/**
* Parser
*/
val parser = ProtoBuf
/**
* Create backup Json file from database
*
* @param uri path of Uri
* @param isJob backup called from job
*/
override fun createBackup(uri: Uri, flags: Int, isJob: Boolean): String? {
// Create root object
var backup: Backup? = null
databaseHelper.inTransaction {
// Get manga from database
val databaseManga = getDatabaseManga()
backup = Backup(
backupManga(databaseManga, flags),
backupCategories(),
backupExtensionInfo(databaseManga),
backupSavedSearches()
)
}
try {
// When BackupCreatorJob
if (isJob) {
// Get dir of file and create
var dir = UniFile.fromUri(context, uri)
dir = dir.createDirectory("automatic")
// Delete older backups
val numberOfBackups = 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(BackupFull.getDefaultFilename())
?: throw Exception("Couldn't create backup file")
val byteArray = parser.encodeToByteArray(BackupSerializer, backup!!)
newFile.openOutputStream().sink().gzip().buffer().use { it.write(byteArray) }
return newFile.uri.toString()
} else {
val file = UniFile.fromUri(context, uri)
?: throw Exception("Couldn't create backup file")
val byteArray = parser.encodeToByteArray(BackupSerializer, backup!!)
file.openOutputStream().sink().gzip().buffer().use { it.write(byteArray) }
return file.uri.toString()
}
} catch (e: Exception) {
Timber.e(e)
throw e
}
}
private fun getDatabaseManga() = getFavoriteManga() /* SY --> */ + getMergedManga().filterNot { it.source == MERGED_SOURCE_ID } /* SY <-- */
private fun backupManga(mangas: List<Manga>, flags: Int): List<BackupManga> {
return mangas.map {
backupMangaObject(it, flags)
}
}
private fun backupExtensionInfo(mangas: List<Manga>): List<BackupSource> {
return mangas
.asSequence()
.map { it.source }
.distinct()
.map { sourceManager.getOrStub(it) }
.map { BackupSource.copyFrom(it) }
.toList()
}
/**
* Backup the categories of library
*
* @return list of [BackupCategory] to be backed up
*/
private fun backupCategories(): List<BackupCategory> {
return databaseHelper.getCategories()
.executeAsBlocking()
.map { BackupCategory.copyFrom(it) }
}
// SY -->
/**
* Backup the saved searches from sources
*
* @return list of [BackupSavedSearch] to be backed up
*/
private fun backupSavedSearches(): List<BackupSavedSearch> {
return preferences.eh_savedSearches().get().map {
val sourceId = it.substringBefore(':').toLong()
val content = Json.decodeFromString<JsonSavedSearch>(it.substringAfter(':'))
BackupSavedSearch(
content.name,
content.query,
content.filters,
sourceId
)
}
}
// SY <--
/**
* Convert a manga to Json
*
* @param manga manga that gets converted
* @param options options for the backup
* @return [BackupManga] containing manga in a serializable form
*/
private fun backupMangaObject(manga: Manga, options: Int): BackupManga {
// Entry for this manga
val mangaObject = BackupManga.copyFrom(manga)
// SY -->
if (manga.source == MERGED_SOURCE_ID) {
manga.id?.let { mangaId ->
mangaObject.mergedMangaReferences = databaseHelper.getMergedMangaReferences(mangaId)
.executeAsBlocking()
.map { BackupMergedMangaReference.copyFrom(it) }
}
}
// SY <--
// Check if user wants chapter information in backup
if (options and BACKUP_CHAPTER_MASK == BACKUP_CHAPTER) {
// Backup all the chapters
val chapters = databaseHelper.getChapters(manga).executeAsBlocking()
if (chapters.isNotEmpty()) {
mangaObject.chapters = chapters.map { BackupChapter.copyFrom(it) }
}
}
// Check if user wants category information in backup
if (options and BACKUP_CATEGORY_MASK == BACKUP_CATEGORY) {
// Backup categories for this manga
val categoriesForManga = databaseHelper.getCategoriesForManga(manga).executeAsBlocking()
if (categoriesForManga.isNotEmpty()) {
mangaObject.categories = categoriesForManga.mapNotNull { it.order }
}
}
// Check if user wants track information in backup
if (options and BACKUP_TRACK_MASK == BACKUP_TRACK) {
val tracks = databaseHelper.getTracks(manga).executeAsBlocking()
if (tracks.isNotEmpty()) {
mangaObject.tracking = tracks.map { BackupTracking.copyFrom(it) }
}
}
// Check if user wants history information in backup
if (options and BACKUP_HISTORY_MASK == BACKUP_HISTORY) {
val historyForManga = databaseHelper.getHistoryByMangaId(manga.id!!).executeAsBlocking()
if (historyForManga.isNotEmpty()) {
val history = historyForManga.mapNotNull { history ->
val url = databaseHelper.getChapter(history.chapter_id).executeAsBlocking()?.url
url?.let { BackupHistory(url, history.last_read) }
}
if (history.isNotEmpty()) {
mangaObject.history = history
}
}
}
return mangaObject
}
fun restoreMangaNoFetch(manga: Manga, dbManga: Manga) {
manga.id = dbManga.id
manga.copyFrom(dbManga)
insertManga(manga)
}
/**
* [Observable] that fetches manga information
*
* @param source source of manga
* @param manga manga that needs updating
* @return [Observable] that contains manga
*/
fun restoreMangaFetchObservable(source: Source?, manga: Manga, online: Boolean): Observable<Manga> {
return if (online && source != null /* SY --> */ && source !is MergedSource /* SY <-- */) {
source.fetchMangaDetails(manga)
.map { networkManga ->
manga.copyFrom(networkManga)
manga.favorite = manga.favorite
manga.initialized = true
manga.id = insertManga(manga)
manga
}
} else {
Observable.just(manga)
.map {
it.initialized = it.description != null
it.id = insertManga(it)
it
}
}
}
/**
* [Observable] that fetches chapter information
*
* @param source source of manga
* @param manga manga that needs updating
* @param chapters list of chapters in the backup
* @param throttleManager e-hentai throttle so it doesnt get banned
* @return [Observable] that contains manga
*/
fun restoreChapterFetchObservable(source: Source, manga: Manga, chapters: List<Chapter> /* SY --> */, throttleManager: EHentaiThrottleManager /* SY <-- */): Observable<Pair<List<Chapter>, List<Chapter>>> {
// SY -->
return (
if (source is EHentai) {
source.fetchChapterList(manga, throttleManager::throttle)
} else {
source.fetchChapterList(manga)
}
).map {
syncChaptersWithSource(databaseHelper, it, manga, source)
}
// SY <--
.doOnNext { pair ->
if (pair.first.isNotEmpty()) {
chapters.forEach { it.manga_id = manga.id }
updateChapters(chapters)
}
}
}
/**
* Restore the categories from Json
*
* @param backupCategories list containing categories
*/
internal fun restoreCategories(backupCategories: List<BackupCategory>) {
// Get categories from file and from db
val dbCategories = databaseHelper.getCategories().executeAsBlocking()
// Iterate over them
backupCategories.map { it.getCategoryImpl() }.forEach { category ->
// Used to know if the category is already in the db
var found = false
for (dbCategory in dbCategories) {
// If the category is already in the db, assign the id to the file's category
// and do nothing
if (category.name == dbCategory.name) {
category.id = dbCategory.id
found = true
break
}
}
// If the category isn't in the db, remove the id and insert a new category
// Store the inserted id in the category
if (!found) {
// Let the db assign the id
category.id = null
val result = databaseHelper.insertCategory(category).executeAsBlocking()
category.id = result.insertedId()?.toInt()
}
}
}
/**
* Restores the categories a manga is in.
*
* @param manga the manga whose categories have to be restored.
* @param categories the categories to restore.
*/
internal fun restoreCategoriesForManga(manga: Manga, categories: List<Int>, backupCategories: List<BackupCategory>) {
val dbCategories = databaseHelper.getCategories().executeAsBlocking()
val mangaCategoriesToUpdate = mutableListOf<MangaCategory>()
for (backupCategoryStr in categories) {
for (backupCategory in backupCategories) {
if (backupCategoryStr == backupCategory.order) {
dbCategories.firstOrNull { it.name == backupCategory.name }?.let { dbCategory ->
mangaCategoriesToUpdate.add(MangaCategory.create(manga, dbCategory))
}
break
}
}
}
// Update database
if (mangaCategoriesToUpdate.isNotEmpty()) {
databaseHelper.deleteOldMangasCategories(listOf(manga)).executeAsBlocking()
databaseHelper.insertMangasCategories(mangaCategoriesToUpdate).executeAsBlocking()
}
}
/**
* Restore history from Json
*
* @param history list containing history to be restored
*/
internal fun restoreHistoryForManga(history: List<BackupHistory>) {
// List containing history to be updated
val historyToBeUpdated = mutableListOf<History>()
for ((url, lastRead) in history) {
val dbHistory = databaseHelper.getHistoryByChapterUrl(url).executeAsBlocking()
// Check if history already in database and update
if (dbHistory != null) {
dbHistory.apply {
last_read = max(lastRead, dbHistory.last_read)
}
historyToBeUpdated.add(dbHistory)
} else {
// If not in database create
databaseHelper.getChapter(url).executeAsBlocking()?.let {
val historyToAdd = History.create(it).apply {
last_read = lastRead
}
historyToBeUpdated.add(historyToAdd)
}
}
}
databaseHelper.updateHistoryLastRead(historyToBeUpdated).executeAsBlocking()
}
/**
* Restores the sync of a manga.
*
* @param manga the manga whose sync have to be restored.
* @param tracks the track list to restore.
*/
internal fun restoreTrackForManga(manga: Manga, tracks: List<Track>) {
// Fix foreign keys with the current manga id
tracks.map { it.manga_id = manga.id!! }
// Get tracks from database
val dbTracks = databaseHelper.getTracks(manga).executeAsBlocking()
val trackToUpdate = mutableListOf<Track>()
tracks.forEach { track ->
val service = trackManager.getService(track.sync_id)
if (service != null && service.isLogged) {
var isInDatabase = false
for (dbTrack in dbTracks) {
if (track.sync_id == dbTrack.sync_id) {
// The sync is already in the db, only update its fields
if (track.media_id != dbTrack.media_id) {
dbTrack.media_id = track.media_id
}
if (track.library_id != dbTrack.library_id) {
dbTrack.library_id = track.library_id
}
dbTrack.last_chapter_read = max(dbTrack.last_chapter_read, track.last_chapter_read)
isInDatabase = true
trackToUpdate.add(dbTrack)
break
}
}
if (!isInDatabase) {
// Insert new sync. Let the db assign the id
track.id = null
trackToUpdate.add(track)
}
}
}
// Update database
if (trackToUpdate.isNotEmpty()) {
databaseHelper.insertTracks(trackToUpdate).executeAsBlocking()
}
}
/**
* Restore the chapters for manga if chapters already in database
*
* @param manga manga of chapters
* @param chapters list containing chapters that get restored
* @return boolean answering if chapter fetch is not needed
*/
internal fun restoreChaptersForManga(manga: Manga, chapters: List<Chapter>): Boolean {
val dbChapters = databaseHelper.getChapters(manga).executeAsBlocking()
// Return if fetch is needed
if (dbChapters.isEmpty() || dbChapters.size < chapters.size) {
return false
}
for (chapter in chapters) {
val pos = dbChapters.indexOf(chapter)
if (pos != -1) {
val dbChapter = dbChapters[pos]
chapter.id = dbChapter.id
chapter.copyFrom(dbChapter)
break
}
}
// Filter the chapters that couldn't be found.
chapters.filter { it.id != null }
chapters.map { it.manga_id = manga.id }
updateChapters(chapters)
return true
}
internal fun restoreChaptersForMangaOffline(manga: Manga, chapters: List<Chapter>) {
val dbChapters = databaseHelper.getChapters(manga).executeAsBlocking()
for (chapter in chapters) {
val pos = dbChapters.indexOf(chapter)
if (pos != -1) {
val dbChapter = dbChapters[pos]
chapter.id = dbChapter.id
chapter.copyFrom(dbChapter)
break
}
}
chapters.map { it.manga_id = manga.id }
updateChapters(chapters.filter { it.id != null })
insertChapters(chapters.filter { it.id == null })
}
// SY -->
internal fun restoreSavedSearches(backupSavedSearches: List<BackupSavedSearch>) {
val currentSavedSearches = preferences.eh_savedSearches().get().map {
val sourceId = it.substringBefore(':').toLong()
val content = Json.decodeFromString<JsonSavedSearch>(it.substringAfter(':'))
BackupSavedSearch(
content.name,
content.query,
content.filters,
sourceId
)
}
preferences.eh_savedSearches()
.set(
(
backupSavedSearches.filter { backupSavedSearch -> currentSavedSearches.all { it.name != backupSavedSearch.name || it.source != backupSavedSearch.source } }
.map {
"${it.source}:" + Json.encodeToString(
JsonSavedSearch(
it.name,
it.query,
it.filterList
)
)
} + preferences.eh_savedSearches().get()
)
.toSet()
)
}
/**
* Restore the categories from Json
*
* @param manga the merge manga for the references
* @param backupMergedMangaReferences the list of backup manga references for the merged manga
*/
internal fun restoreMergedMangaReferencesForManga(manga: Manga, backupMergedMangaReferences: List<BackupMergedMangaReference>) {
// Get merged manga references from file and from db
val dbMergedMangaReferences = databaseHelper.getMergedMangaReferences().executeAsBlocking()
// Iterate over them
backupMergedMangaReferences.forEach { backupMergedMangaReference ->
// Used to know if the merged manga reference is already in the db
var found = false
for (dbMergedMangaReference in dbMergedMangaReferences) {
// If the backupMergedMangaReference is already in the db, assign the id to the file's backupMergedMangaReference
// and do nothing
if (backupMergedMangaReference.mergeUrl == dbMergedMangaReference.mergeUrl && backupMergedMangaReference.mangaUrl == dbMergedMangaReference.mangaUrl) {
found = true
break
}
}
// If the backupMergedMangaReference isn't in the db, remove the id and insert a new backupMergedMangaReference
// Store the inserted id in the backupMergedMangaReference
if (!found) {
// Let the db assign the id
val mergedManga = databaseHelper.getManga(backupMergedMangaReference.mangaUrl, backupMergedMangaReference.mangaSourceId).executeAsBlocking() ?: return@forEach
val mergedMangaReference = backupMergedMangaReference.getMergedMangaReference()
mergedMangaReference.mergeId = manga.id
mergedMangaReference.mangaId = mergedManga.id
databaseHelper.insertMergedManga(mergedMangaReference).executeAsBlocking()
}
}
}
// SY <--
/**
* Returns manga
*
* @return [Manga], null if not found
*/
internal fun getMangaFromDatabase(manga: Manga): Manga? =
databaseHelper.getManga(manga.url, manga.source).executeAsBlocking()
/**
* Returns list containing manga from library
*
* @return [Manga] from library
*/
private fun getFavoriteManga(): List<Manga> =
databaseHelper.getFavoriteMangas().executeAsBlocking()
// SY -->
/**
* Returns list containing merged manga that are possibly not in the library
*
* @return merged [Manga] that are possibly not in the library
*/
private fun getMergedManga(): List<Manga> =
databaseHelper.getMergedMangas().executeAsBlocking()
// SY <--
/**
* Inserts manga and returns id
*
* @return id of [Manga], null if not found
*/
internal fun insertManga(manga: Manga): Long? =
databaseHelper.insertManga(manga).executeAsBlocking().insertedId()
/**
* Inserts list of chapters
*/
private fun insertChapters(chapters: List<Chapter>) {
databaseHelper.insertChapters(chapters).executeAsBlocking()
}
/**
* Updates a list of chapters
*/
private fun updateChapters(chapters: List<Chapter>) {
databaseHelper.updateChaptersBackup(chapters).executeAsBlocking()
}
/**
* Return number of backups.
*
* @return number of backups selected by user
*/
fun numberOfBackups(): Int = preferences.numberOfBackups().get()
}

View File

@ -0,0 +1,327 @@
package eu.kanade.tachiyomi.data.backup.full
import android.content.Context
import android.net.Uri
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.backup.BackupNotifier
import eu.kanade.tachiyomi.data.backup.full.models.BackupCategory
import eu.kanade.tachiyomi.data.backup.full.models.BackupHistory
import eu.kanade.tachiyomi.data.backup.full.models.BackupManga
import eu.kanade.tachiyomi.data.backup.full.models.BackupMergedMangaReference
import eu.kanade.tachiyomi.data.backup.full.models.BackupSavedSearch
import eu.kanade.tachiyomi.data.backup.full.models.BackupSerializer
import eu.kanade.tachiyomi.data.backup.models.AbstractBackupRestore
import eu.kanade.tachiyomi.data.database.models.Chapter
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.database.models.Track
import eu.kanade.tachiyomi.source.Source
import eu.kanade.tachiyomi.source.online.all.MergedSource
import eu.kanade.tachiyomi.util.chapter.NoChaptersException
import exh.EXHMigrations
import exh.MERGED_SOURCE_ID
import kotlinx.serialization.ExperimentalSerializationApi
import okio.buffer
import okio.gzip
import okio.source
import rx.Observable
import java.util.Date
@OptIn(ExperimentalSerializationApi::class)
class FullBackupRestore(context: Context, notifier: BackupNotifier, private val online: Boolean) : AbstractBackupRestore(context, notifier) {
private lateinit var fullBackupManager: FullBackupManager
/**
* Restores data from backup file.
*
* @param uri backup file to restore
*/
override fun restoreBackup(uri: Uri): Boolean {
// SY -->
throttleManager.resetThrottle()
// SY <--
val startTime = System.currentTimeMillis()
// Initialize manager
fullBackupManager = FullBackupManager(context)
val backupString = context.contentResolver.openInputStream(uri)!!.source().gzip().buffer().use { it.readByteArray() }
val backup = fullBackupManager.parser.decodeFromByteArray(BackupSerializer, backupString)
restoreAmount = backup.backupManga.size + 1 /* SY --> */ + 1 /* SY <-- */ // +1 for categories, +1 for saved searches
restoreProgress = 0
errors.clear()
// Restore categories
if (backup.backupCategories.isNotEmpty()) {
restoreCategories(backup.backupCategories)
}
// SY -->
if (backup.backupSavedSearches.isNotEmpty()) {
restoreSavedSearches(backup.backupSavedSearches)
}
// SY <--
// Store source mapping for error messages
sourceMapping = backup.backupSources.map { it.sourceId to it.name }.toMap()
// Restore individual manga, sort by merged source so that merged source manga go last and merged references get the proper ids
backup.backupManga /* SY --> */.sortedBy { it.source == MERGED_SOURCE_ID } /* SY <-- */.forEach {
if (job?.isActive != true) {
return false
}
restoreManga(it, backup.backupCategories, online)
}
val endTime = System.currentTimeMillis()
val time = endTime - startTime
val logFile = writeErrorLog()
notifier.showRestoreComplete(time, errors.size, logFile.parent, logFile.name)
return true
}
private fun restoreCategories(backupCategories: List<BackupCategory>) {
db.inTransaction {
fullBackupManager.restoreCategories(backupCategories)
}
restoreProgress += 1
showRestoreProgress(restoreProgress, restoreAmount, context.getString(R.string.categories))
}
// SY -->
private fun restoreSavedSearches(backupSavedSearches: List<BackupSavedSearch>) {
fullBackupManager.restoreSavedSearches(backupSavedSearches)
restoreProgress += 1
showRestoreProgress(restoreProgress, restoreAmount, context.getString(R.string.saved_searches))
}
// SY <--
private fun restoreManga(backupManga: BackupManga, backupCategories: List<BackupCategory>, online: Boolean) {
var manga = backupManga.getMangaImpl()
val chapters = backupManga.getChaptersImpl()
val categories = backupManga.categories
val history = backupManga.history
val tracks = backupManga.getTrackingImpl()
// SY -->
val mergedMangaReferences = backupManga.mergedMangaReferences
// SY <--
// SY -->
manga = EXHMigrations.migrateBackupEntry(manga)
// SY <--
try {
val source = fullBackupManager.sourceManager.get(manga.source)
if (source != null || !online) {
restoreMangaData(manga, source, chapters, categories, history, tracks, backupCategories, mergedMangaReferences, online)
} else {
val sourceName = sourceMapping[manga.source] ?: manga.source.toString()
errors.add(Date() to "${manga.title} - ${context.getString(R.string.source_not_found_name, sourceName)}")
}
} catch (e: Exception) {
errors.add(Date() to "${manga.title} - ${e.message}")
}
restoreProgress += 1
showRestoreProgress(restoreProgress, restoreAmount, manga.title)
}
/**
* Returns a manga restore observable
*
* @param manga manga data from json
* @param source source to get manga data from
* @param chapters chapters data from json
* @param categories categories data from json
* @param history history data from json
* @param tracks tracking data from json
*/
private fun restoreMangaData(
manga: Manga,
source: Source?,
chapters: List<Chapter>,
categories: List<Int>,
history: List<BackupHistory>,
tracks: List<Track>,
backupCategories: List<BackupCategory>,
mergedMangaReferences: List<BackupMergedMangaReference>,
online: Boolean
) {
val dbManga = fullBackupManager.getMangaFromDatabase(manga)
db.inTransaction {
if (dbManga == null) {
// Manga not in database
restoreMangaFetch(source, manga, chapters, categories, history, tracks, backupCategories, mergedMangaReferences, online)
} else { // Manga in database
// Copy information from manga already in database
fullBackupManager.restoreMangaNoFetch(manga, dbManga)
// Fetch rest of manga information
restoreMangaNoFetch(source, manga, chapters, categories, history, tracks, backupCategories, mergedMangaReferences, online)
}
}
}
/**
* [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 restoreMangaFetch(
source: Source?,
manga: Manga,
chapters: List<Chapter>,
categories: List<Int>,
history: List<BackupHistory>,
tracks: List<Track>,
backupCategories: List<BackupCategory>,
mergedMangaReferences: List<BackupMergedMangaReference>,
online: Boolean
) {
fullBackupManager.restoreMangaFetchObservable(source, manga, online)
.doOnError {
errors.add(Date() to "${manga.title} - ${it.message}")
}
.filter { it.id != null }
.flatMap {
if (online && source != null) {
// SY -->
if (source !is MergedSource) {
chapterFetchObservable(source, it, chapters)
// Convert to the manga that contains new chapters.
.map { manga }
} else {
Observable.just(manga)
}
// SY <--
} else {
fullBackupManager.restoreChaptersForMangaOffline(it, chapters)
Observable.just(manga)
}
}
.doOnNext {
restoreExtraForManga(it, categories, history, tracks, backupCategories, mergedMangaReferences)
}
.flatMap {
trackingFetchObservable(it, tracks)
}
.subscribe()
}
private fun restoreMangaNoFetch(
source: Source?,
backupManga: Manga,
chapters: List<Chapter>,
categories: List<Int>,
history: List<BackupHistory>,
tracks: List<Track>,
backupCategories: List<BackupCategory>,
mergedMangaReferences: List<BackupMergedMangaReference>,
online: Boolean
) {
Observable.just(backupManga)
.flatMap { manga ->
if (online && source != null) {
if (/* SY --> */ source !is MergedSource && /* SY <-- */ !fullBackupManager.restoreChaptersForManga(manga, chapters)) {
chapterFetchObservable(source, manga, chapters)
.map { manga }
} else {
Observable.just(manga)
}
} else {
fullBackupManager.restoreChaptersForMangaOffline(manga, chapters)
Observable.just(manga)
}
}
.doOnNext {
restoreExtraForManga(it, categories, history, tracks, backupCategories, mergedMangaReferences)
}
.flatMap { manga ->
trackingFetchObservable(manga, tracks)
}
.subscribe()
}
private fun restoreExtraForManga(manga: Manga, categories: List<Int>, history: List<BackupHistory>, tracks: List<Track>, backupCategories: List<BackupCategory>, mergedMangaReferences: List<BackupMergedMangaReference>) {
// Restore categories
fullBackupManager.restoreCategoriesForManga(manga, categories, backupCategories)
// Restore history
fullBackupManager.restoreHistoryForManga(history)
// Restore tracking
fullBackupManager.restoreTrackForManga(manga, tracks)
// SY -->
// Restore merged manga references if its a merged manga
fullBackupManager.restoreMergedMangaReferencesForManga(manga, mergedMangaReferences)
// SY <--
}
/**
* [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 fullBackupManager.restoreChapterFetchObservable(source, manga, chapters /* SY --> */, throttleManager /* SY <-- */)
// If there's any error, return empty update and continue.
.onErrorReturn {
val errorMessage = if (it is NoChaptersException) {
context.getString(R.string.no_chapters_error)
} else {
it.message
}
errors.add(Date() to "${manga.title} - $errorMessage")
Pair(emptyList(), emptyList())
}
}
/**
* [Observable] that refreshes tracking information
* @param manga manga that needs updating.
* @param tracks list containing tracks from restore file.
* @return [Observable] that contains updated track item
*/
private fun trackingFetchObservable(manga: Manga, tracks: List<Track>): Observable<Track> {
return Observable.from(tracks)
.flatMap { track ->
val service = trackManager.getService(track.sync_id)
if (service != null && service.isLogged) {
service.refresh(track)
.doOnNext { db.insertTrack(it).executeAsBlocking() }
.onErrorReturn {
errors.add(Date() to "${manga.title} - ${it.message}")
track
}
} else {
errors.add(Date() to "${manga.title} - ${context.getString(R.string.tracker_not_logged_in, service?.name)}")
Observable.empty()
}
}
}
/**
* Called to update dialog in [BackupConst]
*
* @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
) {
notifier.showRestoreProgress(title, progress, amount)
}
}

View File

@ -0,0 +1,49 @@
package eu.kanade.tachiyomi.data.backup.full
import android.content.Context
import android.net.Uri
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.backup.full.models.BackupSerializer
import eu.kanade.tachiyomi.data.backup.models.AbstractBackupRestoreValidator
import kotlinx.serialization.ExperimentalSerializationApi
import okio.buffer
import okio.gzip
import okio.source
@OptIn(ExperimentalSerializationApi::class)
class FullBackupRestoreValidator : AbstractBackupRestoreValidator() {
/**
* Checks for critical backup file data.
*
* @throws Exception if manga cannot be found.
* @return List of missing sources or missing trackers.
*/
override fun validate(context: Context, uri: Uri): Results {
val backupManager = FullBackupManager(context)
val backupString = context.contentResolver.openInputStream(uri)!!.source().gzip().buffer().use { it.readByteArray() }
val backup = backupManager.parser.decodeFromByteArray(BackupSerializer, backupString)
if (backup.backupManga.isEmpty()) {
throw Exception(context.getString(R.string.invalid_backup_file_missing_manga))
}
val sources = backup.backupSources.map { it.sourceId to it.name }.toMap()
val missingSources = sources
.filter { sourceManager.get(it.key) == null }
.values
.sorted()
val trackers = backup.backupManga
.flatMap { it.tracking }
.map { it.syncId }
.distinct()
val missingTrackers = trackers
.mapNotNull { trackManager.getService(it) }
.filter { !it.isLogged }
.map { it.name }
.sorted()
return Results(missingSources, missingTrackers)
}
}

View File

@ -0,0 +1,19 @@
package eu.kanade.tachiyomi.data.backup.full.models
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.Serializable
import kotlinx.serialization.protobuf.ProtoNumber
/**
* Backup json model
*/
@ExperimentalSerializationApi
@Serializable
data class Backup(
@ProtoNumber(1) val backupManga: List<BackupManga>,
@ProtoNumber(2) var backupCategories: List<BackupCategory> = emptyList(),
// Bump by 100 to specify this is a 0.x value
@ProtoNumber(100) var backupSources: List<BackupSource> = emptyList(),
// SY specific values
@ProtoNumber(600) var backupSavedSearches: List<BackupSavedSearch> = emptyList()
)

View File

@ -0,0 +1,39 @@
package eu.kanade.tachiyomi.data.backup.full.models
import eu.kanade.tachiyomi.data.database.models.Category
import eu.kanade.tachiyomi.data.database.models.CategoryImpl
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.Serializable
import kotlinx.serialization.protobuf.ProtoNumber
@ExperimentalSerializationApi
@Serializable
class BackupCategory(
@ProtoNumber(1) var name: String,
@ProtoNumber(2) var order: Int = 0,
// @ProtoNumber(3) val updateInterval: Int = 0, 1.x value not used in 0.x
// Bump by 100 to specify this is a 0.x value
@ProtoNumber(100) var flags: Int = 0,
// SY specific values
@ProtoNumber(600) var mangaOrder: List<Long> = emptyList()
) {
fun getCategoryImpl(): CategoryImpl {
return CategoryImpl().apply {
name = this@BackupCategory.name
flags = this@BackupCategory.flags
order = this@BackupCategory.order
mangaOrder = this@BackupCategory.mangaOrder
}
}
companion object {
fun copyFrom(category: Category): BackupCategory {
return BackupCategory(
name = category.name,
order = category.order,
flags = category.flags,
mangaOrder = category.mangaOrder
)
}
}
}

View File

@ -0,0 +1,58 @@
package eu.kanade.tachiyomi.data.backup.full.models
import eu.kanade.tachiyomi.data.database.models.Chapter
import eu.kanade.tachiyomi.data.database.models.ChapterImpl
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.Serializable
import kotlinx.serialization.protobuf.ProtoNumber
@ExperimentalSerializationApi
@Serializable
data class BackupChapter(
// in 1.x some of these values have different names
// url is called key in 1.x
@ProtoNumber(1) var url: String,
@ProtoNumber(2) var name: String,
@ProtoNumber(3) var scanlator: String? = null,
@ProtoNumber(4) var read: Boolean = false,
@ProtoNumber(5) var bookmark: Boolean = false,
// lastPageRead is called progress in 1.x
@ProtoNumber(6) var lastPageRead: Int = 0,
@ProtoNumber(7) var dateFetch: Long = 0,
@ProtoNumber(8) var dateUpload: Long = 0,
// chapterNumber is called number is 1.x
@ProtoNumber(9) var chapterNumber: Float = 0F,
@ProtoNumber(10) var sourceOrder: Int = 0,
) {
fun toChapterImpl(): ChapterImpl {
return ChapterImpl().apply {
url = this@BackupChapter.url
name = this@BackupChapter.name
chapter_number = this@BackupChapter.chapterNumber
scanlator = this@BackupChapter.scanlator
read = this@BackupChapter.read
bookmark = this@BackupChapter.bookmark
last_page_read = this@BackupChapter.lastPageRead
date_fetch = this@BackupChapter.dateFetch
date_upload = this@BackupChapter.dateUpload
source_order = this@BackupChapter.sourceOrder
}
}
companion object {
fun copyFrom(chapter: Chapter): BackupChapter {
return BackupChapter(
url = chapter.url,
name = chapter.name,
chapterNumber = chapter.chapter_number,
scanlator = chapter.scanlator,
read = chapter.read,
bookmark = chapter.bookmark,
lastPageRead = chapter.last_page_read,
dateFetch = chapter.date_fetch,
dateUpload = chapter.date_upload,
sourceOrder = chapter.source_order
)
}
}
}

View File

@ -0,0 +1,12 @@
package eu.kanade.tachiyomi.data.backup.full.models
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
object BackupFull {
fun getDefaultFilename(): String {
val date = SimpleDateFormat("yyyy-MM-dd_HH-mm", Locale.getDefault()).format(Date())
return "tachiyomi_full_$date.proto.gz"
}
}

View File

@ -0,0 +1,12 @@
package eu.kanade.tachiyomi.data.backup.full.models
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.Serializable
import kotlinx.serialization.protobuf.ProtoNumber
@ExperimentalSerializationApi
@Serializable
data class BackupHistory(
@ProtoNumber(0) var url: String,
@ProtoNumber(1) var lastRead: Long
)

View File

@ -0,0 +1,91 @@
package eu.kanade.tachiyomi.data.backup.full.models
import eu.kanade.tachiyomi.data.database.models.ChapterImpl
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.database.models.MangaImpl
import eu.kanade.tachiyomi.data.database.models.TrackImpl
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.Serializable
import kotlinx.serialization.protobuf.ProtoNumber
@ExperimentalSerializationApi
@Serializable
data class BackupManga(
// in 1.x some of these values have different names
@ProtoNumber(1) var source: Long,
// url is called key in 1.x
@ProtoNumber(2) var url: String,
@ProtoNumber(3) var title: String = "",
@ProtoNumber(4) var artist: String? = null,
@ProtoNumber(5) var author: String? = null,
@ProtoNumber(6) var description: String? = null,
@ProtoNumber(7) var genre: List<String> = emptyList(),
@ProtoNumber(8) var status: Int = 0,
// thumbnailUrl is called cover in 1.x
@ProtoNumber(9) var thumbnailUrl: String? = null,
// @ProtoNumber(10) val customCover: String = "", 1.x value, not used in 0.x
// @ProtoNumber(11) val lastUpdate: Long = 0, 1.x value, not used in 0.x
// @ProtoNumber(12) val lastInit: Long = 0, 1.x value, not used in 0.x
@ProtoNumber(13) var dateAdded: Long = 0,
@ProtoNumber(14) var viewer: Int = 0,
// @ProtoNumber(15) val flags: Int = 0, 1.x value, not used in 0.x
@ProtoNumber(15) var chapters: List<BackupChapter> = emptyList(),
@ProtoNumber(14) var categories: List<Int> = emptyList(),
@ProtoNumber(16) var tracking: List<BackupTracking> = emptyList(),
// Bump by 100 for values that are not saved/implemented in 1.x but are used in 0.x
@ProtoNumber(100) var favorite: Boolean = true,
@ProtoNumber(101) var chapterFlags: Int = 0,
@ProtoNumber(102) var history: List<BackupHistory> = emptyList(),
// SY specific values
@ProtoNumber(600) var mergedMangaReferences: List<BackupMergedMangaReference> = emptyList()
) {
fun getMangaImpl(): MangaImpl {
return MangaImpl().apply {
url = this@BackupManga.url
title = this@BackupManga.title
artist = this@BackupManga.artist
author = this@BackupManga.author
description = this@BackupManga.description
genre = this@BackupManga.genre.joinToString()
status = this@BackupManga.status
thumbnail_url = this@BackupManga.thumbnailUrl
favorite = this@BackupManga.favorite
source = this@BackupManga.source
date_added = this@BackupManga.dateAdded
viewer = this@BackupManga.viewer
chapter_flags = this@BackupManga.chapterFlags
}
}
fun getChaptersImpl(): List<ChapterImpl> {
return chapters.map {
it.toChapterImpl()
}
}
fun getTrackingImpl(): List<TrackImpl> {
return tracking.map {
it.getTrackingImpl()
}
}
companion object {
fun copyFrom(manga: Manga): BackupManga {
return BackupManga(
url = manga.url,
title = manga.title,
artist = manga.artist,
author = manga.author,
description = manga.description,
genre = manga.getGenres() ?: emptyList(),
status = manga.status,
thumbnailUrl = manga.thumbnail_url,
favorite = manga.favorite,
source = manga.source,
dateAdded = manga.date_added,
viewer = manga.viewer,
chapterFlags = manga.chapter_flags
)
}
}
}

View File

@ -0,0 +1,53 @@
package eu.kanade.tachiyomi.data.backup.full.models
import exh.merged.sql.models.MergedMangaReference
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.Serializable
import kotlinx.serialization.protobuf.ProtoNumber
/*
* SY merged manga backup class
*/
@ExperimentalSerializationApi
@Serializable
data class BackupMergedMangaReference(
@ProtoNumber(1) var isInfoManga: Boolean,
@ProtoNumber(2) var getChapterUpdates: Boolean,
@ProtoNumber(3) var chapterSortMode: Int,
@ProtoNumber(4) var chapterPriority: Int,
@ProtoNumber(5) var downloadChapters: Boolean,
@ProtoNumber(6) var mergeUrl: String,
@ProtoNumber(7) var mangaUrl: String,
@ProtoNumber(8) var mangaSourceId: Long
) {
fun getMergedMangaReference(): MergedMangaReference {
return MergedMangaReference(
isInfoManga = isInfoManga,
getChapterUpdates = getChapterUpdates,
chapterSortMode = chapterSortMode,
chapterPriority = chapterPriority,
downloadChapters = downloadChapters,
mergeUrl = mergeUrl,
mangaUrl = mangaUrl,
mangaSourceId = mangaSourceId,
mergeId = null,
mangaId = null,
id = null
)
}
companion object {
fun copyFrom(mergedMangaReference: MergedMangaReference): BackupMergedMangaReference {
return BackupMergedMangaReference(
isInfoManga = mergedMangaReference.isInfoManga,
getChapterUpdates = mergedMangaReference.getChapterUpdates,
chapterSortMode = mergedMangaReference.chapterSortMode,
chapterPriority = mergedMangaReference.chapterPriority,
downloadChapters = mergedMangaReference.downloadChapters,
mergeUrl = mergedMangaReference.mergeUrl,
mangaUrl = mergedMangaReference.mangaUrl,
mangaSourceId = mergedMangaReference.mangaSourceId
)
}
}
}

View File

@ -0,0 +1,19 @@
package eu.kanade.tachiyomi.data.backup.full.models
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.buildJsonArray
import kotlinx.serialization.protobuf.ProtoNumber
/*
* SY saved searches class
*/
@ExperimentalSerializationApi
@Serializable
data class BackupSavedSearch(
@ProtoNumber(1) val name: String,
@ProtoNumber(2) val query: String = "",
@ProtoNumber(3) val filterList: JsonArray = buildJsonArray {},
@ProtoNumber(4) val source: Long = 0
)

View File

@ -0,0 +1,8 @@
package eu.kanade.tachiyomi.data.backup.full.models
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.Serializer
@ExperimentalSerializationApi
@Serializer(forClass = Backup::class)
object BackupSerializer

View File

@ -0,0 +1,22 @@
package eu.kanade.tachiyomi.data.backup.full.models
import eu.kanade.tachiyomi.source.Source
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.Serializable
import kotlinx.serialization.protobuf.ProtoNumber
@ExperimentalSerializationApi
@Serializable
data class BackupSource(
@ProtoNumber(0) var name: String = "",
@ProtoNumber(1) var sourceId: Long
) {
companion object {
fun copyFrom(source: Source): BackupSource {
return BackupSource(
name = source.name,
sourceId = source.id
)
}
}
}

View File

@ -0,0 +1,68 @@
package eu.kanade.tachiyomi.data.backup.full.models
import eu.kanade.tachiyomi.data.database.models.Track
import eu.kanade.tachiyomi.data.database.models.TrackImpl
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.Serializable
import kotlinx.serialization.protobuf.ProtoNumber
@ExperimentalSerializationApi
@Serializable
data class BackupTracking(
// in 1.x some of these values have different types or names
// syncId is called siteId in 1,x
@ProtoNumber(1) var syncId: Int,
// LibraryId is not null in 1.x
@ProtoNumber(2) var libraryId: Long,
@ProtoNumber(3) var mediaId: Int = 0,
// trackingUrl is called mediaUrl in 1.x
@ProtoNumber(4) var trackingUrl: String = "",
@ProtoNumber(5) var title: String = "",
// lastChapterRead is called last read, and it has been changed to a float in 1.x
@ProtoNumber(6) var lastChapterRead: Float = 0F,
@ProtoNumber(7) var totalChapters: Int = 0,
@ProtoNumber(8) var score: Float = 0F,
@ProtoNumber(9) var status: Int = 0,
// startedReadingDate is called startReadTime in 1.x
@ProtoNumber(10) var startedReadingDate: Long = 0,
// finishedReadingDate is called endReadTime in 1.x
@ProtoNumber(11) var finishedReadingDate: Long = 0,
) {
fun getTrackingImpl(): TrackImpl {
return TrackImpl().apply {
sync_id = this@BackupTracking.syncId
media_id = this@BackupTracking.mediaId
library_id = this@BackupTracking.libraryId
title = this@BackupTracking.title
// convert from float to int because of 1.x types
last_chapter_read = this@BackupTracking.lastChapterRead.toInt()
total_chapters = this@BackupTracking.totalChapters
score = this@BackupTracking.score
status = this@BackupTracking.status
started_reading_date = this@BackupTracking.startedReadingDate
finished_reading_date = this@BackupTracking.finishedReadingDate
tracking_url = this@BackupTracking.trackingUrl
}
}
companion object {
fun copyFrom(track: Track): BackupTracking {
return BackupTracking(
syncId = track.sync_id,
mediaId = track.media_id,
// forced not null so its compatible with 1.x backup system
libraryId = track.library_id!!,
title = track.title,
// convert to float for 1.x
lastChapterRead = track.last_chapter_read.toFloat(),
totalChapters = track.total_chapters,
score = track.score,
status = track.status,
startedReadingDate = track.started_reading_date,
finishedReadingDate = track.finished_reading_date,
trackingUrl = track.tracking_url
)
}
}
}

View File

@ -1,9 +1,8 @@
package eu.kanade.tachiyomi.data.backup package eu.kanade.tachiyomi.data.backup.legacy
import android.content.Context import android.content.Context
import android.net.Uri import android.net.Uri
import com.github.salomonbrys.kotson.fromJson import com.github.salomonbrys.kotson.fromJson
import com.github.salomonbrys.kotson.jsonObject
import com.github.salomonbrys.kotson.registerTypeAdapter import com.github.salomonbrys.kotson.registerTypeAdapter
import com.github.salomonbrys.kotson.registerTypeHierarchyAdapter import com.github.salomonbrys.kotson.registerTypeHierarchyAdapter
import com.github.salomonbrys.kotson.set import com.github.salomonbrys.kotson.set
@ -22,23 +21,24 @@ import eu.kanade.tachiyomi.data.backup.BackupCreateService.Companion.BACKUP_HIST
import eu.kanade.tachiyomi.data.backup.BackupCreateService.Companion.BACKUP_HISTORY_MASK import eu.kanade.tachiyomi.data.backup.BackupCreateService.Companion.BACKUP_HISTORY_MASK
import eu.kanade.tachiyomi.data.backup.BackupCreateService.Companion.BACKUP_TRACK import eu.kanade.tachiyomi.data.backup.BackupCreateService.Companion.BACKUP_TRACK
import eu.kanade.tachiyomi.data.backup.BackupCreateService.Companion.BACKUP_TRACK_MASK import eu.kanade.tachiyomi.data.backup.BackupCreateService.Companion.BACKUP_TRACK_MASK
import eu.kanade.tachiyomi.data.backup.models.Backup import eu.kanade.tachiyomi.data.backup.legacy.models.Backup
import eu.kanade.tachiyomi.data.backup.models.Backup.CATEGORIES import eu.kanade.tachiyomi.data.backup.legacy.models.Backup.CATEGORIES
import eu.kanade.tachiyomi.data.backup.models.Backup.CHAPTERS import eu.kanade.tachiyomi.data.backup.legacy.models.Backup.CHAPTERS
import eu.kanade.tachiyomi.data.backup.models.Backup.CURRENT_VERSION import eu.kanade.tachiyomi.data.backup.legacy.models.Backup.CURRENT_VERSION
import eu.kanade.tachiyomi.data.backup.models.Backup.EXTENSIONS import eu.kanade.tachiyomi.data.backup.legacy.models.Backup.EXTENSIONS
import eu.kanade.tachiyomi.data.backup.models.Backup.HISTORY import eu.kanade.tachiyomi.data.backup.legacy.models.Backup.HISTORY
import eu.kanade.tachiyomi.data.backup.models.Backup.MANGA import eu.kanade.tachiyomi.data.backup.legacy.models.Backup.MANGA
import eu.kanade.tachiyomi.data.backup.models.Backup.MERGEDMANGAREFERENCES import eu.kanade.tachiyomi.data.backup.legacy.models.Backup.MERGEDMANGAREFERENCES
import eu.kanade.tachiyomi.data.backup.models.Backup.SAVEDSEARCHES import eu.kanade.tachiyomi.data.backup.legacy.models.Backup.SAVEDSEARCHES
import eu.kanade.tachiyomi.data.backup.models.Backup.TRACK import eu.kanade.tachiyomi.data.backup.legacy.models.Backup.TRACK
import eu.kanade.tachiyomi.data.backup.models.DHistory import eu.kanade.tachiyomi.data.backup.legacy.models.DHistory
import eu.kanade.tachiyomi.data.backup.serializer.CategoryTypeAdapter import eu.kanade.tachiyomi.data.backup.models.AbstractBackupManager
import eu.kanade.tachiyomi.data.backup.serializer.ChapterTypeAdapter import eu.kanade.tachiyomi.data.backup.legacy.serializer.CategoryTypeAdapter
import eu.kanade.tachiyomi.data.backup.serializer.HistoryTypeAdapter import eu.kanade.tachiyomi.data.backup.legacy.serializer.ChapterTypeAdapter
import eu.kanade.tachiyomi.data.backup.serializer.MangaTypeAdapter import eu.kanade.tachiyomi.data.backup.legacy.serializer.HistoryTypeAdapter
import eu.kanade.tachiyomi.data.backup.serializer.MergedMangaReferenceTypeAdapter import eu.kanade.tachiyomi.data.backup.legacy.serializer.MangaTypeAdapter
import eu.kanade.tachiyomi.data.backup.serializer.TrackTypeAdapter import eu.kanade.tachiyomi.data.backup.legacy.serializer.MergedMangaReferenceTypeAdapter
import eu.kanade.tachiyomi.data.backup.legacy.serializer.TrackTypeAdapter
import eu.kanade.tachiyomi.data.database.DatabaseHelper import eu.kanade.tachiyomi.data.database.DatabaseHelper
import eu.kanade.tachiyomi.data.database.models.CategoryImpl import eu.kanade.tachiyomi.data.database.models.CategoryImpl
import eu.kanade.tachiyomi.data.database.models.Chapter import eu.kanade.tachiyomi.data.database.models.Chapter
@ -51,7 +51,6 @@ import eu.kanade.tachiyomi.data.database.models.Track
import eu.kanade.tachiyomi.data.database.models.TrackImpl import eu.kanade.tachiyomi.data.database.models.TrackImpl
import eu.kanade.tachiyomi.data.preference.PreferencesHelper import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.data.track.TrackManager import eu.kanade.tachiyomi.data.track.TrackManager
import eu.kanade.tachiyomi.source.CatalogueSource
import eu.kanade.tachiyomi.source.LocalSource import eu.kanade.tachiyomi.source.LocalSource
import eu.kanade.tachiyomi.source.Source import eu.kanade.tachiyomi.source.Source
import eu.kanade.tachiyomi.source.SourceManager import eu.kanade.tachiyomi.source.SourceManager
@ -61,23 +60,22 @@ import eu.kanade.tachiyomi.util.chapter.syncChaptersWithSource
import exh.MERGED_SOURCE_ID import exh.MERGED_SOURCE_ID
import exh.eh.EHentaiThrottleManager import exh.eh.EHentaiThrottleManager
import exh.merged.sql.models.MergedMangaReference import exh.merged.sql.models.MergedMangaReference
import exh.savedsearches.EXHSavedSearch
import exh.savedsearches.JsonSavedSearch import exh.savedsearches.JsonSavedSearch
import exh.util.asObservable import exh.util.asObservable
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import kotlinx.serialization.decodeFromString import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import rx.Observable import rx.Observable
import timber.log.Timber import timber.log.Timber
import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get import uy.kohesive.injekt.api.get
import uy.kohesive.injekt.injectLazy import uy.kohesive.injekt.injectLazy
import xyz.nulldev.ts.api.http.serializer.FilterSerializer
import java.lang.RuntimeException import java.lang.RuntimeException
import kotlin.math.max import kotlin.math.max
class BackupManager(val context: Context, version: Int = CURRENT_VERSION) { class LegacyBackupManager(val context: Context, version: Int = CURRENT_VERSION) : AbstractBackupManager() {
internal val databaseHelper: DatabaseHelper by injectLazy() internal val databaseHelper: DatabaseHelper by injectLazy()
internal val sourceManager: SourceManager by injectLazy() internal val sourceManager: SourceManager by injectLazy()
@ -126,7 +124,7 @@ class BackupManager(val context: Context, version: Int = CURRENT_VERSION) {
* @param uri path of Uri * @param uri path of Uri
* @param isJob backup called from job * @param isJob backup called from job
*/ */
fun createBackup(uri: Uri, flags: Int, isJob: Boolean): String? { override fun createBackup(uri: Uri, flags: Int, isJob: Boolean): String? {
// Create root object // Create root object
val root = JsonObject() val root = JsonObject()
@ -547,25 +545,12 @@ class BackupManager(val context: Context, version: Int = CURRENT_VERSION) {
// SY --> // SY -->
internal fun restoreSavedSearches(jsonSavedSearches: JsonElement) { internal fun restoreSavedSearches(jsonSavedSearches: JsonElement) {
val backupSavedSearches = jsonSavedSearches.asString.split("***").toSet() val backupSavedSearches = jsonSavedSearches.asString.split("***").toSet()
val filterSerializer = FilterSerializer()
val newSavedSearches = backupSavedSearches.mapNotNull { val newSavedSearches = backupSavedSearches.mapNotNull {
try { try {
val id = it.substringBefore(':').toLong() val id = it.substringBefore(':').toLong()
val content = Json.decodeFromString<JsonSavedSearch>(it.substringAfter(':')) val content = Json.decodeFromString<JsonSavedSearch>(it.substringAfter(':'))
val source = sourceManager.getOrStub(id) id to content
if (source !is CatalogueSource) return@mapNotNull null
val originalFilters = source.getFilterList()
filterSerializer.deserialize(originalFilters, content.filters)
Pair(
id,
EXHSavedSearch(
content.name,
content.query,
originalFilters
)
)
} catch (t: RuntimeException) { } catch (t: RuntimeException) {
// Load failed // Load failed
Timber.e(t, "Failed to load saved search!") Timber.e(t, "Failed to load saved search!")
@ -580,20 +565,7 @@ class BackupManager(val context: Context, version: Int = CURRENT_VERSION) {
try { try {
val id = it.substringBefore(':').toLong() val id = it.substringBefore(':').toLong()
val content = Json.decodeFromString<JsonSavedSearch>(it.substringAfter(':')) val content = Json.decodeFromString<JsonSavedSearch>(it.substringAfter(':'))
if (id !in currentSources) return@mapNotNull null id to content
val source = sourceManager.getOrStub(id)
if (source !is CatalogueSource) return@mapNotNull null
val originalFilters = source.getFilterList()
filterSerializer.deserialize(originalFilters, content.filters)
Pair(
id,
EXHSavedSearch(
content.name,
content.query,
originalFilters
)
)
} catch (t: RuntimeException) { } catch (t: RuntimeException) {
// Load failed // Load failed
Timber.e(t, "Failed to load saved search!") Timber.e(t, "Failed to load saved search!")
@ -608,15 +580,8 @@ class BackupManager(val context: Context, version: Int = CURRENT_VERSION) {
it it
} }
/*.filter {
!it.startsWith("${newSource.id}:")
}*/
val newSerialized = newSavedSearches.map { val newSerialized = newSavedSearches.map {
"${it.first}:" + jsonObject( "${it.first}:" + Json.encodeToString(it.second)
"name" to it.second.name,
"query" to it.second.query,
"filters" to filterSerializer.serialize(it.second.filterList)
).toString()
} }
preferences.eh_savedSearches().set((otherSerialized + newSerialized).toSet()) preferences.eh_savedSearches().set((otherSerialized + newSerialized).toSet())
} }

View File

@ -0,0 +1,324 @@
package eu.kanade.tachiyomi.data.backup.legacy
import android.content.Context
import android.net.Uri
import com.github.salomonbrys.kotson.fromJson
import com.google.gson.JsonArray
import com.google.gson.JsonElement
import com.google.gson.JsonObject
import com.google.gson.JsonParser
import com.google.gson.stream.JsonReader
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.backup.BackupConst
import eu.kanade.tachiyomi.data.backup.BackupNotifier
import eu.kanade.tachiyomi.data.backup.legacy.models.Backup
import eu.kanade.tachiyomi.data.backup.legacy.models.Backup.MANGAS
import eu.kanade.tachiyomi.data.backup.legacy.models.DHistory
import eu.kanade.tachiyomi.data.backup.models.AbstractBackupRestore
import eu.kanade.tachiyomi.data.database.models.Chapter
import eu.kanade.tachiyomi.data.database.models.ChapterImpl
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.database.models.MangaImpl
import eu.kanade.tachiyomi.data.database.models.Track
import eu.kanade.tachiyomi.data.database.models.TrackImpl
import eu.kanade.tachiyomi.source.Source
import eu.kanade.tachiyomi.util.chapter.NoChaptersException
import exh.EXHMigrations
import rx.Observable
import java.util.Date
class LegacyBackupRestore(context: Context, notifier: BackupNotifier) : AbstractBackupRestore(context, notifier) {
private lateinit var backupManager: LegacyBackupManager
/**
* Restores data from backup file.
*
* @param uri backup file to restore
*/
override fun restoreBackup(uri: Uri): Boolean {
// SY -->
throttleManager.resetThrottle()
// SY <--
val startTime = System.currentTimeMillis()
val reader = JsonReader(context.contentResolver.openInputStream(uri)!!.bufferedReader())
val json = JsonParser.parseReader(reader).asJsonObject
// Get parser version
val version = json.get(Backup.VERSION)?.asInt ?: 1
// Initialize manager
backupManager = LegacyBackupManager(context, version)
val mangasJson = json.get(MANGAS).asJsonArray
restoreAmount = mangasJson.size() + 3 // +1 for categories, +1 for saved searches, +1 for merged manga references
restoreProgress = 0
errors.clear()
// Restore categories
json.get(Backup.CATEGORIES)?.let { restoreCategories(it) }
// SY -->
json.get(Backup.SAVEDSEARCHES)?.let { restoreSavedSearches(it) }
json.get(Backup.MERGEDMANGAREFERENCES)?.let { restoreMergedMangaReferences(it) }
// SY <--
// Store source mapping for error messages
sourceMapping = LegacyBackupRestoreValidator.getSourceMapping(json)
// Restore individual manga
mangasJson.forEach {
if (job?.isActive != true) {
return false
}
restoreManga(it.asJsonObject)
}
val endTime = System.currentTimeMillis()
val time = endTime - startTime
val logFile = writeErrorLog()
notifier.showRestoreComplete(time, errors.size, logFile.parent, logFile.name)
return true
}
private fun restoreCategories(categoriesJson: JsonElement) {
db.inTransaction {
backupManager.restoreCategories(categoriesJson.asJsonArray)
}
restoreProgress += 1
showRestoreProgress(restoreProgress, restoreAmount, context.getString(R.string.categories))
}
// SY -->
private fun restoreSavedSearches(savedSearchesJson: JsonElement) {
backupManager.restoreSavedSearches(savedSearchesJson)
restoreProgress += 1
showRestoreProgress(restoreProgress, restoreAmount, context.getString(R.string.saved_searches))
}
private fun restoreMergedMangaReferences(mergedMangaReferencesJson: JsonElement) {
db.inTransaction {
backupManager.restoreMergedMangaReferences(mergedMangaReferencesJson.asJsonArray)
}
restoreProgress += 1
showRestoreProgress(restoreProgress, restoreAmount, context.getString(R.string.merged_references))
}
// SY <--
private fun restoreManga(mangaJson: JsonObject) {
/* SY --> */ var /* SY <-- */ manga = backupManager.parser.fromJson<MangaImpl>(
mangaJson.get(
Backup.MANGA
)
)
val chapters = backupManager.parser.fromJson<List<ChapterImpl>>(
mangaJson.get(Backup.CHAPTERS)
?: JsonArray()
)
val categories = backupManager.parser.fromJson<List<String>>(
mangaJson.get(Backup.CATEGORIES)
?: JsonArray()
)
val history = backupManager.parser.fromJson<List<DHistory>>(
mangaJson.get(Backup.HISTORY)
?: JsonArray()
)
val tracks = backupManager.parser.fromJson<List<TrackImpl>>(
mangaJson.get(Backup.TRACK)
?: JsonArray()
)
// EXH -->
manga = EXHMigrations.migrateBackupEntry(manga)
// <-- EXH
try {
val source = backupManager.sourceManager.get(manga.source)
if (source != null) {
restoreMangaData(manga, source, chapters, categories, history, tracks)
} else {
val sourceName = sourceMapping[manga.source] ?: manga.source.toString()
errors.add(Date() to "${manga.title} - ${context.getString(R.string.source_not_found_name, sourceName)}")
}
} catch (e: Exception) {
errors.add(Date() to "${manga.title} - ${e.message}")
}
restoreProgress += 1
showRestoreProgress(restoreProgress, restoreAmount, manga.title)
}
/**
* Returns a manga restore observable
*
* @param manga manga data from json
* @param source source to get manga data from
* @param chapters chapters data from json
* @param categories categories data from json
* @param history history data from json
* @param tracks tracking data from json
*/
private fun restoreMangaData(
manga: Manga,
source: Source,
chapters: List<Chapter>,
categories: List<String>,
history: List<DHistory>,
tracks: List<Track>
) {
val dbManga = backupManager.getMangaFromDatabase(manga)
db.inTransaction {
if (dbManga == null) {
// Manga not in database
restoreMangaFetch(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
restoreMangaNoFetch(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 restoreMangaFetch(
source: Source,
manga: Manga,
chapters: List<Chapter>,
categories: List<String>,
history: List<DHistory>,
tracks: List<Track>
) {
backupManager.restoreMangaFetchObservable(source, manga)
.onErrorReturn {
errors.add(Date() to "${manga.title} - ${it.message}")
manga
}
.filter { it.id != null }
.flatMap {
chapterFetchObservable(source, it, chapters)
// Convert to the manga that contains new chapters.
.map { manga }
}
.doOnNext {
restoreExtraForManga(it, categories, history, tracks)
}
.flatMap {
trackingFetchObservable(it, tracks)
}
.subscribe()
}
private fun restoreMangaNoFetch(
source: Source,
backupManga: Manga,
chapters: List<Chapter>,
categories: List<String>,
history: List<DHistory>,
tracks: List<Track>
) {
Observable.just(backupManga)
.flatMap { manga ->
if (!backupManager.restoreChaptersForManga(manga, chapters)) {
chapterFetchObservable(source, manga, chapters)
.map { manga }
} else {
Observable.just(manga)
}
}
.doOnNext {
restoreExtraForManga(it, categories, history, tracks)
}
.flatMap { manga ->
trackingFetchObservable(manga, tracks)
}
.subscribe()
}
private fun restoreExtraForManga(manga: Manga, categories: List<String>, history: List<DHistory>, tracks: List<Track>) {
// Restore categories
backupManager.restoreCategoriesForManga(manga, categories)
// Restore history
backupManager.restoreHistoryForManga(history)
// Restore tracking
backupManager.restoreTrackForManga(manga, tracks)
}
/**
* [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 /* SY --> */, throttleManager /* SY <-- */)
// If there's any error, return empty update and continue.
.onErrorReturn {
val errorMessage = if (it is NoChaptersException) {
context.getString(R.string.no_chapters_error)
} else {
it.message
}
errors.add(Date() to "${manga.title} - $errorMessage")
Pair(emptyList(), emptyList())
}
}
/**
* [Observable] that refreshes tracking information
* @param manga manga that needs updating.
* @param tracks list containing tracks from restore file.
* @return [Observable] that contains updated track item
*/
private fun trackingFetchObservable(manga: Manga, tracks: List<Track>): Observable<Track> {
return Observable.from(tracks)
.flatMap { track ->
val service = trackManager.getService(track.sync_id)
if (service != null && service.isLogged) {
service.refresh(track)
.doOnNext { db.insertTrack(it).executeAsBlocking() }
.onErrorReturn {
errors.add(Date() to "${manga.title} - ${it.message}")
track
}
} else {
errors.add(Date() to "${manga.title} - ${context.getString(R.string.tracker_not_logged_in, service?.name)}")
Observable.empty()
}
}
}
/**
* Called to update dialog in [BackupConst]
*
* @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
) {
notifier.showRestoreProgress(title, progress, amount)
}
}

View File

@ -1,4 +1,4 @@
package eu.kanade.tachiyomi.data.backup package eu.kanade.tachiyomi.data.backup.legacy
import android.content.Context import android.content.Context
import android.net.Uri import android.net.Uri
@ -6,23 +6,17 @@ import com.google.gson.JsonObject
import com.google.gson.JsonParser import com.google.gson.JsonParser
import com.google.gson.stream.JsonReader import com.google.gson.stream.JsonReader
import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.backup.models.Backup import eu.kanade.tachiyomi.data.backup.legacy.models.Backup
import eu.kanade.tachiyomi.data.track.TrackManager import eu.kanade.tachiyomi.data.backup.models.AbstractBackupRestoreValidator
import eu.kanade.tachiyomi.source.SourceManager
import uy.kohesive.injekt.injectLazy
object BackupRestoreValidator {
private val sourceManager: SourceManager by injectLazy()
private val trackManager: TrackManager by injectLazy()
class LegacyBackupRestoreValidator : AbstractBackupRestoreValidator() {
/** /**
* Checks for critical backup file data. * Checks for critical backup file data.
* *
* @throws Exception if version or manga cannot be found. * @throws Exception if version or manga cannot be found.
* @return List of missing sources or missing trackers. * @return List of missing sources or missing trackers.
*/ */
fun validate(context: Context, uri: Uri): Results { override fun validate(context: Context, uri: Uri): Results {
val reader = JsonReader(context.contentResolver.openInputStream(uri)!!.bufferedReader()) val reader = JsonReader(context.contentResolver.openInputStream(uri)!!.bufferedReader())
val json = JsonParser.parseReader(reader).asJsonObject val json = JsonParser.parseReader(reader).asJsonObject
@ -57,6 +51,7 @@ object BackupRestoreValidator {
return Results(missingSources, missingTrackers) return Results(missingSources, missingTrackers)
} }
companion object {
fun getSourceMapping(json: JsonObject): Map<Long, String> { fun getSourceMapping(json: JsonObject): Map<Long, String> {
val extensionsMapping = json.get(Backup.EXTENSIONS) ?: return emptyMap() val extensionsMapping = json.get(Backup.EXTENSIONS) ?: return emptyMap()
@ -67,6 +62,5 @@ object BackupRestoreValidator {
} }
.toMap() .toMap()
} }
}
data class Results(val missingSources: List<String>, val missingTrackers: List<String>)
} }

View File

@ -1,4 +1,4 @@
package eu.kanade.tachiyomi.data.backup.models package eu.kanade.tachiyomi.data.backup.legacy.models
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.Date import java.util.Date

View File

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

View File

@ -1,4 +1,4 @@
package eu.kanade.tachiyomi.data.backup.serializer package eu.kanade.tachiyomi.data.backup.legacy.serializer
import com.github.salomonbrys.kotson.typeAdapter import com.github.salomonbrys.kotson.typeAdapter
import com.google.gson.TypeAdapter import com.google.gson.TypeAdapter

View File

@ -1,4 +1,4 @@
package eu.kanade.tachiyomi.data.backup.serializer package eu.kanade.tachiyomi.data.backup.legacy.serializer
import com.github.salomonbrys.kotson.typeAdapter import com.github.salomonbrys.kotson.typeAdapter
import com.google.gson.TypeAdapter import com.google.gson.TypeAdapter

View File

@ -1,8 +1,8 @@
package eu.kanade.tachiyomi.data.backup.serializer package eu.kanade.tachiyomi.data.backup.legacy.serializer
import com.github.salomonbrys.kotson.typeAdapter import com.github.salomonbrys.kotson.typeAdapter
import com.google.gson.TypeAdapter import com.google.gson.TypeAdapter
import eu.kanade.tachiyomi.data.backup.models.DHistory import eu.kanade.tachiyomi.data.backup.legacy.models.DHistory
/** /**
* JSON Serializer used to write / read [DHistory] to / from json * JSON Serializer used to write / read [DHistory] to / from json

View File

@ -1,4 +1,4 @@
package eu.kanade.tachiyomi.data.backup.serializer package eu.kanade.tachiyomi.data.backup.legacy.serializer
import com.github.salomonbrys.kotson.typeAdapter import com.github.salomonbrys.kotson.typeAdapter
import com.google.gson.TypeAdapter import com.google.gson.TypeAdapter

View File

@ -1,4 +1,4 @@
package eu.kanade.tachiyomi.data.backup.serializer package eu.kanade.tachiyomi.data.backup.legacy.serializer
import com.github.salomonbrys.kotson.typeAdapter import com.github.salomonbrys.kotson.typeAdapter
import com.google.gson.TypeAdapter import com.google.gson.TypeAdapter

View File

@ -1,4 +1,4 @@
package eu.kanade.tachiyomi.data.backup.serializer package eu.kanade.tachiyomi.data.backup.legacy.serializer
import com.github.salomonbrys.kotson.typeAdapter import com.github.salomonbrys.kotson.typeAdapter
import com.google.gson.TypeAdapter import com.google.gson.TypeAdapter

View File

@ -0,0 +1,7 @@
package eu.kanade.tachiyomi.data.backup.models
import android.net.Uri
abstract class AbstractBackupManager {
abstract fun createBackup(uri: Uri, flags: Int, isJob: Boolean): String?
}

View File

@ -0,0 +1,70 @@
package eu.kanade.tachiyomi.data.backup.models
import android.content.Context
import android.net.Uri
import eu.kanade.tachiyomi.data.backup.BackupNotifier
import eu.kanade.tachiyomi.data.database.DatabaseHelper
import eu.kanade.tachiyomi.data.track.TrackManager
import exh.eh.EHentaiThrottleManager
import kotlinx.coroutines.Job
import uy.kohesive.injekt.injectLazy
import java.io.File
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
abstract class AbstractBackupRestore(protected val context: Context, protected val notifier: BackupNotifier) {
protected val db: DatabaseHelper by injectLazy()
protected val trackManager: TrackManager by injectLazy()
var job: Job? = null
// SY -->
protected val throttleManager = EHentaiThrottleManager()
// SY <--
/**
* The progress of a backup restore
*/
protected var restoreProgress = 0
/**
* Amount of manga in Json file (needed for restore)
*/
protected var restoreAmount = 0
/**
* Mapping of source ID to source name from backup data
*/
protected var sourceMapping: Map<Long, String> = emptyMap()
/**
* List containing errors
*/
protected val errors = mutableListOf<Pair<Date, String>>()
abstract fun restoreBackup(uri: Uri): Boolean
/**
* Write errors to error log
*/
fun writeErrorLog(): File {
try {
if (errors.isNotEmpty()) {
val destFile = File(context.externalCacheDir, "tachiyomi_restore.txt")
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("")
}
}

View File

@ -0,0 +1,16 @@
package eu.kanade.tachiyomi.data.backup.models
import android.content.Context
import android.net.Uri
import eu.kanade.tachiyomi.data.track.TrackManager
import eu.kanade.tachiyomi.source.SourceManager
import uy.kohesive.injekt.injectLazy
abstract class AbstractBackupRestoreValidator {
protected val sourceManager: SourceManager by injectLazy()
protected val trackManager: TrackManager by injectLazy()
abstract fun validate(context: Context, uri: Uri): Results
data class Results(val missingSources: List<String>, val missingTrackers: List<String>)
}

View File

@ -336,4 +336,6 @@ object PreferenceKeys {
const val biometricTimeRanges = "biometric_time_ranges" const val biometricTimeRanges = "biometric_time_ranges"
const val sortTagsForLibrary = "sort_tags_for_library" const val sortTagsForLibrary = "sort_tags_for_library"
const val createLegacyBackup = "create_legacy_backup"
} }

View File

@ -456,4 +456,6 @@ class PreferencesHelper(val context: Context) {
fun biometricTimeRanges() = flowPrefs.getStringSet(Keys.biometricTimeRanges, mutableSetOf()) fun biometricTimeRanges() = flowPrefs.getStringSet(Keys.biometricTimeRanges, mutableSetOf())
fun sortTagsForLibrary() = flowPrefs.getStringSet(Keys.sortTagsForLibrary, mutableSetOf()) fun sortTagsForLibrary() = flowPrefs.getStringSet(Keys.sortTagsForLibrary, mutableSetOf())
fun createLegacyBackup() = flowPrefs.getBoolean(Keys.createLegacyBackup, false)
} }

View File

@ -4,6 +4,7 @@ import android.Manifest.permission.WRITE_EXTERNAL_STORAGE
import android.app.Activity import android.app.Activity
import android.app.Dialog import android.app.Dialog
import android.content.ActivityNotFoundException import android.content.ActivityNotFoundException
import android.content.Context
import android.content.Intent import android.content.Intent
import android.net.Uri import android.net.Uri
import android.os.Bundle import android.os.Bundle
@ -12,13 +13,17 @@ import androidx.core.net.toUri
import androidx.preference.PreferenceScreen import androidx.preference.PreferenceScreen
import com.afollestad.materialdialogs.MaterialDialog import com.afollestad.materialdialogs.MaterialDialog
import com.afollestad.materialdialogs.list.listItemsMultiChoice import com.afollestad.materialdialogs.list.listItemsMultiChoice
import com.afollestad.materialdialogs.list.listItemsSingleChoice
import com.hippo.unifile.UniFile import com.hippo.unifile.UniFile
import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.backup.BackupConst
import eu.kanade.tachiyomi.data.backup.BackupCreateService import eu.kanade.tachiyomi.data.backup.BackupCreateService
import eu.kanade.tachiyomi.data.backup.BackupCreatorJob import eu.kanade.tachiyomi.data.backup.BackupCreatorJob
import eu.kanade.tachiyomi.data.backup.BackupRestoreService import eu.kanade.tachiyomi.data.backup.BackupRestoreService
import eu.kanade.tachiyomi.data.backup.BackupRestoreValidator import eu.kanade.tachiyomi.data.backup.full.FullBackupRestoreValidator
import eu.kanade.tachiyomi.data.backup.models.Backup import eu.kanade.tachiyomi.data.backup.full.models.BackupFull
import eu.kanade.tachiyomi.data.backup.legacy.LegacyBackupRestoreValidator
import eu.kanade.tachiyomi.data.backup.legacy.models.Backup
import eu.kanade.tachiyomi.data.preference.asImmediateFlow import eu.kanade.tachiyomi.data.preference.asImmediateFlow
import eu.kanade.tachiyomi.ui.base.controller.DialogController import eu.kanade.tachiyomi.ui.base.controller.DialogController
import eu.kanade.tachiyomi.ui.base.controller.requestPermissionsSafe import eu.kanade.tachiyomi.ui.base.controller.requestPermissionsSafe
@ -30,6 +35,7 @@ import eu.kanade.tachiyomi.util.preference.onClick
import eu.kanade.tachiyomi.util.preference.preference import eu.kanade.tachiyomi.util.preference.preference
import eu.kanade.tachiyomi.util.preference.preferenceCategory import eu.kanade.tachiyomi.util.preference.preferenceCategory
import eu.kanade.tachiyomi.util.preference.summaryRes import eu.kanade.tachiyomi.util.preference.summaryRes
import eu.kanade.tachiyomi.util.preference.switchPreference
import eu.kanade.tachiyomi.util.preference.titleRes import eu.kanade.tachiyomi.util.preference.titleRes
import eu.kanade.tachiyomi.util.system.getFilePicker import eu.kanade.tachiyomi.util.system.getFilePicker
import eu.kanade.tachiyomi.util.system.toast import eu.kanade.tachiyomi.util.system.toast
@ -52,36 +58,47 @@ class SettingsBackupController : SettingsController() {
override fun setupPreferenceScreen(screen: PreferenceScreen) = screen.apply { override fun setupPreferenceScreen(screen: PreferenceScreen) = screen.apply {
titleRes = R.string.backup titleRes = R.string.backup
preferenceCategory {
titleRes = R.string.backup
preference { preference {
key = "pref_create_backup" key = "pref_create_full_backup"
titleRes = R.string.pref_create_full_backup
summaryRes = R.string.pref_create_full_backup_summary
onClick {
backupClick(context, BackupConst.BACKUP_TYPE_FULL)
}
}
preference {
key = "pref_restore_full_backup"
titleRes = R.string.pref_restore_full_backup
summaryRes = R.string.pref_restore_full_backup_summary
onClick {
restoreClick(context, CODE_FULL_BACKUP_RESTORE)
}
}
}
preferenceCategory {
titleRes = R.string.legacy_backup
preference {
key = "pref_create_legacy_backup"
titleRes = R.string.pref_create_backup titleRes = R.string.pref_create_backup
summaryRes = R.string.pref_create_backup_summ summaryRes = R.string.pref_create_backup_summ
onClick { onClick {
if (!BackupCreateService.isRunning(context)) { backupClick(context, BackupConst.BACKUP_TYPE_LEGACY)
val ctrl = CreateBackupDialog()
ctrl.targetController = this@SettingsBackupController
ctrl.showDialog(router)
} else {
context.toast(R.string.backup_in_progress)
}
} }
} }
preference { preference {
key = "pref_restore_backup" key = "pref_restore_legacy_backup"
titleRes = R.string.pref_restore_backup titleRes = R.string.pref_restore_backup
summaryRes = R.string.pref_restore_backup_summ summaryRes = R.string.pref_restore_backup_summ
onClick { onClick {
if (!BackupRestoreService.isRunning(context)) { restoreClick(context, CODE_LEGACY_BACKUP_RESTORE)
val intent = Intent(Intent.ACTION_GET_CONTENT)
intent.addCategory(Intent.CATEGORY_OPENABLE)
intent.type = "application/*"
val title = resources?.getString(R.string.file_select_backup)
val chooser = Intent.createChooser(intent, title)
startActivityForResult(chooser, CODE_BACKUP_RESTORE)
} else {
context.toast(R.string.restore_in_progress)
} }
} }
} }
@ -142,6 +159,15 @@ class SettingsBackupController : SettingsController() {
defaultValue = "1" defaultValue = "1"
summary = "%s" summary = "%s"
preferences.backupInterval().asImmediateFlow { isVisible = it > 0 }
.launchIn(scope)
}
switchPreference {
key = Keys.createLegacyBackup
titleRes = R.string.pref_backup_auto_create_legacy
summaryRes = R.string.pref_backup_auto_create_legacy_summary
defaultValue = false
preferences.backupInterval().asImmediateFlow { isVisible = it > 0 } preferences.backupInterval().asImmediateFlow { isVisible = it > 0 }
.launchIn(scope) .launchIn(scope)
} }
@ -166,7 +192,7 @@ class SettingsBackupController : SettingsController() {
// Set backup Uri // Set backup Uri
preferences.backupsDirectory().set(uri.toString()) preferences.backupsDirectory().set(uri.toString())
} }
CODE_BACKUP_CREATE -> if (data != null && resultCode == Activity.RESULT_OK) { CODE_LEGACY_BACKUP_CREATE -> if (data != null && resultCode == Activity.RESULT_OK) {
val activity = activity ?: return val activity = activity ?: return
val uri = data.data val uri = data.data
@ -181,39 +207,111 @@ class SettingsBackupController : SettingsController() {
activity.toast(R.string.creating_backup) activity.toast(R.string.creating_backup)
BackupCreateService.start(activity, file.uri, backupFlags) BackupCreateService.start(activity, file.uri, backupFlags, BackupConst.BACKUP_TYPE_LEGACY)
} }
CODE_BACKUP_RESTORE -> if (data != null && resultCode == Activity.RESULT_OK) { CODE_LEGACY_BACKUP_RESTORE -> if (data != null && resultCode == Activity.RESULT_OK) {
val uri = data.data val uri = data.data
if (uri != null) { if (uri != null) {
RestoreBackupDialog(uri).showDialog(router) RestoreBackupDialog(uri, BackupConst.BACKUP_TYPE_LEGACY, isOnline = true).showDialog(router)
}
}
CODE_FULL_BACKUP_CREATE -> if (data != null && resultCode == Activity.RESULT_OK) {
val activity = activity ?: return
val uri = data.data
val flags = Intent.FLAG_GRANT_READ_URI_PERMISSION or
Intent.FLAG_GRANT_WRITE_URI_PERMISSION
if (uri != null) {
activity.contentResolver.takePersistableUriPermission(uri, flags)
}
val file = UniFile.fromUri(activity, uri)
activity.toast(R.string.creating_backup)
BackupCreateService.start(activity, file.uri, backupFlags, BackupConst.BACKUP_TYPE_FULL)
}
CODE_FULL_BACKUP_RESTORE -> if (data != null && resultCode == Activity.RESULT_OK) {
val uri = data.data
if (uri != null) {
val options = arrayOf(
R.string.full_restore_online,
R.string.full_restore_offline
)
.map { activity!!.getString(it) }
MaterialDialog(activity!!)
.title(R.string.full_restore_mode)
.listItemsSingleChoice(
items = options,
initialSelection = 0
) { _, index, _ ->
RestoreBackupDialog(
uri,
BackupConst.BACKUP_TYPE_FULL,
isOnline = index == 0
).showDialog(router)
}
.positiveButton(R.string.action_restore)
.show()
} }
} }
} }
} }
fun createBackup(flags: Int) { private fun backupClick(context: Context, type: Int) {
if (!BackupCreateService.isRunning(context)) {
val ctrl = CreateBackupDialog(type)
ctrl.targetController = this@SettingsBackupController
ctrl.showDialog(router)
} else {
context.toast(R.string.backup_in_progress)
}
}
private fun restoreClick(context: Context, type: Int) {
if (!BackupRestoreService.isRunning(context)) {
val intent = Intent(Intent.ACTION_GET_CONTENT)
intent.addCategory(Intent.CATEGORY_OPENABLE)
intent.type = "application/*"
val title = resources?.getString(R.string.file_select_backup)
val chooser = Intent.createChooser(intent, title)
startActivityForResult(chooser, type)
} else {
context.toast(R.string.restore_in_progress)
}
}
fun createBackup(flags: Int, type: Int) {
backupFlags = flags backupFlags = flags
// Get dirs // Get dirs
val currentDir = preferences.backupsDirectory().get() val currentDir = preferences.backupsDirectory().get()
try { try {
val fileName = if (type == BackupConst.BACKUP_TYPE_FULL) BackupFull.getDefaultFilename() else Backup.getDefaultFilename()
// Use Android's built-in file creator // Use Android's built-in file creator
val intent = Intent(Intent.ACTION_CREATE_DOCUMENT) val intent = Intent(Intent.ACTION_CREATE_DOCUMENT)
.addCategory(Intent.CATEGORY_OPENABLE) .addCategory(Intent.CATEGORY_OPENABLE)
.setType("application/*") .setType("application/*")
.putExtra(Intent.EXTRA_TITLE, Backup.getDefaultFilename()) .putExtra(Intent.EXTRA_TITLE, fileName)
startActivityForResult(intent, CODE_BACKUP_CREATE) startActivityForResult(intent, if (type == BackupConst.BACKUP_TYPE_FULL) CODE_FULL_BACKUP_CREATE else CODE_LEGACY_BACKUP_CREATE)
} catch (e: ActivityNotFoundException) { } catch (e: ActivityNotFoundException) {
// Handle errors where the android ROM doesn't support the built in picker // Handle errors where the android ROM doesn't support the built in picker
startActivityForResult(preferences.context.getFilePicker(currentDir), CODE_BACKUP_CREATE) startActivityForResult(preferences.context.getFilePicker(currentDir), if (type == BackupConst.BACKUP_TYPE_FULL) CODE_FULL_BACKUP_CREATE else CODE_LEGACY_BACKUP_CREATE)
} }
} }
class CreateBackupDialog : DialogController() { class CreateBackupDialog(bundle: Bundle? = null) : DialogController(bundle) {
constructor(type: Int) : this(
Bundle().apply {
putInt(KEY_TYPE, type)
}
)
override fun onCreateDialog(savedViewState: Bundle?): Dialog { override fun onCreateDialog(savedViewState: Bundle?): Dialog {
val type = args.getInt(KEY_TYPE)
val activity = activity!! val activity = activity!!
val options = arrayOf( val options = arrayOf(
R.string.manga, R.string.manga,
@ -225,7 +323,7 @@ class SettingsBackupController : SettingsController() {
.map { activity.getString(it) } .map { activity.getString(it) }
return MaterialDialog(activity) return MaterialDialog(activity)
.title(R.string.pref_create_backup) .title(R.string.create_backup)
.message(R.string.backup_choice) .message(R.string.backup_choice)
.listItemsMultiChoice( .listItemsMultiChoice(
items = options, items = options,
@ -242,28 +340,38 @@ class SettingsBackupController : SettingsController() {
} }
} }
(targetController as? SettingsBackupController)?.createBackup(flags) (targetController as? SettingsBackupController)?.createBackup(flags, type)
} }
.positiveButton(R.string.action_create) .positiveButton(R.string.action_create)
.negativeButton(android.R.string.cancel) .negativeButton(android.R.string.cancel)
} }
private companion object {
const val KEY_TYPE = "CreateBackupDialog.type"
}
} }
class RestoreBackupDialog(bundle: Bundle? = null) : DialogController(bundle) { class RestoreBackupDialog(bundle: Bundle? = null) : DialogController(bundle) {
constructor(uri: Uri) : this( constructor(uri: Uri, type: Int, isOnline: Boolean) : this(
Bundle().apply { Bundle().apply {
putParcelable(KEY_URI, uri) putParcelable(KEY_URI, uri)
putInt(KEY_TYPE, type)
putBoolean(KEY_MODE, isOnline)
} }
) )
override fun onCreateDialog(savedViewState: Bundle?): Dialog { override fun onCreateDialog(savedViewState: Bundle?): Dialog {
val activity = activity!! val activity = activity!!
val uri: Uri = args.getParcelable(KEY_URI)!! val uri: Uri = args.getParcelable(KEY_URI)!!
val type: Int = args.getInt(KEY_TYPE)
val isOnline: Boolean = args.getBoolean(KEY_MODE, true)
return try { return try {
var message = activity.getString(R.string.backup_restore_content) var message = activity.getString(R.string.backup_restore_content)
val results = BackupRestoreValidator.validate(activity, uri) val validator = if (type == BackupConst.BACKUP_TYPE_FULL) FullBackupRestoreValidator() else LegacyBackupRestoreValidator()
val results = validator.validate(activity, uri)
if (results.missingSources.isNotEmpty()) { if (results.missingSources.isNotEmpty()) {
message += "\n\n${activity.getString(R.string.backup_restore_missing_sources)}\n${results.missingSources.joinToString("\n") { "- $it" }}" message += "\n\n${activity.getString(R.string.backup_restore_missing_sources)}\n${results.missingSources.joinToString("\n") { "- $it" }}"
} }
@ -272,10 +380,10 @@ class SettingsBackupController : SettingsController() {
} }
MaterialDialog(activity) MaterialDialog(activity)
.title(R.string.pref_restore_backup) .title(R.string.restore_backup)
.message(text = message) .message(text = message)
.positiveButton(R.string.action_restore) { .positiveButton(R.string.action_restore) {
BackupRestoreService.start(activity, uri) BackupRestoreService.start(activity, uri, type, isOnline)
} }
} catch (e: Exception) { } catch (e: Exception) {
MaterialDialog(activity) MaterialDialog(activity)
@ -287,12 +395,16 @@ class SettingsBackupController : SettingsController() {
private companion object { private companion object {
const val KEY_URI = "RestoreBackupDialog.uri" const val KEY_URI = "RestoreBackupDialog.uri"
const val KEY_TYPE = "RestoreBackupDialog.type"
const val KEY_MODE = "RestoreBackupDialog.mode"
} }
} }
private companion object { private companion object {
const val CODE_BACKUP_CREATE = 501 const val CODE_LEGACY_BACKUP_CREATE = 501
const val CODE_BACKUP_RESTORE = 502 const val CODE_LEGACY_BACKUP_RESTORE = 502
const val CODE_BACKUP_DIR = 503 const val CODE_BACKUP_DIR = 503
const val CODE_FULL_BACKUP_CREATE = 504
const val CODE_FULL_BACKUP_RESTORE = 505
} }
} }

View File

@ -5,7 +5,7 @@ import com.elvishew.xlog.XLog
import com.pushtorefresh.storio.sqlite.queries.Query import com.pushtorefresh.storio.sqlite.queries.Query
import com.pushtorefresh.storio.sqlite.queries.RawQuery import com.pushtorefresh.storio.sqlite.queries.RawQuery
import eu.kanade.tachiyomi.BuildConfig import eu.kanade.tachiyomi.BuildConfig
import eu.kanade.tachiyomi.data.backup.models.DHistory import eu.kanade.tachiyomi.data.backup.legacy.models.DHistory
import eu.kanade.tachiyomi.data.database.DatabaseHelper import eu.kanade.tachiyomi.data.database.DatabaseHelper
import eu.kanade.tachiyomi.data.database.models.Chapter import eu.kanade.tachiyomi.data.database.models.Chapter
import eu.kanade.tachiyomi.data.database.models.Manga import eu.kanade.tachiyomi.data.database.models.Manga

View File

@ -348,14 +348,26 @@
<!-- Backup section --> <!-- Backup section -->
<string name="backup">Backup</string> <string name="backup">Backup</string>
<string name="pref_create_backup">Create backup</string> <string name="legacy_backup">Legacy Backup</string>
<string name="pref_create_backup_summ">Can be used to restore current library</string> <string name="pref_create_full_backup">Create full backup</string>
<string name="pref_restore_backup">Restore backup</string> <string name="pref_create_full_backup_summary">Can be used to restore current library</string>
<string name="pref_restore_backup_summ">Restore library from backup file</string> <string name="pref_restore_full_backup">Restore full backup</string>
<string name="pref_restore_full_backup_summary">Restore library from backup file, only use this if your backup is a full type backup, this can be restored offline as well as online</string>
<string name="pref_create_backup">Create legacy backup</string>
<string name="pref_create_backup_summ">Can be used to restore current library in older versions of Tachiyomi</string>
<string name="pref_restore_backup">Restore legacy backup</string>
<string name="pref_restore_backup_summ">Restore library from a legacy backup file</string>
<string name="pref_backup_auto_create_legacy">Create legacy backup</string>
<string name="pref_backup_auto_create_legacy_summary">Creates a legacy backup alongside the full backup</string>
<string name="pref_backup_directory">Backup location</string> <string name="pref_backup_directory">Backup location</string>
<string name="pref_backup_service_category">Automatic backups</string> <string name="pref_backup_service_category">Automatic backups</string>
<string name="pref_backup_interval">Backup frequency</string> <string name="pref_backup_interval">Backup frequency</string>
<string name="pref_backup_slots">Maximum backups</string> <string name="pref_backup_slots">Maximum backups</string>
<string name="full_restore_mode">Network Mode</string>
<string name="full_restore_online">Restore online, slower but gives you more updated info and chapters</string>
<string name="full_restore_offline">Restore offline, finishes quickly but contains only what you backup has</string>
<string name="create_backup">Create backup</string>
<string name="restore_backup">Restore backup</string>
<string name="source_not_found_name">Source not found: %1$s</string> <string name="source_not_found_name">Source not found: %1$s</string>
<string name="tracker_not_logged_in">Not logged in: %1$s</string> <string name="tracker_not_logged_in">Not logged in: %1$s</string>
<string name="backup_created">Backup created</string> <string name="backup_created">Backup created</string>

View File

@ -544,8 +544,8 @@
<!-- MangaDex --> <!-- MangaDex -->
<string name="md_follows_unfollowed">Unfollowed</string> <string name="md_follows_unfollowed">Unfollowed</string>
<string name="mangadex_specific_settings">MangaDex settings</string> <string name="mangadex_specific_settings">MangaDex settings</string>
<string name="mangadex_sync_follows_to_library">Sync Mangadex manga into your library</string> <string name="mangadex_sync_follows_to_library">Sync Mangadex manga into Neko</string>
<string name="mangadex_sync_follows_to_library_summary">Pulls reading/rereading manga from Mangadex into your library</string> <string name="mangadex_sync_follows_to_library_summary">Pulls reading/rereading manga from Mangadex into your Neko library</string>
<string name="mangadex_low_quality_covers">Use low quality thumbnails</string> <string name="mangadex_low_quality_covers">Use low quality thumbnails</string>
<string name="mangadex_use_latest_cover">Use latest uploaded cover</string> <string name="mangadex_use_latest_cover">Use latest uploaded cover</string>
<string name="mangadex_use_latest_cover_summary">When enabled, it uses the latest uploaded manga cover under the /covers url instead of using the cover on MangaDex\'s manga page</string> <string name="mangadex_use_latest_cover_summary">When enabled, it uses the latest uploaded manga cover under the /covers url instead of using the cover on MangaDex\'s manga page</string>

View File

@ -8,8 +8,9 @@ import com.google.gson.JsonArray
import com.google.gson.JsonObject import com.google.gson.JsonObject
import eu.kanade.tachiyomi.BuildConfig import eu.kanade.tachiyomi.BuildConfig
import eu.kanade.tachiyomi.CustomRobolectricGradleTestRunner import eu.kanade.tachiyomi.CustomRobolectricGradleTestRunner
import eu.kanade.tachiyomi.data.backup.models.Backup import eu.kanade.tachiyomi.data.backup.legacy.LegacyBackupManager
import eu.kanade.tachiyomi.data.backup.models.DHistory import eu.kanade.tachiyomi.data.backup.legacy.models.Backup
import eu.kanade.tachiyomi.data.backup.legacy.models.DHistory
import eu.kanade.tachiyomi.data.database.DatabaseHelper import eu.kanade.tachiyomi.data.database.DatabaseHelper
import eu.kanade.tachiyomi.data.database.models.Category import eu.kanade.tachiyomi.data.database.models.Category
import eu.kanade.tachiyomi.data.database.models.Chapter import eu.kanade.tachiyomi.data.database.models.Chapter
@ -37,7 +38,7 @@ import uy.kohesive.injekt.api.InjektRegistrar
import uy.kohesive.injekt.api.addSingleton import uy.kohesive.injekt.api.addSingleton
/** /**
* Test class for the [BackupManager]. * Test class for the [LegacyBackupManager].
* Note that this does not include the backup create/restore services. * Note that this does not include the backup create/restore services.
*/ */
@Config(constants = BuildConfig::class, sdk = [Build.VERSION_CODES.LOLLIPOP]) @Config(constants = BuildConfig::class, sdk = [Build.VERSION_CODES.LOLLIPOP])
@ -59,7 +60,7 @@ class BackupTest {
lateinit var context: Context lateinit var context: Context
lateinit var source: HttpSource lateinit var source: HttpSource
lateinit var backupManager: BackupManager lateinit var legacyBackupManager: LegacyBackupManager
lateinit var db: DatabaseHelper lateinit var db: DatabaseHelper
@ -67,8 +68,8 @@ class BackupTest {
fun setup() { fun setup() {
app = RuntimeEnvironment.application app = RuntimeEnvironment.application
context = app.applicationContext context = app.applicationContext
backupManager = BackupManager(context) legacyBackupManager = LegacyBackupManager(context)
db = backupManager.databaseHelper db = legacyBackupManager.databaseHelper
// Mock the source manager // Mock the source manager
val module = object : InjektModule { val module = object : InjektModule {
@ -79,7 +80,7 @@ class BackupTest {
Injekt.importModule(module) Injekt.importModule(module)
source = mock(HttpSource::class.java) source = mock(HttpSource::class.java)
`when`(backupManager.sourceManager.get(anyLong())).thenReturn(source) `when`(legacyBackupManager.sourceManager.get(anyLong())).thenReturn(source)
root.add(Backup.MANGAS, mangaEntries) root.add(Backup.MANGAS, mangaEntries)
root.add(Backup.CATEGORIES, categoryEntries) root.add(Backup.CATEGORIES, categoryEntries)
@ -94,10 +95,10 @@ class BackupTest {
initializeJsonTest(2) initializeJsonTest(2)
// Create backup of empty database // Create backup of empty database
backupManager.backupCategories(categoryEntries) legacyBackupManager.backupCategories(categoryEntries)
// Restore Json // Restore Json
backupManager.restoreCategories(categoryEntries) legacyBackupManager.restoreCategories(categoryEntries)
// Check if empty // Check if empty
val dbCats = db.getCategories().executeAsBlocking() val dbCats = db.getCategories().executeAsBlocking()
@ -116,10 +117,10 @@ class BackupTest {
val category = addSingleCategory("category") val category = addSingleCategory("category")
// Restore Json // Restore Json
backupManager.restoreCategories(categoryEntries) legacyBackupManager.restoreCategories(categoryEntries)
// Check if successful // Check if successful
val dbCats = backupManager.databaseHelper.getCategories().executeAsBlocking() val dbCats = legacyBackupManager.databaseHelper.getCategories().executeAsBlocking()
assertThat(dbCats).hasSize(1) assertThat(dbCats).hasSize(1)
assertThat(dbCats[0].name).isEqualTo(category.name) assertThat(dbCats[0].name).isEqualTo(category.name)
} }
@ -143,10 +144,10 @@ class BackupTest {
db.insertCategory(category).executeAsBlocking() db.insertCategory(category).executeAsBlocking()
// Restore Json // Restore Json
backupManager.restoreCategories(categoryEntries) legacyBackupManager.restoreCategories(categoryEntries)
// Check if successful // Check if successful
val dbCats = backupManager.databaseHelper.getCategories().executeAsBlocking() val dbCats = legacyBackupManager.databaseHelper.getCategories().executeAsBlocking()
assertThat(dbCats).hasSize(5) assertThat(dbCats).hasSize(5)
assertThat(dbCats[0].name).isEqualTo(category.name) assertThat(dbCats[0].name).isEqualTo(category.name)
assertThat(dbCats[1].name).isEqualTo(category2.name) assertThat(dbCats[1].name).isEqualTo(category2.name)
@ -168,27 +169,27 @@ class BackupTest {
manga.viewer = 3 manga.viewer = 3
manga.id = db.insertManga(manga).executeAsBlocking().insertedId() manga.id = db.insertManga(manga).executeAsBlocking().insertedId()
var favoriteManga = backupManager.databaseHelper.getFavoriteMangas().executeAsBlocking() var favoriteManga = legacyBackupManager.databaseHelper.getFavoriteMangas().executeAsBlocking()
assertThat(favoriteManga).hasSize(1) assertThat(favoriteManga).hasSize(1)
assertThat(favoriteManga[0].viewer).isEqualTo(3) assertThat(favoriteManga[0].viewer).isEqualTo(3)
// Update json with all options enabled // Update json with all options enabled
mangaEntries.add(backupManager.backupMangaObject(manga, 1)) mangaEntries.add(legacyBackupManager.backupMangaObject(manga, 1))
// Change manga in database to default values // Change manga in database to default values
val dbManga = getSingleManga("One Piece") val dbManga = getSingleManga("One Piece")
dbManga.id = manga.id dbManga.id = manga.id
db.insertManga(dbManga).executeAsBlocking() db.insertManga(dbManga).executeAsBlocking()
favoriteManga = backupManager.databaseHelper.getFavoriteMangas().executeAsBlocking() favoriteManga = legacyBackupManager.databaseHelper.getFavoriteMangas().executeAsBlocking()
assertThat(favoriteManga).hasSize(1) assertThat(favoriteManga).hasSize(1)
assertThat(favoriteManga[0].viewer).isEqualTo(0) assertThat(favoriteManga[0].viewer).isEqualTo(0)
// Restore local manga // Restore local manga
backupManager.restoreMangaNoFetch(manga, dbManga) legacyBackupManager.restoreMangaNoFetch(manga, dbManga)
// Test if restore successful // Test if restore successful
favoriteManga = backupManager.databaseHelper.getFavoriteMangas().executeAsBlocking() favoriteManga = legacyBackupManager.databaseHelper.getFavoriteMangas().executeAsBlocking()
assertThat(favoriteManga).hasSize(1) assertThat(favoriteManga).hasSize(1)
assertThat(favoriteManga[0].viewer).isEqualTo(3) assertThat(favoriteManga[0].viewer).isEqualTo(3)
@ -196,28 +197,28 @@ class BackupTest {
clearDatabase() clearDatabase()
// Test if successful // Test if successful
favoriteManga = backupManager.databaseHelper.getFavoriteMangas().executeAsBlocking() favoriteManga = legacyBackupManager.databaseHelper.getFavoriteMangas().executeAsBlocking()
assertThat(favoriteManga).hasSize(0) assertThat(favoriteManga).hasSize(0)
// Restore Json // Restore Json
// Create JSON from manga to test parser // Create JSON from manga to test parser
val json = backupManager.parser.toJsonTree(manga) val json = legacyBackupManager.parser.toJsonTree(manga)
// Restore JSON from manga to test parser // Restore JSON from manga to test parser
val jsonManga = backupManager.parser.fromJson<MangaImpl>(json) val jsonManga = legacyBackupManager.parser.fromJson<MangaImpl>(json)
// Restore manga with fetch observable // Restore manga with fetch observable
val networkManga = getSingleManga("One Piece") val networkManga = getSingleManga("One Piece")
networkManga.description = "This is a description" networkManga.description = "This is a description"
`when`(source.fetchMangaDetails(jsonManga)).thenReturn(Observable.just(networkManga)) `when`(source.fetchMangaDetails(jsonManga)).thenReturn(Observable.just(networkManga))
val obs = backupManager.restoreMangaFetchObservable(source, jsonManga) val obs = legacyBackupManager.restoreMangaFetchObservable(source, jsonManga)
val testSubscriber = TestSubscriber<Manga>() val testSubscriber = TestSubscriber<Manga>()
obs.subscribe(testSubscriber) obs.subscribe(testSubscriber)
testSubscriber.assertNoErrors() testSubscriber.assertNoErrors()
// Check if restore successful // Check if restore successful
val dbCats = backupManager.databaseHelper.getFavoriteMangas().executeAsBlocking() val dbCats = legacyBackupManager.databaseHelper.getFavoriteMangas().executeAsBlocking()
assertThat(dbCats).hasSize(1) assertThat(dbCats).hasSize(1)
assertThat(dbCats[0].viewer).isEqualTo(3) assertThat(dbCats[0].viewer).isEqualTo(3)
assertThat(dbCats[0].description).isEqualTo("This is a description") assertThat(dbCats[0].description).isEqualTo("This is a description")
@ -233,7 +234,7 @@ class BackupTest {
// Insert manga // Insert manga
val manga = getSingleManga("One Piece") val manga = getSingleManga("One Piece")
manga.id = backupManager.databaseHelper.insertManga(manga).executeAsBlocking().insertedId() manga.id = legacyBackupManager.databaseHelper.insertManga(manga).executeAsBlocking().insertedId()
// Create restore list // Create restore list
val chapters = mutableListOf<Chapter>() val chapters = mutableListOf<Chapter>()
@ -244,8 +245,8 @@ class BackupTest {
} }
// Check parser // Check parser
val chaptersJson = backupManager.parser.toJsonTree(chapters) val chaptersJson = legacyBackupManager.parser.toJsonTree(chapters)
val restoredChapters = backupManager.parser.fromJson<List<ChapterImpl>>(chaptersJson) val restoredChapters = legacyBackupManager.parser.fromJson<List<ChapterImpl>>(chaptersJson)
// Fetch chapters from upstream // Fetch chapters from upstream
// Create list // Create list
@ -254,13 +255,13 @@ class BackupTest {
`when`(source.fetchChapterList(manga)).thenReturn(Observable.just(chaptersRemote)) `when`(source.fetchChapterList(manga)).thenReturn(Observable.just(chaptersRemote))
// Call restoreChapterFetchObservable // Call restoreChapterFetchObservable
val obs = backupManager.restoreChapterFetchObservable(source, manga, restoredChapters) val obs = legacyBackupManager.restoreChapterFetchObservable(source, manga, restoredChapters)
val testSubscriber = TestSubscriber<Pair<List<Chapter>, List<Chapter>>>() val testSubscriber = TestSubscriber<Pair<List<Chapter>, List<Chapter>>>()
obs.subscribe(testSubscriber) obs.subscribe(testSubscriber)
testSubscriber.assertNoErrors() testSubscriber.assertNoErrors()
val dbCats = backupManager.databaseHelper.getChapters(manga).executeAsBlocking() val dbCats = legacyBackupManager.databaseHelper.getChapters(manga).executeAsBlocking()
assertThat(dbCats).hasSize(10) assertThat(dbCats).hasSize(10)
assertThat(dbCats[0].read).isEqualTo(true) assertThat(dbCats[0].read).isEqualTo(true)
} }
@ -274,13 +275,13 @@ class BackupTest {
initializeJsonTest(2) initializeJsonTest(2)
val manga = getSingleManga("One Piece") val manga = getSingleManga("One Piece")
manga.id = backupManager.databaseHelper.insertManga(manga).executeAsBlocking().insertedId() manga.id = legacyBackupManager.databaseHelper.insertManga(manga).executeAsBlocking().insertedId()
// Create chapter // Create chapter
val chapter = getSingleChapter("Chapter 1") val chapter = getSingleChapter("Chapter 1")
chapter.manga_id = manga.id chapter.manga_id = manga.id
chapter.read = true chapter.read = true
chapter.id = backupManager.databaseHelper.insertChapter(chapter).executeAsBlocking().insertedId() chapter.id = legacyBackupManager.databaseHelper.insertChapter(chapter).executeAsBlocking().insertedId()
val historyJson = getSingleHistory(chapter) val historyJson = getSingleHistory(chapter)
@ -288,13 +289,13 @@ class BackupTest {
historyList.add(historyJson) historyList.add(historyJson)
// Check parser // Check parser
val historyListJson = backupManager.parser.toJsonTree(historyList) val historyListJson = legacyBackupManager.parser.toJsonTree(historyList)
val history = backupManager.parser.fromJson<List<DHistory>>(historyListJson) val history = legacyBackupManager.parser.fromJson<List<DHistory>>(historyListJson)
// Restore categories // Restore categories
backupManager.restoreHistoryForManga(history) legacyBackupManager.restoreHistoryForManga(history)
val historyDB = backupManager.databaseHelper.getHistoryByMangaId(manga.id!!).executeAsBlocking() val historyDB = legacyBackupManager.databaseHelper.getHistoryByMangaId(manga.id!!).executeAsBlocking()
assertThat(historyDB).hasSize(1) assertThat(historyDB).hasSize(1)
assertThat(historyDB[0].last_read).isEqualTo(1000) assertThat(historyDB[0].last_read).isEqualTo(1000)
} }
@ -310,15 +311,15 @@ class BackupTest {
// Create mangas // Create mangas
val manga = getSingleManga("One Piece") val manga = getSingleManga("One Piece")
val manga2 = getSingleManga("Bleach") val manga2 = getSingleManga("Bleach")
manga.id = backupManager.databaseHelper.insertManga(manga).executeAsBlocking().insertedId() manga.id = legacyBackupManager.databaseHelper.insertManga(manga).executeAsBlocking().insertedId()
manga2.id = backupManager.databaseHelper.insertManga(manga2).executeAsBlocking().insertedId() manga2.id = legacyBackupManager.databaseHelper.insertManga(manga2).executeAsBlocking().insertedId()
// Create track and add it to database // Create track and add it to database
// This tests duplicate errors. // This tests duplicate errors.
val track = getSingleTrack(manga) val track = getSingleTrack(manga)
track.last_chapter_read = 5 track.last_chapter_read = 5
backupManager.databaseHelper.insertTrack(track).executeAsBlocking() legacyBackupManager.databaseHelper.insertTrack(track).executeAsBlocking()
var trackDB = backupManager.databaseHelper.getTracks(manga).executeAsBlocking() var trackDB = legacyBackupManager.databaseHelper.getTracks(manga).executeAsBlocking()
assertThat(trackDB).hasSize(1) assertThat(trackDB).hasSize(1)
assertThat(trackDB[0].last_chapter_read).isEqualTo(5) assertThat(trackDB[0].last_chapter_read).isEqualTo(5)
track.last_chapter_read = 7 track.last_chapter_read = 7
@ -330,22 +331,22 @@ class BackupTest {
// Check parser and restore already in database // Check parser and restore already in database
var trackList = listOf(track) var trackList = listOf(track)
// Check parser // Check parser
var trackListJson = backupManager.parser.toJsonTree(trackList) var trackListJson = legacyBackupManager.parser.toJsonTree(trackList)
var trackListRestore = backupManager.parser.fromJson<List<TrackImpl>>(trackListJson) var trackListRestore = legacyBackupManager.parser.fromJson<List<TrackImpl>>(trackListJson)
backupManager.restoreTrackForManga(manga, trackListRestore) legacyBackupManager.restoreTrackForManga(manga, trackListRestore)
// Assert if restore works. // Assert if restore works.
trackDB = backupManager.databaseHelper.getTracks(manga).executeAsBlocking() trackDB = legacyBackupManager.databaseHelper.getTracks(manga).executeAsBlocking()
assertThat(trackDB).hasSize(1) assertThat(trackDB).hasSize(1)
assertThat(trackDB[0].last_chapter_read).isEqualTo(7) assertThat(trackDB[0].last_chapter_read).isEqualTo(7)
// Check parser and restore already in database with lower chapter_read // Check parser and restore already in database with lower chapter_read
track.last_chapter_read = 5 track.last_chapter_read = 5
trackList = listOf(track) trackList = listOf(track)
backupManager.restoreTrackForManga(manga, trackList) legacyBackupManager.restoreTrackForManga(manga, trackList)
// Assert if restore works. // Assert if restore works.
trackDB = backupManager.databaseHelper.getTracks(manga).executeAsBlocking() trackDB = legacyBackupManager.databaseHelper.getTracks(manga).executeAsBlocking()
assertThat(trackDB).hasSize(1) assertThat(trackDB).hasSize(1)
assertThat(trackDB[0].last_chapter_read).isEqualTo(7) assertThat(trackDB[0].last_chapter_read).isEqualTo(7)
@ -353,12 +354,12 @@ class BackupTest {
trackList = listOf(track2) trackList = listOf(track2)
// Check parser // Check parser
trackListJson = backupManager.parser.toJsonTree(trackList) trackListJson = legacyBackupManager.parser.toJsonTree(trackList)
trackListRestore = backupManager.parser.fromJson<List<TrackImpl>>(trackListJson) trackListRestore = legacyBackupManager.parser.fromJson<List<TrackImpl>>(trackListJson)
backupManager.restoreTrackForManga(manga2, trackListRestore) legacyBackupManager.restoreTrackForManga(manga2, trackListRestore)
// Assert if restore works. // Assert if restore works.
trackDB = backupManager.databaseHelper.getTracks(manga2).executeAsBlocking() trackDB = legacyBackupManager.databaseHelper.getTracks(manga2).executeAsBlocking()
assertThat(trackDB).hasSize(1) assertThat(trackDB).hasSize(1)
assertThat(trackDB[0].last_chapter_read).isEqualTo(10) assertThat(trackDB[0].last_chapter_read).isEqualTo(10)
} }
@ -372,12 +373,12 @@ class BackupTest {
fun initializeJsonTest(version: Int) { fun initializeJsonTest(version: Int) {
clearJson() clearJson()
backupManager.setVersion(version) legacyBackupManager.setVersion(version)
} }
fun addSingleCategory(name: String): Category { fun addSingleCategory(name: String): Category {
val category = Category.create(name) val category = Category.create(name)
val catJson = backupManager.parser.toJsonTree(category) val catJson = legacyBackupManager.parser.toJsonTree(category)
categoryEntries.add(catJson) categoryEntries.add(catJson)
return category return category
} }