Make a protobuf based backup system (#3936)

* Make a protobuf based backup system

* Cleanup

* More cleanup

* Fix restores always loading the full backup restore, even when legacy restore was used

* Make offline the default

(cherry picked from commit f6fd8a8ddb90869f3e28fd8fcd81a2125f8e0527)

* Find chapter based on the url

(cherry picked from commit 326dc2700944a60da381d82cd9782c5f0d335902)

* Dont break after finding one chapter

(cherry picked from commit f91d1af37398619cf371e4920b60f6d309799c74)

* Also apply changes to online restore

(cherry picked from commit e7c16cd0d14ea5d50ce4a9a3dfa8ca768be702f2)

* Rewrite backup categories

(cherry picked from commit f4200e2146a9c540675767206ed4664894aa1216)

* Dedupe some code, move over read and bookmarks properly

(cherry picked from commit d9ce86aca66945c831670a1523d8bc69966312df)

* Move some functions to the abstract backup manager

(cherry picked from commit b0c658741a2f506bc31823f1f0347772bc119d2e)

# Conflicts:
#	app/src/main/java/eu/kanade/tachiyomi/data/backup/full/FullBackupManager.kt
#	app/src/main/java/eu/kanade/tachiyomi/data/backup/legacy/LegacyBackupManager.kt

* Fix some backup duplication issues

(cherry picked from commit a4a1c2827c4537d2d07a0cb589dc1c3be1d65185)

# Conflicts:
#	app/src/main/java/eu/kanade/tachiyomi/data/backup/legacy/LegacyBackupManager.kt

* Fix a missed bundleOf

* So glad this wasnt merged before now, everything should be working with this commit
pull/4072/head
jobobby04 4 years ago committed by GitHub
parent a150762c63
commit 682fae12b6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -185,7 +185,9 @@ dependencies {
implementation "com.squareup.retrofit2:adapter-rxjava:$retrofit_version" implementation "com.squareup.retrofit2:adapter-rxjava:$retrofit_version"
// JSON // JSON
implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:1.0.1" final kotlin_serialization_version = '1.0.1'
implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:$kotlin_serialization_version"
implementation "org.jetbrains.kotlinx:kotlinx-serialization-protobuf:$kotlin_serialization_version"
implementation 'com.google.code.gson:gson:2.8.6' implementation 'com.google.code.gson:gson:2.8.6'
implementation 'com.github.salomonbrys.kotson:kotson:2.5.0' implementation 'com.github.salomonbrys.kotson:kotson:2.5.0'

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

@ -9,6 +9,9 @@ import android.os.PowerManager
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
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)
} }
ContextCompat.startForegroundService(context, intent) ContextCompat.startForegroundService(context, intent)
} }
@ -62,7 +66,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() {
@ -101,7 +105,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)

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

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

@ -7,45 +7,17 @@ import android.net.Uri
import android.os.IBinder import android.os.IBinder
import android.os.PowerManager import android.os.PowerManager
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
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.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 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.
@ -69,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_MODE, mode)
online?.let { putExtra(BackupConst.EXTRA_TYPE, it) }
} }
ContextCompat.startForegroundService(context, intent) ContextCompat.startForegroundService(context, intent)
} }
@ -95,35 +69,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
/**
* 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()
@ -144,7 +92,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()
} }
@ -165,304 +113,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)
val online = intent.getBooleanExtra(BackupConst.EXTRA_TYPE, true)
// 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
restoreAmount = mangasJson.size() + 1 // +1 for categories
restoreProgress = 0
errors.clear()
// Restore categories
json.get(CATEGORIES)?.let { restoreCategories(it) }
// 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))
}
private fun restoreManga(mangaJson: JsonObject) {
val 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()
)
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)
// 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("")
}
} }

@ -0,0 +1,442 @@
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.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.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.source.Source
import eu.kanade.tachiyomi.util.chapter.syncChaptersWithSource
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.protobuf.ProtoBuf
import okio.buffer
import okio.gzip
import okio.sink
import rx.Observable
import timber.log.Timber
import kotlin.math.max
@OptIn(ExperimentalSerializationApi::class)
class FullBackupManager(context: Context) : AbstractBackupManager(context) {
/**
* 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)
)
}
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_full_\d+-\d+-\d+_\d+-\d+.proto.gz""")
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()
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) }
}
/**
* 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)
// 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) {
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
* @return [Observable] that contains manga
*/
fun restoreChapterFetchObservable(source: Source, manga: Manga, chapters: List<Chapter>): Observable<Pair<List<Chapter>, List<Chapter>>> {
return source.fetchChapterList(manga)
.map { syncChaptersWithSource(databaseHelper, it, manga, source) }
.doOnNext { 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>()
categories.forEach { backupCategoryOrder ->
backupCategories.firstOrNull {
it.order == backupCategoryOrder
}?.let { backupCategory ->
dbCategories.firstOrNull { dbCategory ->
dbCategory.name == backupCategory.name
}?.let { dbCategory ->
mangaCategoriesToUpdate += MangaCategory.create(manga, dbCategory)
}
}
}
// 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
}
chapters.forEach { chapter ->
val pos = dbChapters.indexOfFirst { it.url == chapter.url }
if (pos != -1) {
val dbChapter = dbChapters[pos]
chapter.id = dbChapter.id
chapter.copyFrom(dbChapter)
if (dbChapter.read && !chapter.read) {
chapter.read = dbChapter.read
chapter.last_page_read = dbChapter.last_page_read
} else if (chapter.last_page_read == 0 && dbChapter.last_page_read != 0) {
chapter.last_page_read = dbChapter.last_page_read
}
if (!chapter.bookmark && dbChapter.bookmark) {
chapter.bookmark = dbChapter.bookmark
}
}
}
// 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()
chapters.forEach { chapter ->
val pos = dbChapters.indexOfFirst { it.url == chapter.url }
if (pos != -1) {
val dbChapter = dbChapters[pos]
chapter.id = dbChapter.id
chapter.copyFrom(dbChapter)
if (dbChapter.read && !chapter.read) {
chapter.read = dbChapter.read
chapter.last_page_read = dbChapter.last_page_read
} else if (chapter.last_page_read == 0 && dbChapter.last_page_read != 0) {
chapter.last_page_read = dbChapter.last_page_read
}
if (!chapter.bookmark && dbChapter.bookmark) {
chapter.bookmark = dbChapter.bookmark
}
}
}
chapters.map { it.manga_id = manga.id }
updateChapters(chapters.filter { it.id != null })
insertChapters(chapters.filter { it.id == null })
}
}

@ -0,0 +1,283 @@
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.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.util.chapter.NoChaptersException
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 {
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 // +1 for categories
restoreProgress = 0
errors.clear()
// Restore categories
if (backup.backupCategories.isNotEmpty()) {
restoreCategories(backup.backupCategories)
}
// 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.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))
}
private fun restoreManga(backupManga: BackupManga, backupCategories: List<BackupCategory>, online: Boolean) {
val manga = backupManga.getMangaImpl()
val chapters = backupManga.getChaptersImpl()
val categories = backupManga.categories
val history = backupManga.history
val tracks = backupManga.getTrackingImpl()
try {
val source = fullBackupManager.sourceManager.get(manga.source)
if (source != null || !online) {
restoreMangaData(manga, source, chapters, categories, history, tracks, backupCategories, 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>,
online: Boolean
) {
val dbManga = fullBackupManager.getMangaFromDatabase(manga)
db.inTransaction {
if (dbManga == null) {
// Manga not in database
restoreMangaFetch(source, manga, chapters, categories, history, tracks, backupCategories, 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, 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>,
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) {
chapterFetchObservable(source, it, chapters)
// Convert to the manga that contains new chapters.
.map { manga }
} else {
fullBackupManager.restoreChaptersForMangaOffline(it, chapters)
Observable.just(manga)
}
}
.doOnNext {
restoreExtraForManga(it, categories, history, tracks, backupCategories)
}
.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>,
online: Boolean
) {
Observable.just(backupManga)
.flatMap { manga ->
if (online && source != null) {
if (!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)
}
.flatMap { manga ->
trackingFetchObservable(manga, tracks)
}
.subscribe()
}
private fun restoreExtraForManga(manga: Manga, categories: List<Int>, history: List<BackupHistory>, tracks: List<Track>, backupCategories: List<BackupCategory>) {
// Restore categories
fullBackupManager.restoreCategoriesForManga(manga, categories, backupCategories)
// Restore history
fullBackupManager.restoreHistoryForManga(history)
// Restore tracking
fullBackupManager.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 fullBackupManager.restoreChapterFetchObservable(source, manga, chapters)
// 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)
}
}

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

@ -0,0 +1,17 @@
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(),
)

@ -0,0 +1,35 @@
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,
) {
fun getCategoryImpl(): CategoryImpl {
return CategoryImpl().apply {
name = this@BackupCategory.name
flags = this@BackupCategory.flags
order = this@BackupCategory.order
}
}
companion object {
fun copyFrom(category: Category): BackupCategory {
return BackupCategory(
name = category.name,
order = category.order,
flags = category.flags
)
}
}
}

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

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

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

@ -0,0 +1,89 @@
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(16) var chapters: List<BackupChapter> = emptyList(),
@ProtoNumber(17) var categories: List<Int> = emptyList(),
@ProtoNumber(18) 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(),
) {
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
)
}
}
}

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

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

@ -0,0 +1,67 @@
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
)
}
}
}

@ -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
@ -20,21 +20,21 @@ 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.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.legacy.serializer.CategoryTypeAdapter
import eu.kanade.tachiyomi.data.backup.serializer.ChapterTypeAdapter import eu.kanade.tachiyomi.data.backup.legacy.serializer.ChapterTypeAdapter
import eu.kanade.tachiyomi.data.backup.serializer.HistoryTypeAdapter import eu.kanade.tachiyomi.data.backup.legacy.serializer.HistoryTypeAdapter
import eu.kanade.tachiyomi.data.backup.serializer.MangaTypeAdapter 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.TrackTypeAdapter
import eu.kanade.tachiyomi.data.database.DatabaseHelper import eu.kanade.tachiyomi.data.backup.models.AbstractBackupManager
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
import eu.kanade.tachiyomi.data.database.models.ChapterImpl import eu.kanade.tachiyomi.data.database.models.ChapterImpl
@ -44,24 +44,14 @@ import eu.kanade.tachiyomi.data.database.models.MangaCategory
import eu.kanade.tachiyomi.data.database.models.MangaImpl import eu.kanade.tachiyomi.data.database.models.MangaImpl
import eu.kanade.tachiyomi.data.database.models.Track 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.track.TrackManager
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.util.chapter.syncChaptersWithSource import eu.kanade.tachiyomi.util.chapter.syncChaptersWithSource
import rx.Observable import rx.Observable
import timber.log.Timber import timber.log.Timber
import uy.kohesive.injekt.injectLazy
import kotlin.math.max import kotlin.math.max
class BackupManager(val context: Context, version: Int = CURRENT_VERSION) { class LegacyBackupManager(context: Context, version: Int = CURRENT_VERSION) : AbstractBackupManager(context) {
internal val databaseHelper: DatabaseHelper by injectLazy()
internal val sourceManager: SourceManager by injectLazy()
internal val trackManager: TrackManager by injectLazy()
private val preferences: PreferencesHelper by injectLazy()
/** /**
* Version of parser * Version of parser
*/ */
@ -101,7 +91,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()
@ -302,7 +292,7 @@ class BackupManager(val context: Context, version: Int = CURRENT_VERSION) {
.doOnNext { pair -> .doOnNext { pair ->
if (pair.first.isNotEmpty()) { if (pair.first.isNotEmpty()) {
chapters.forEach { it.manga_id = manga.id } chapters.forEach { it.manga_id = manga.id }
insertChapters(chapters) updateChapters(chapters)
} }
} }
} }
@ -469,45 +459,7 @@ class BackupManager(val context: Context, version: Int = CURRENT_VERSION) {
chapters.filter { it.id != null } chapters.filter { it.id != null }
chapters.map { it.manga_id = manga.id } chapters.map { it.manga_id = manga.id }
insertChapters(chapters) updateChapters(chapters)
return true return true
} }
/**
* 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
*/
internal fun getFavoriteManga(): List<Manga> =
databaseHelper.getFavoriteMangas().executeAsBlocking()
/**
* 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.updateChaptersBackup(chapters).executeAsBlocking()
}
/**
* Return number of backups.
*
* @return number of backups selected by user
*/
fun numberOfBackups(): Int = preferences.numberOfBackups().get()
} }

@ -0,0 +1,292 @@
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 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 {
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) }
// 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))
}
private fun restoreManga(mangaJson: JsonObject) {
val 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()
)
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)
// 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)
}
}

@ -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,16 +51,16 @@ object BackupRestoreValidator {
return Results(missingSources, missingTrackers) return Results(missingSources, missingTrackers)
} }
fun getSourceMapping(json: JsonObject): Map<Long, String> { companion object {
val extensionsMapping = json.get(Backup.EXTENSIONS) ?: return emptyMap() fun getSourceMapping(json: JsonObject): Map<Long, String> {
val extensionsMapping = json.get(Backup.EXTENSIONS) ?: return emptyMap()
return extensionsMapping.asJsonArray return extensionsMapping.asJsonArray
.map { .map {
val items = it.asString.split(":") val items = it.asString.split(":")
items[0].toLong() to items[1] items[0].toLong() to items[1]
} }
.toMap() .toMap()
}
} }
data class Results(val missingSources: List<String>, val missingTrackers: List<String>)
} }

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

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

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

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

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

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

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

@ -0,0 +1,65 @@
package eu.kanade.tachiyomi.data.backup.models
import android.content.Context
import android.net.Uri
import eu.kanade.tachiyomi.data.database.DatabaseHelper
import eu.kanade.tachiyomi.data.database.models.Chapter
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.data.track.TrackManager
import eu.kanade.tachiyomi.source.SourceManager
import uy.kohesive.injekt.injectLazy
abstract class AbstractBackupManager(protected val context: Context) {
internal val databaseHelper: DatabaseHelper by injectLazy()
internal val sourceManager: SourceManager by injectLazy()
internal val trackManager: TrackManager by injectLazy()
protected val preferences: PreferencesHelper by injectLazy()
abstract fun createBackup(uri: Uri, flags: Int, isJob: Boolean): String?
/**
* 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
*/
protected fun getFavoriteManga(): List<Manga> =
databaseHelper.getFavoriteMangas().executeAsBlocking()
/**
* 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
*/
protected fun insertChapters(chapters: List<Chapter>) {
databaseHelper.insertChapters(chapters).executeAsBlocking()
}
/**
* Updates a list of chapters
*/
protected fun updateChapters(chapters: List<Chapter>) {
databaseHelper.updateChaptersBackup(chapters).executeAsBlocking()
}
/**
* Return number of backups.
*
* @return number of backups selected by user
*/
protected fun numberOfBackups(): Int = preferences.numberOfBackups().get()
}

@ -0,0 +1,65 @@
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 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
/**
* 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("")
}
}

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

@ -181,6 +181,8 @@ object PreferenceKeys {
const val incognitoMode = "incognito_mode" const val incognitoMode = "incognito_mode"
const val createLegacyBackup = "create_legacy_backup"
fun trackUsername(syncId: Int) = "pref_mangasync_username_$syncId" fun trackUsername(syncId: Int) = "pref_mangasync_username_$syncId"
fun trackPassword(syncId: Int) = "pref_mangasync_password_$syncId" fun trackPassword(syncId: Int) = "pref_mangasync_password_$syncId"

@ -271,6 +271,8 @@ class PreferencesHelper(val context: Context) {
fun incognitoMode() = flowPrefs.getBoolean(Keys.incognitoMode, false) fun incognitoMode() = flowPrefs.getBoolean(Keys.incognitoMode, false)
fun createLegacyBackup() = flowPrefs.getBoolean(Keys.createLegacyBackup, false)
fun setChapterSettingsDefault(manga: Manga) { fun setChapterSettingsDefault(manga: Manga) {
prefs.edit { prefs.edit {
putInt(Keys.defaultChapterFilterByRead, manga.readFilter) putInt(Keys.defaultChapterFilterByRead, manga.readFilter)

@ -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
@ -13,13 +14,17 @@ import androidx.core.os.bundleOf
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
@ -31,6 +36,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
@ -53,36 +59,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
preference { preferenceCategory {
key = "pref_create_backup" titleRes = R.string.backup
titleRes = R.string.pref_create_backup
summaryRes = R.string.pref_create_backup_summ preference {
key = "pref_create_full_backup"
onClick { titleRes = R.string.pref_create_full_backup
if (!BackupCreateService.isRunning(context)) { summaryRes = R.string.pref_create_full_backup_summary
val ctrl = CreateBackupDialog()
ctrl.targetController = this@SettingsBackupController onClick {
ctrl.showDialog(router) backupClick(context, BackupConst.BACKUP_TYPE_FULL)
} else { }
context.toast(R.string.backup_in_progress) }
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)
} }
} }
} }
preference { preferenceCategory {
key = "pref_restore_backup" titleRes = R.string.legacy_backup
titleRes = R.string.pref_restore_backup
summaryRes = R.string.pref_restore_backup_summ preference {
key = "pref_create_legacy_backup"
onClick { titleRes = R.string.pref_create_backup
if (!BackupRestoreService.isRunning(context)) { summaryRes = R.string.pref_create_backup_summ
val intent = Intent(Intent.ACTION_GET_CONTENT)
intent.addCategory(Intent.CATEGORY_OPENABLE) onClick {
intent.type = "application/*" backupClick(context, BackupConst.BACKUP_TYPE_LEGACY)
val title = resources?.getString(R.string.file_select_backup) }
val chooser = Intent.createChooser(intent, title) }
startActivityForResult(chooser, CODE_BACKUP_RESTORE) preference {
} else { key = "pref_restore_legacy_backup"
context.toast(R.string.restore_in_progress) titleRes = R.string.pref_restore_backup
summaryRes = R.string.pref_restore_backup_summ
onClick {
restoreClick(context, CODE_LEGACY_BACKUP_RESTORE)
} }
} }
} }
@ -143,6 +160,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)
} }
@ -167,7 +193,30 @@ 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 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_LEGACY)
}
CODE_LEGACY_BACKUP_RESTORE -> if (data != null && resultCode == Activity.RESULT_OK) {
val uri = data.data
if (uri != null) {
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 activity = activity ?: return
val uri = data.data val uri = data.data
@ -182,39 +231,88 @@ 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_FULL)
} }
CODE_BACKUP_RESTORE -> if (data != null && resultCode == Activity.RESULT_OK) { CODE_FULL_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) val options = arrayOf(
R.string.full_restore_offline,
R.string.full_restore_online
)
.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(
bundleOf(
KEY_TYPE to 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,
@ -226,7 +324,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,
@ -243,26 +341,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(
bundleOf(KEY_URI to uri) bundleOf(
KEY_URI to uri,
KEY_TYPE to type,
KEY_MODE to 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" }}"
} }
@ -271,10 +381,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)
@ -286,12 +396,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
} }
} }

@ -347,14 +347,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, much slower but gives you more updated info and chapters</string>
<string name="full_restore_offline">Restore offline, finishes quickly but contains only what your 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>

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

Loading…
Cancel
Save