mirror of
				https://github.com/mihonapp/mihon.git
				synced 2025-10-31 06:17:57 +01:00 
			
		
		
		
	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
This commit is contained in:
		| @@ -7,4 +7,9 @@ object BackupConst { | ||||
|     private const val NAME = "BackupRestoreServices" | ||||
|     const val EXTRA_URI = "$ID.$NAME.EXTRA_URI" | ||||
|     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.net.toUri | ||||
| 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.util.system.acquireWakeLock | ||||
| import eu.kanade.tachiyomi.util.system.isServiceRunning | ||||
| @@ -46,11 +49,12 @@ class BackupCreateService : Service() { | ||||
|          * @param uri path of Uri | ||||
|          * @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)) { | ||||
|                 val intent = Intent(context, BackupCreateService::class.java).apply { | ||||
|                     putExtra(BackupConst.EXTRA_URI, uri) | ||||
|                     putExtra(BackupConst.EXTRA_FLAGS, flags) | ||||
|                     putExtra(BackupConst.EXTRA_TYPE, type) | ||||
|                 } | ||||
|                 ContextCompat.startForegroundService(context, intent) | ||||
|             } | ||||
| @@ -62,7 +66,7 @@ class BackupCreateService : Service() { | ||||
|      */ | ||||
|     private lateinit var wakeLock: PowerManager.WakeLock | ||||
|  | ||||
|     private lateinit var backupManager: BackupManager | ||||
|     private lateinit var backupManager: AbstractBackupManager | ||||
|     private lateinit var notifier: BackupNotifier | ||||
|  | ||||
|     override fun onCreate() { | ||||
| @@ -101,7 +105,8 @@ class BackupCreateService : Service() { | ||||
|         try { | ||||
|             val uri = intent.getParcelableExtra<Uri>(BackupConst.EXTRA_URI) | ||||
|             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 unifile = UniFile.fromUri(this, backupFileUri) | ||||
|   | ||||
| @@ -7,6 +7,8 @@ import androidx.work.PeriodicWorkRequestBuilder | ||||
| import androidx.work.WorkManager | ||||
| import androidx.work.Worker | ||||
| 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 uy.kohesive.injekt.Injekt | ||||
| import uy.kohesive.injekt.api.get | ||||
| @@ -17,11 +19,13 @@ class BackupCreatorJob(private val context: Context, workerParams: WorkerParamet | ||||
|  | ||||
|     override fun doWork(): Result { | ||||
|         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 flags = BackupCreateService.BACKUP_ALL | ||||
|         return try { | ||||
|             backupManager.createBackup(uri, flags, true) | ||||
|             legacyBackupManager?.createBackup(uri, flags, true) | ||||
|             Result.success() | ||||
|         } catch (e: Exception) { | ||||
|             Result.failure() | ||||
|   | ||||
| @@ -15,7 +15,7 @@ import uy.kohesive.injekt.injectLazy | ||||
| import java.io.File | ||||
| import java.util.concurrent.TimeUnit | ||||
|  | ||||
| internal class BackupNotifier(private val context: Context) { | ||||
| class BackupNotifier(private val context: Context) { | ||||
|  | ||||
|     private val preferences: PreferencesHelper by injectLazy() | ||||
|  | ||||
|   | ||||
| @@ -7,45 +7,17 @@ import android.net.Uri | ||||
| import android.os.IBinder | ||||
| import android.os.PowerManager | ||||
| 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.data.backup.models.Backup.CATEGORIES | ||||
| import eu.kanade.tachiyomi.data.backup.models.Backup.CHAPTERS | ||||
| import eu.kanade.tachiyomi.data.backup.models.Backup.HISTORY | ||||
| import eu.kanade.tachiyomi.data.backup.models.Backup.MANGA | ||||
| import eu.kanade.tachiyomi.data.backup.models.Backup.MANGAS | ||||
| import eu.kanade.tachiyomi.data.backup.models.Backup.TRACK | ||||
| import eu.kanade.tachiyomi.data.backup.models.Backup.VERSION | ||||
| import eu.kanade.tachiyomi.data.backup.models.DHistory | ||||
| import eu.kanade.tachiyomi.data.database.DatabaseHelper | ||||
| import eu.kanade.tachiyomi.data.database.models.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.backup.full.FullBackupRestore | ||||
| import eu.kanade.tachiyomi.data.backup.legacy.LegacyBackupRestore | ||||
| import eu.kanade.tachiyomi.data.backup.models.AbstractBackupRestore | ||||
| 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.isServiceRunning | ||||
| import kotlinx.coroutines.CoroutineExceptionHandler | ||||
| import kotlinx.coroutines.GlobalScope | ||||
| import kotlinx.coroutines.Job | ||||
| import kotlinx.coroutines.launch | ||||
| import rx.Observable | ||||
| 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. | ||||
| @@ -69,10 +41,12 @@ class BackupRestoreService : Service() { | ||||
|          * @param context context of application | ||||
|          * @param uri path of Uri | ||||
|          */ | ||||
|         fun start(context: Context, uri: Uri) { | ||||
|         fun start(context: Context, uri: Uri, mode: Int, online: Boolean?) { | ||||
|             if (!isRunning(context)) { | ||||
|                 val intent = Intent(context, BackupRestoreService::class.java).apply { | ||||
|                     putExtra(BackupConst.EXTRA_URI, uri) | ||||
|                     putExtra(BackupConst.EXTRA_MODE, mode) | ||||
|                     online?.let { putExtra(BackupConst.EXTRA_TYPE, it) } | ||||
|                 } | ||||
|                 ContextCompat.startForegroundService(context, intent) | ||||
|             } | ||||
| @@ -95,35 +69,9 @@ class BackupRestoreService : Service() { | ||||
|      */ | ||||
|     private lateinit var wakeLock: PowerManager.WakeLock | ||||
|  | ||||
|     private var job: Job? = 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 var backupRestore: AbstractBackupRestore? = null | ||||
|     private lateinit var notifier: BackupNotifier | ||||
|  | ||||
|     private val db: DatabaseHelper by injectLazy() | ||||
|  | ||||
|     private val trackManager: TrackManager by injectLazy() | ||||
|  | ||||
|     override fun onCreate() { | ||||
|         super.onCreate() | ||||
|  | ||||
| @@ -144,7 +92,7 @@ class BackupRestoreService : Service() { | ||||
|     } | ||||
|  | ||||
|     private fun destroyJob() { | ||||
|         job?.cancel() | ||||
|         backupRestore?.job?.cancel() | ||||
|         if (wakeLock.isHeld) { | ||||
|             wakeLock.release() | ||||
|         } | ||||
| @@ -165,304 +113,30 @@ class BackupRestoreService : Service() { | ||||
|      */ | ||||
|     override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { | ||||
|         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. | ||||
|         job?.cancel() | ||||
|         backupRestore?.job?.cancel() | ||||
|  | ||||
|         backupRestore = if (mode == BackupConst.BACKUP_TYPE_FULL) FullBackupRestore(this, notifier, online) else LegacyBackupRestore(this, notifier) | ||||
|         val handler = CoroutineExceptionHandler { _, exception -> | ||||
|             Timber.e(exception) | ||||
|             writeErrorLog() | ||||
|             backupRestore?.writeErrorLog() | ||||
|  | ||||
|             notifier.showRestoreError(exception.message) | ||||
|  | ||||
|             stopSelf(startId) | ||||
|         } | ||||
|         job = GlobalScope.launch(handler) { | ||||
|             if (!restoreBackup(uri)) { | ||||
|         backupRestore?.job = GlobalScope.launch(handler) { | ||||
|             if (backupRestore?.restoreBackup(uri) == false) { | ||||
|                 notifier.showRestoreError(getString(R.string.restoring_backup_canceled)) | ||||
|             } | ||||
|         } | ||||
|         job?.invokeOnCompletion { | ||||
|         backupRestore?.job?.invokeOnCompletion { | ||||
|             stopSelf(startId) | ||||
|         } | ||||
|  | ||||
|         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.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_TRACK | ||||
| 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.models.Backup.CATEGORIES | ||||
| import eu.kanade.tachiyomi.data.backup.models.Backup.CHAPTERS | ||||
| import eu.kanade.tachiyomi.data.backup.models.Backup.CURRENT_VERSION | ||||
| import eu.kanade.tachiyomi.data.backup.models.Backup.EXTENSIONS | ||||
| import eu.kanade.tachiyomi.data.backup.models.Backup.HISTORY | ||||
| import eu.kanade.tachiyomi.data.backup.models.Backup.MANGA | ||||
| import eu.kanade.tachiyomi.data.backup.models.Backup.TRACK | ||||
| import eu.kanade.tachiyomi.data.backup.models.DHistory | ||||
| import eu.kanade.tachiyomi.data.backup.serializer.CategoryTypeAdapter | ||||
| import eu.kanade.tachiyomi.data.backup.serializer.ChapterTypeAdapter | ||||
| import eu.kanade.tachiyomi.data.backup.serializer.HistoryTypeAdapter | ||||
| import eu.kanade.tachiyomi.data.backup.serializer.MangaTypeAdapter | ||||
| import eu.kanade.tachiyomi.data.backup.serializer.TrackTypeAdapter | ||||
| import eu.kanade.tachiyomi.data.database.DatabaseHelper | ||||
| import eu.kanade.tachiyomi.data.backup.legacy.models.Backup | ||||
| import eu.kanade.tachiyomi.data.backup.legacy.models.Backup.CATEGORIES | ||||
| import eu.kanade.tachiyomi.data.backup.legacy.models.Backup.CHAPTERS | ||||
| import eu.kanade.tachiyomi.data.backup.legacy.models.Backup.CURRENT_VERSION | ||||
| import eu.kanade.tachiyomi.data.backup.legacy.models.Backup.EXTENSIONS | ||||
| import eu.kanade.tachiyomi.data.backup.legacy.models.Backup.HISTORY | ||||
| import eu.kanade.tachiyomi.data.backup.legacy.models.Backup.MANGA | ||||
| import eu.kanade.tachiyomi.data.backup.legacy.models.Backup.TRACK | ||||
| import eu.kanade.tachiyomi.data.backup.legacy.models.DHistory | ||||
| import eu.kanade.tachiyomi.data.backup.legacy.serializer.CategoryTypeAdapter | ||||
| import eu.kanade.tachiyomi.data.backup.legacy.serializer.ChapterTypeAdapter | ||||
| import eu.kanade.tachiyomi.data.backup.legacy.serializer.HistoryTypeAdapter | ||||
| import eu.kanade.tachiyomi.data.backup.legacy.serializer.MangaTypeAdapter | ||||
| import eu.kanade.tachiyomi.data.backup.legacy.serializer.TrackTypeAdapter | ||||
| import eu.kanade.tachiyomi.data.backup.models.AbstractBackupManager | ||||
| import eu.kanade.tachiyomi.data.database.models.CategoryImpl | ||||
| import eu.kanade.tachiyomi.data.database.models.Chapter | ||||
| 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.Track | ||||
| 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.Source | ||||
| import eu.kanade.tachiyomi.source.SourceManager | ||||
| import eu.kanade.tachiyomi.util.chapter.syncChaptersWithSource | ||||
| import rx.Observable | ||||
| import timber.log.Timber | ||||
| import uy.kohesive.injekt.injectLazy | ||||
| import kotlin.math.max | ||||
| 
 | ||||
| class BackupManager(val context: Context, version: Int = CURRENT_VERSION) { | ||||
| 
 | ||||
|     internal val databaseHelper: DatabaseHelper by injectLazy() | ||||
|     internal val sourceManager: SourceManager by injectLazy() | ||||
|     internal val trackManager: TrackManager by injectLazy() | ||||
|     private val preferences: PreferencesHelper by injectLazy() | ||||
| 
 | ||||
| class LegacyBackupManager(context: Context, version: Int = CURRENT_VERSION) : AbstractBackupManager(context) { | ||||
|     /** | ||||
|      * Version of parser | ||||
|      */ | ||||
| @@ -101,7 +91,7 @@ class BackupManager(val context: Context, version: Int = CURRENT_VERSION) { | ||||
|      * @param uri path of Uri | ||||
|      * @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 | ||||
|         val root = JsonObject() | ||||
| 
 | ||||
| @@ -302,7 +292,7 @@ class BackupManager(val context: Context, version: Int = CURRENT_VERSION) { | ||||
|             .doOnNext { pair -> | ||||
|                 if (pair.first.isNotEmpty()) { | ||||
|                     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.map { it.manga_id = manga.id } | ||||
| 
 | ||||
|         insertChapters(chapters) | ||||
|         updateChapters(chapters) | ||||
|         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.net.Uri | ||||
| @@ -6,23 +6,17 @@ 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.models.Backup | ||||
| import eu.kanade.tachiyomi.data.track.TrackManager | ||||
| 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() | ||||
| import eu.kanade.tachiyomi.data.backup.legacy.models.Backup | ||||
| import eu.kanade.tachiyomi.data.backup.models.AbstractBackupRestoreValidator | ||||
| 
 | ||||
| class LegacyBackupRestoreValidator : AbstractBackupRestoreValidator() { | ||||
|     /** | ||||
|      * Checks for critical backup file data. | ||||
|      * | ||||
|      * @throws Exception if version or manga cannot be found. | ||||
|      * @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 json = JsonParser.parseReader(reader).asJsonObject | ||||
| 
 | ||||
| @@ -57,16 +51,16 @@ object BackupRestoreValidator { | ||||
|         return Results(missingSources, missingTrackers) | ||||
|     } | ||||
| 
 | ||||
|     fun getSourceMapping(json: JsonObject): Map<Long, String> { | ||||
|         val extensionsMapping = json.get(Backup.EXTENSIONS) ?: return emptyMap() | ||||
|     companion object { | ||||
|         fun getSourceMapping(json: JsonObject): Map<Long, String> { | ||||
|             val extensionsMapping = json.get(Backup.EXTENSIONS) ?: return emptyMap() | ||||
| 
 | ||||
|         return extensionsMapping.asJsonArray | ||||
|             .map { | ||||
|                 val items = it.asString.split(":") | ||||
|                 items[0].toLong() to items[1] | ||||
|             } | ||||
|             .toMap() | ||||
|             return extensionsMapping.asJsonArray | ||||
|                 .map { | ||||
|                     val items = it.asString.split(":") | ||||
|                     items[0].toLong() to items[1] | ||||
|                 } | ||||
|                 .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.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) | ||||
| @@ -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.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.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.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 | ||||
| @@ -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.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.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 createLegacyBackup = "create_legacy_backup" | ||||
|  | ||||
|     fun trackUsername(syncId: Int) = "pref_mangasync_username_$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 createLegacyBackup() = flowPrefs.getBoolean(Keys.createLegacyBackup, false) | ||||
|  | ||||
|     fun setChapterSettingsDefault(manga: Manga) { | ||||
|         prefs.edit { | ||||
|             putInt(Keys.defaultChapterFilterByRead, manga.readFilter) | ||||
|   | ||||
| @@ -4,6 +4,7 @@ import android.Manifest.permission.WRITE_EXTERNAL_STORAGE | ||||
| import android.app.Activity | ||||
| import android.app.Dialog | ||||
| import android.content.ActivityNotFoundException | ||||
| import android.content.Context | ||||
| import android.content.Intent | ||||
| import android.net.Uri | ||||
| import android.os.Bundle | ||||
| @@ -13,13 +14,17 @@ import androidx.core.os.bundleOf | ||||
| import androidx.preference.PreferenceScreen | ||||
| import com.afollestad.materialdialogs.MaterialDialog | ||||
| import com.afollestad.materialdialogs.list.listItemsMultiChoice | ||||
| import com.afollestad.materialdialogs.list.listItemsSingleChoice | ||||
| import com.hippo.unifile.UniFile | ||||
| 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.BackupCreatorJob | ||||
| import eu.kanade.tachiyomi.data.backup.BackupRestoreService | ||||
| import eu.kanade.tachiyomi.data.backup.BackupRestoreValidator | ||||
| import eu.kanade.tachiyomi.data.backup.models.Backup | ||||
| import eu.kanade.tachiyomi.data.backup.full.FullBackupRestoreValidator | ||||
| 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.ui.base.controller.DialogController | ||||
| 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.preferenceCategory | ||||
| 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.system.getFilePicker | ||||
| import eu.kanade.tachiyomi.util.system.toast | ||||
| @@ -53,36 +59,47 @@ class SettingsBackupController : SettingsController() { | ||||
|     override fun setupPreferenceScreen(screen: PreferenceScreen) = screen.apply { | ||||
|         titleRes = R.string.backup | ||||
|  | ||||
|         preference { | ||||
|             key = "pref_create_backup" | ||||
|             titleRes = R.string.pref_create_backup | ||||
|             summaryRes = R.string.pref_create_backup_summ | ||||
|         preferenceCategory { | ||||
|             titleRes = R.string.backup | ||||
|  | ||||
|             onClick { | ||||
|                 if (!BackupCreateService.isRunning(context)) { | ||||
|                     val ctrl = CreateBackupDialog() | ||||
|                     ctrl.targetController = this@SettingsBackupController | ||||
|                     ctrl.showDialog(router) | ||||
|                 } else { | ||||
|                     context.toast(R.string.backup_in_progress) | ||||
|             preference { | ||||
|                 key = "pref_create_full_backup" | ||||
|                 titleRes = R.string.pref_create_full_backup | ||||
|                 summaryRes = R.string.pref_create_full_backup_summary | ||||
|  | ||||
|                 onClick { | ||||
|                     backupClick(context, BackupConst.BACKUP_TYPE_FULL) | ||||
|                 } | ||||
|             } | ||||
|             preference { | ||||
|                 key = "pref_restore_full_backup" | ||||
|                 titleRes = R.string.pref_restore_full_backup | ||||
|                 summaryRes = R.string.pref_restore_full_backup_summary | ||||
|  | ||||
|                 onClick { | ||||
|                     restoreClick(context, CODE_FULL_BACKUP_RESTORE) | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|         preference { | ||||
|             key = "pref_restore_backup" | ||||
|             titleRes = R.string.pref_restore_backup | ||||
|             summaryRes = R.string.pref_restore_backup_summ | ||||
|         preferenceCategory { | ||||
|             titleRes = R.string.legacy_backup | ||||
|  | ||||
|             onClick { | ||||
|                 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, CODE_BACKUP_RESTORE) | ||||
|                 } else { | ||||
|                     context.toast(R.string.restore_in_progress) | ||||
|             preference { | ||||
|                 key = "pref_create_legacy_backup" | ||||
|                 titleRes = R.string.pref_create_backup | ||||
|                 summaryRes = R.string.pref_create_backup_summ | ||||
|  | ||||
|                 onClick { | ||||
|                     backupClick(context, BackupConst.BACKUP_TYPE_LEGACY) | ||||
|                 } | ||||
|             } | ||||
|             preference { | ||||
|                 key = "pref_restore_legacy_backup" | ||||
|                 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" | ||||
|                 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 } | ||||
|                     .launchIn(scope) | ||||
|             } | ||||
| @@ -167,7 +193,7 @@ class SettingsBackupController : SettingsController() { | ||||
|                 // Set backup Uri | ||||
|                 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 | ||||
| @@ -182,39 +208,111 @@ class SettingsBackupController : SettingsController() { | ||||
|  | ||||
|                 activity.toast(R.string.creating_backup) | ||||
|  | ||||
|                 BackupCreateService.start(activity, file.uri, backupFlags) | ||||
|                 BackupCreateService.start(activity, file.uri, backupFlags, BackupConst.BACKUP_TYPE_LEGACY) | ||||
|             } | ||||
|             CODE_BACKUP_RESTORE -> if (data != null && resultCode == Activity.RESULT_OK) { | ||||
|             CODE_LEGACY_BACKUP_RESTORE -> if (data != null && resultCode == Activity.RESULT_OK) { | ||||
|                 val uri = data.data | ||||
|                 if (uri != null) { | ||||
|                     RestoreBackupDialog(uri).showDialog(router) | ||||
|                     RestoreBackupDialog(uri, BackupConst.BACKUP_TYPE_LEGACY, isOnline = true).showDialog(router) | ||||
|                 } | ||||
|             } | ||||
|             CODE_FULL_BACKUP_CREATE -> if (data != null && resultCode == Activity.RESULT_OK) { | ||||
|                 val activity = activity ?: return | ||||
|  | ||||
|                 val uri = data.data | ||||
|                 val flags = Intent.FLAG_GRANT_READ_URI_PERMISSION or | ||||
|                     Intent.FLAG_GRANT_WRITE_URI_PERMISSION | ||||
|  | ||||
|                 if (uri != null) { | ||||
|                     activity.contentResolver.takePersistableUriPermission(uri, flags) | ||||
|                 } | ||||
|  | ||||
|                 val file = UniFile.fromUri(activity, uri) | ||||
|  | ||||
|                 activity.toast(R.string.creating_backup) | ||||
|  | ||||
|                 BackupCreateService.start(activity, file.uri, backupFlags, BackupConst.BACKUP_TYPE_FULL) | ||||
|             } | ||||
|             CODE_FULL_BACKUP_RESTORE -> if (data != null && resultCode == Activity.RESULT_OK) { | ||||
|                 val uri = data.data | ||||
|                 if (uri != null) { | ||||
|                     val options = arrayOf( | ||||
|                         R.string.full_restore_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 | ||||
|  | ||||
|         // Get dirs | ||||
|         val currentDir = preferences.backupsDirectory().get() | ||||
|  | ||||
|         try { | ||||
|             val fileName = if (type == BackupConst.BACKUP_TYPE_FULL) BackupFull.getDefaultFilename() else Backup.getDefaultFilename() | ||||
|             // Use Android's built-in file creator | ||||
|             val intent = Intent(Intent.ACTION_CREATE_DOCUMENT) | ||||
|                 .addCategory(Intent.CATEGORY_OPENABLE) | ||||
|                 .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) { | ||||
|             // 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 { | ||||
|             val type = args.getInt(KEY_TYPE) | ||||
|             val activity = activity!! | ||||
|             val options = arrayOf( | ||||
|                 R.string.manga, | ||||
| @@ -226,7 +324,7 @@ class SettingsBackupController : SettingsController() { | ||||
|                 .map { activity.getString(it) } | ||||
|  | ||||
|             return MaterialDialog(activity) | ||||
|                 .title(R.string.pref_create_backup) | ||||
|                 .title(R.string.create_backup) | ||||
|                 .message(R.string.backup_choice) | ||||
|                 .listItemsMultiChoice( | ||||
|                     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) | ||||
|                 .negativeButton(android.R.string.cancel) | ||||
|         } | ||||
|  | ||||
|         private companion object { | ||||
|             const val KEY_TYPE = "CreateBackupDialog.type" | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     class RestoreBackupDialog(bundle: Bundle? = null) : DialogController(bundle) { | ||||
|         constructor(uri: Uri) : this( | ||||
|             bundleOf(KEY_URI to uri) | ||||
|         constructor(uri: Uri, type: Int, isOnline: Boolean) : this( | ||||
|             bundleOf( | ||||
|                 KEY_URI to uri, | ||||
|                 KEY_TYPE to type, | ||||
|                 KEY_MODE to isOnline | ||||
|             ) | ||||
|         ) | ||||
|  | ||||
|         override fun onCreateDialog(savedViewState: Bundle?): Dialog { | ||||
|             val activity = activity!! | ||||
|             val uri: Uri = args.getParcelable(KEY_URI)!! | ||||
|             val type: Int = args.getInt(KEY_TYPE) | ||||
|             val isOnline: Boolean = args.getBoolean(KEY_MODE, true) | ||||
|  | ||||
|             return try { | ||||
|                 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()) { | ||||
|                     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) | ||||
|                     .title(R.string.pref_restore_backup) | ||||
|                     .title(R.string.restore_backup) | ||||
|                     .message(text = message) | ||||
|                     .positiveButton(R.string.action_restore) { | ||||
|                         BackupRestoreService.start(activity, uri) | ||||
|                         BackupRestoreService.start(activity, uri, type, isOnline) | ||||
|                     } | ||||
|             } catch (e: Exception) { | ||||
|                 MaterialDialog(activity) | ||||
| @@ -286,12 +396,16 @@ class SettingsBackupController : SettingsController() { | ||||
|  | ||||
|         private companion object { | ||||
|             const val KEY_URI = "RestoreBackupDialog.uri" | ||||
|             const val KEY_TYPE = "RestoreBackupDialog.type" | ||||
|             const val KEY_MODE = "RestoreBackupDialog.mode" | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private companion object { | ||||
|         const val CODE_BACKUP_CREATE = 501 | ||||
|         const val CODE_BACKUP_RESTORE = 502 | ||||
|         const val CODE_LEGACY_BACKUP_CREATE = 501 | ||||
|         const val CODE_LEGACY_BACKUP_RESTORE = 502 | ||||
|         const val CODE_BACKUP_DIR = 503 | ||||
|         const val CODE_FULL_BACKUP_CREATE = 504 | ||||
|         const val CODE_FULL_BACKUP_RESTORE = 505 | ||||
|     } | ||||
| } | ||||
|   | ||||
		Reference in New Issue
	
	Block a user