Update restore to use a notification

Also added cleanup option to downloads
This commit is contained in:
Carlos 2020-01-10 23:31:29 -08:00 committed by Jay
parent 5368e37988
commit b44ec4bfab
15 changed files with 516 additions and 380 deletions

View File

@ -32,6 +32,8 @@ import eu.kanade.tachiyomi.source.Source
import eu.kanade.tachiyomi.source.SourceManager import eu.kanade.tachiyomi.source.SourceManager
import eu.kanade.tachiyomi.util.sendLocalBroadcast import eu.kanade.tachiyomi.util.sendLocalBroadcast
import eu.kanade.tachiyomi.util.syncChaptersWithSource import eu.kanade.tachiyomi.util.syncChaptersWithSource
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import rx.Observable import rx.Observable
import timber.log.Timber import timber.log.Timber
import uy.kohesive.injekt.injectLazy import uy.kohesive.injekt.injectLazy
@ -263,15 +265,15 @@ class BackupManager(val context: Context, version: Int = CURRENT_VERSION) {
* @param manga manga that needs updating * @param manga manga that needs updating
* @return [Observable] that contains manga * @return [Observable] that contains manga
*/ */
fun restoreMangaFetchObservable(source: Source, manga: Manga): Observable<Manga> { suspend fun restoreMangaFetch(source: Source, manga: Manga): Manga {
return source.fetchMangaDetails(manga) return withContext(Dispatchers.IO) {
.map { networkManga -> val networkManga = source.fetchMangaDetails(manga).toBlocking().single()
manga.copyFrom(networkManga) manga.copyFrom(networkManga)
manga.favorite = true manga.favorite = true
manga.initialized = true manga.initialized = true
manga.id = insertManga(manga) manga.id = insertManga(manga)
manga manga
} }
} }
/** /**
@ -281,15 +283,16 @@ class BackupManager(val context: Context, version: Int = CURRENT_VERSION) {
* @param manga manga that needs updating * @param manga manga that needs updating
* @return [Observable] that contains manga * @return [Observable] that contains manga
*/ */
fun restoreChapterFetchObservable(source: Source, manga: Manga, chapters: List<Chapter>): Observable<Pair<List<Chapter>, List<Chapter>>> { suspend fun restoreChapterFetch(source: Source, manga: Manga, chapters: List<Chapter>) {
return source.fetchChapterList(manga) withContext(Dispatchers.IO) {
.map { syncChaptersWithSource(databaseHelper, it, manga, source) } val fetchChapters = source.fetchChapterList(manga).toBlocking().single()
.doOnNext { val syncChaptersWithSource =
if (it.first.isNotEmpty()) { syncChaptersWithSource(databaseHelper, fetchChapters, manga, source)
chapters.forEach { it.manga_id = manga.id } if (syncChaptersWithSource.first.isNotEmpty()) {
insertChapters(chapters) chapters.forEach { it.manga_id = manga.id }
} insertChapters(chapters)
} }
}
} }
/** /**

View File

@ -1,13 +1,18 @@
package eu.kanade.tachiyomi.data.backup package eu.kanade.tachiyomi.data.backup
import android.app.PendingIntent
import android.app.Service import android.app.Service
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.net.Uri import android.net.Uri
import android.os.Build
import android.os.IBinder import android.os.IBinder
import android.os.PowerManager import android.os.PowerManager
import androidx.core.app.NotificationCompat
import androidx.core.content.ContextCompat
import com.github.salomonbrys.kotson.fromJson import com.github.salomonbrys.kotson.fromJson
import com.google.gson.JsonArray import com.google.gson.JsonArray
import com.google.gson.JsonObject
import com.google.gson.JsonParser import com.google.gson.JsonParser
import com.google.gson.stream.JsonReader import com.google.gson.stream.JsonReader
import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.R
@ -21,64 +26,28 @@ import eu.kanade.tachiyomi.data.backup.models.Backup.VERSION
import eu.kanade.tachiyomi.data.backup.models.DHistory import eu.kanade.tachiyomi.data.backup.models.DHistory
import eu.kanade.tachiyomi.data.database.DatabaseHelper import eu.kanade.tachiyomi.data.database.DatabaseHelper
import eu.kanade.tachiyomi.data.database.models.* import eu.kanade.tachiyomi.data.database.models.*
import eu.kanade.tachiyomi.data.notification.NotificationReceiver
import eu.kanade.tachiyomi.data.notification.Notifications
import eu.kanade.tachiyomi.data.track.TrackManager import eu.kanade.tachiyomi.data.track.TrackManager
import eu.kanade.tachiyomi.source.Source
import eu.kanade.tachiyomi.source.SourceNotFoundException import eu.kanade.tachiyomi.source.SourceNotFoundException
import eu.kanade.tachiyomi.util.chop import eu.kanade.tachiyomi.util.getUriCompat
import eu.kanade.tachiyomi.util.isServiceRunning import eu.kanade.tachiyomi.util.isServiceRunning
import eu.kanade.tachiyomi.util.sendLocalBroadcast import eu.kanade.tachiyomi.util.notificationManager
import rx.Observable import kotlinx.coroutines.CoroutineExceptionHandler
import rx.Subscription import kotlinx.coroutines.GlobalScope
import rx.schedulers.Schedulers import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import timber.log.Timber import timber.log.Timber
import uy.kohesive.injekt.injectLazy import uy.kohesive.injekt.injectLazy
import java.io.File import java.io.File
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale import java.util.Locale
import java.util.concurrent.ExecutorService
import java.util.concurrent.Executors
/** /**
* Restores backup from json file * Restores backup from json file
*/ */
class BackupRestoreService : Service() { class BackupRestoreService : Service() {
companion object {
/**
* Returns the status of the service.
*
* @param context the application context.
* @return true if the service is running, false otherwise.
*/
private fun isRunning(context: Context): Boolean =
context.isServiceRunning(BackupRestoreService::class.java)
/**
* Starts a service to restore a backup from Json
*
* @param context context of application
* @param uri path of Uri
*/
fun start(context: Context, uri: Uri) {
if (!isRunning(context)) {
val intent = Intent(context, BackupRestoreService::class.java).apply {
putExtra(BackupConst.EXTRA_URI, uri)
}
context.startService(intent)
}
}
/**
* Stops the service.
*
* @param context the application context.
*/
fun stop(context: Context) {
context.stopService(Intent(context, BackupRestoreService::class.java))
}
}
/** /**
* Wake lock that will be held until the service is destroyed. * Wake lock that will be held until the service is destroyed.
@ -88,27 +57,29 @@ class BackupRestoreService : Service() {
/** /**
* Subscription where the update is done. * Subscription where the update is done.
*/ */
private var subscription: Subscription? = null private var job: Job? = null
/** /**
* The progress of a backup restore * The progress of a backup restore
*/ */
private var restoreProgress = 0 private var restoreProgress = 0
/** private var totalAmount = 0
* Amount of manga in Json file (needed for restore)
*/
private var restoreAmount = 0
/** /**
* List containing errors * List containing errors
*/ */
private val errors = mutableListOf<Pair<Date, String>>() private val errors = mutableListOf<String>()
/**
* count of cancelled
*/
private var cancelled = 0
/** /**
* List containing distinct errors * List containing distinct errors
*/ */
private val errorsMini = mutableListOf<String>() private val trackingErrors = mutableListOf<String>()
/** /**
@ -116,6 +87,11 @@ class BackupRestoreService : Service() {
*/ */
private val sourcesMissing = mutableListOf<Long>() private val sourcesMissing = mutableListOf<Long>()
/**
* List containing missing sources
*/
private var lincensedManga = 0
/** /**
* Backup manager * Backup manager
*/ */
@ -132,17 +108,15 @@ class BackupRestoreService : Service() {
internal val trackManager: TrackManager by injectLazy() internal val trackManager: TrackManager by injectLazy()
private lateinit var executor: ExecutorService
/** /**
* Method called when the service is created. It injects dependencies and acquire the wake lock. * Method called when the service is created. It injects dependencies and acquire the wake lock.
*/ */
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
startForeground(Notifications.ID_RESTORE_PROGRESS, progressNotification.build())
wakeLock = (getSystemService(Context.POWER_SERVICE) as PowerManager).newWakeLock( wakeLock = (getSystemService(Context.POWER_SERVICE) as PowerManager).newWakeLock(
PowerManager.PARTIAL_WAKE_LOCK, "BackupRestoreService:WakeLock") PowerManager.PARTIAL_WAKE_LOCK, "BackupRestoreService:WakeLock")
wakeLock.acquire() wakeLock.acquire(10 * 60 * 1000L /*10 minutes*/)
executor = Executors.newSingleThreadExecutor()
} }
/** /**
@ -150,8 +124,7 @@ class BackupRestoreService : Service() {
* releases the wake lock. * releases the wake lock.
*/ */
override fun onDestroy() { override fun onDestroy() {
subscription?.unsubscribe() job?.cancel()
executor.shutdown() // must be called after unsubscribe
if (wakeLock.isHeld) { if (wakeLock.isHeld) {
wakeLock.release() wakeLock.release()
} }
@ -177,108 +150,164 @@ class BackupRestoreService : Service() {
val uri = intent.getParcelableExtra<Uri>(BackupConst.EXTRA_URI) ?: return START_NOT_STICKY val uri = intent.getParcelableExtra<Uri>(BackupConst.EXTRA_URI) ?: return START_NOT_STICKY
// Unsubscribe from any previous subscription if needed. // Unsubscribe from any previous subscription if needed.
subscription?.unsubscribe() job?.cancel()
val handler = CoroutineExceptionHandler { _, exception ->
subscription = Observable.using( Timber.e(exception)
{ db.lowLevel().beginTransaction() }, showErrorNotification(exception.message!!)
{ getRestoreObservable(uri).doOnNext { db.lowLevel().setTransactionSuccessful() } }, stopSelf(startId)
{ executor.execute { db.lowLevel().endTransaction() } }) }
.doAfterTerminate { stopSelf(startId) } job = GlobalScope.launch(handler) {
.subscribeOn(Schedulers.from(executor)) restoreBackup(uri!!)
.subscribe() }
job?.invokeOnCompletion { stopSelf(startId) }
return START_NOT_STICKY return START_NOT_STICKY
} }
override fun stopService(name: Intent?): Boolean {
job?.cancel()
if (wakeLock.isHeld) {
wakeLock.release()
}
return super.stopService(name)
}
/** /**
* Returns an [Observable] containing restore process. * Restore a backup json file
*
* @param uri restore file
* @return [Observable<Manga>]
*/ */
private fun getRestoreObservable(uri: Uri): Observable<List<Manga>> { private suspend fun restoreBackup(uri: Uri) {
val startTime = System.currentTimeMillis() val reader = JsonReader(contentResolver.openInputStream(uri)!!.bufferedReader())
val json = JsonParser().parse(reader).asJsonObject
return Observable.just(Unit) // Get parser version
.map { val version = json.get(VERSION)?.asInt ?: 1
val reader = JsonReader(contentResolver.openInputStream(uri)!!.bufferedReader())
val json = JsonParser().parse(reader).asJsonObject
// Get parser version // Initialize manager
val version = json.get(VERSION)?.asInt ?: 1 backupManager = BackupManager(this, version)
// Initialize manager val mangasJson = json.get(MANGAS).asJsonArray
backupManager = BackupManager(this, version)
val mangasJson = json.get(MANGAS).asJsonArray // +1 for categories
totalAmount = mangasJson.size() + 1
trackingErrors.clear()
sourcesMissing.clear()
lincensedManga = 0
errors.clear()
cancelled = 0
// Restore categories
restoreCategories(json, backupManager)
restoreAmount = mangasJson.size() + 1 // +1 for categories mangasJson.forEach {
restoreProgress = 0 restoreManga(it.asJsonObject, backupManager)
errors.clear() }
errorsMini.clear()
// Restore categories notificationManager.cancel(Notifications.ID_RESTORE_PROGRESS)
json.get(CATEGORIES)?.let {
backupManager.restoreCategories(it.asJsonArray)
restoreProgress += 1
showRestoreProgress(restoreProgress, restoreAmount, "Categories added", errors.size)
}
mangasJson cancelled = errors.count { it.contains("cancelled", true) }
val tmpErrors = errors.filter { !it.contains("cancelled", true) }
errors.clear()
errors.addAll(tmpErrors)
val logFile = writeErrorLog()
showResultNotification(logFile.parent, logFile.name)
}
/**Restore categories if they were backed up
*
*/
private fun restoreCategories(json: JsonObject, backupManager: BackupManager) {
val element = json.get(CATEGORIES)
if (element != null) {
backupManager.restoreCategories(element.asJsonArray)
restoreProgress += 1
showProgressNotification(restoreProgress, totalAmount, "Categories added")
} else {
totalAmount -= 1
}
}
/**
* Restore manga from json this should be refactored more at some point to prevent the manga object from being mutable
*/
private suspend fun restoreManga(obj: JsonObject, backupManager: BackupManager) {
val manga = backupManager.parser.fromJson<MangaImpl>(obj.get(MANGA))
val chapters = backupManager.parser.fromJson<List<ChapterImpl>>(obj.get(CHAPTERS) ?: JsonArray())
val categories = backupManager.parser.fromJson<List<String>>(obj.get(CATEGORIES) ?: JsonArray())
val history = backupManager.parser.fromJson<List<DHistory>>(obj.get(HISTORY) ?: JsonArray())
val tracks = backupManager.parser.fromJson<List<TrackImpl>>(obj.get(TRACK) ?: JsonArray())
val source = backupManager.sourceManager.getOrStub(manga.source)
try {
if (job?.isCancelled == false) {
showProgressNotification(restoreProgress, totalAmount, manga.title)
restoreProgress += 1
}
else {
throw java.lang.Exception("Job was cancelled")
}
val dbManga = backupManager.getMangaFromDatabase(manga)
val dbMangaExists = dbManga != null
if (dbMangaExists) {
// Manga in database copy information from manga already in database
backupManager.restoreMangaNoFetch(manga, dbManga!!)
} else {
// manga gets details from network
backupManager.restoreMangaFetch(source, manga)
}
if (!dbMangaExists || !backupManager.restoreChaptersForManga(manga, chapters)) {
//manga gets chapters added
backupManager.restoreChapterFetch(source, manga, chapters)
}
// Restore categories
backupManager.restoreCategoriesForManga(manga, categories)
// Restore history
backupManager.restoreHistoryForManga(history)
// Restore tracking
backupManager.restoreTrackForManga(manga, tracks)
trackingFetch(manga, tracks)
} catch (e: Exception) {
Timber.e(e)
if (e is RuntimeException) {
val cause = e.cause
if (cause is SourceNotFoundException) {
sourcesMissing.add(cause.id)
} }
.flatMap { Observable.from(it) } else if (e.message?.contains("licensed", true) == true) {
.concatMap { lincensedManga++
val obj = it.asJsonObject }
val manga = backupManager.parser.fromJson<MangaImpl>(obj.get(MANGA)) errors.add("${manga.title} - ${cause?.message ?: e.message}")
val chapters = backupManager.parser.fromJson<List<ChapterImpl>>(obj.get(CHAPTERS) ?: JsonArray()) return
val categories = backupManager.parser.fromJson<List<String>>(obj.get(CATEGORIES) ?: JsonArray()) }
val history = backupManager.parser.fromJson<List<DHistory>>(obj.get(HISTORY) ?: JsonArray()) errors.add("${manga.title} - ${e.message}")
val tracks = backupManager.parser.fromJson<List<TrackImpl>>(obj.get(TRACK) ?: JsonArray()) }
}
val observable = getMangaRestoreObservable(manga, chapters, categories, history, tracks) /**
if (observable != null) { * [refreshes tracking information
observable * @param manga manga that needs updating.
} else { * @param tracks list containing tracks from restore file.
errors.add(Date() to "${manga.title} - ${getString(R.string.source_not_found)}") */
restoreProgress += 1 private fun trackingFetch(manga: Manga, tracks: List<Track>) {
val content = getString(R.string.dialog_restoring_source_not_found, manga.title.chop(15)) tracks.forEach { track ->
showRestoreProgress(restoreProgress, restoreAmount, manga.title, errors.size, content) val service = trackManager.getService(track.sync_id)
Observable.just(manga) if (service != null && service.isLogged) {
} service.refresh(track)
} .doOnNext { db.insertTrack(it).executeAsBlocking() }
.toList() .onErrorReturn {
.doOnNext { errors.add("${manga.title} - ${it.message}")
val endTime = System.currentTimeMillis() track
val time = endTime - startTime }
val logFile = writeErrorLog() } else {
val completeIntent = Intent(BackupConst.INTENT_FILTER).apply { errors.add("${manga.title} - ${service?.name} not logged in")
putExtra(BackupConst.EXTRA_TIME, time) val notLoggedIn = getString(R.string.not_logged_into, service?.name)
putExtra(BackupConst.EXTRA_ERRORS, errors.size) trackingErrors.add(notLoggedIn)
putExtra(BackupConst.EXTRA_ERROR_FILE_PATH, logFile.parent) }
putExtra(BackupConst.EXTRA_ERROR_FILE, logFile.name) }
val sourceMissingCount = sourcesMissing.distinct().size
val sourceErrors = getString(R.string.sources_missing, sourceMissingCount)
val otherErrors = errorsMini.distinct().joinToString("\n")
putExtra(BackupConst.EXTRA_MINI_ERROR,
if (sourceMissingCount > 0) sourceErrors + "\n" + otherErrors
else otherErrors
)
putExtra(BackupConst.EXTRA_ERRORS, errors.size)
putExtra(BackupConst.ACTION, BackupConst.ACTION_RESTORE_COMPLETED_DIALOG)
}
sendLocalBroadcast(completeIntent)
}
.doOnError { error ->
Timber.e(error)
writeErrorLog()
val errorIntent = Intent(BackupConst.INTENT_FILTER).apply {
putExtra(BackupConst.ACTION, BackupConst.ACTION_ERROR_RESTORE_DIALOG)
putExtra(BackupConst.EXTRA_ERROR_MESSAGE, error.message)
}
sendLocalBroadcast(errorIntent)
}
.onErrorReturn { emptyList() }
} }
/** /**
@ -291,186 +320,149 @@ class BackupRestoreService : Service() {
val sdf = SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS", Locale.getDefault()) val sdf = SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS", Locale.getDefault())
destFile.bufferedWriter().use { out -> destFile.bufferedWriter().use { out ->
errors.forEach { (date, message) -> errors.forEach { message ->
out.write("[${sdf.format(date)}] $message\n") out.write("$message\n")
} }
} }
return destFile return destFile
} }
} catch (e: Exception) { } catch (e: Exception) {
// Empty Timber.e(e)
} }
return File("") return File("")
} }
/** /**
* Returns a manga restore observable * keep a partially constructed progress notification for resuse
*
* @param manga manga data from json
* @param chapters chapters data from json
* @param categories categories data from json
* @param history history data from json
* @param tracks tracking data from json
* @return [Observable] containing manga restore information
*/ */
private fun getMangaRestoreObservable(manga: Manga, chapters: List<Chapter>, private val progressNotification by lazy {
categories: List<String>, history: List<DHistory>, NotificationCompat.Builder(this, Notifications.CHANNEL_RESTORE)
tracks: List<Track>): Observable<Manga>? { .setContentTitle(getString(R.string.app_name))
// Get source .setSmallIcon(R.drawable.ic_tachi)
val source = backupManager.sourceManager.getOrStub(manga.source) .setOngoing(true)
val dbManga = backupManager.getMangaFromDatabase(manga) .setOnlyAlertOnce(true)
.setAutoCancel(false)
.setColor(ContextCompat.getColor(this, R.color.colorAccent))
.addAction(R.drawable.ic_clear_grey_24dp_img, getString(android.R.string.cancel), cancelIntent)
}
return if (dbManga == null) { /**
// Manga not in database * Pending intent of action that cancels the library update
mangaFetchObservable(source, manga, chapters, categories, history, tracks) */
} else { // Manga in database private val cancelIntent by lazy {
// Copy information from manga already in database NotificationReceiver.cancelRestorePendingBroadcast(this)
backupManager.restoreMangaNoFetch(manga, dbManga) }
// Fetch rest of manga information
mangaNoFetchObservable(source, manga, chapters, categories, history, tracks)
/**
* Shows the notification containing the currently updating manga and the progress.
*
* @param manga the manga that's being updated.
* @param current the current progress.
* @param total the total progress.
*/
private fun showProgressNotification(current: Int, total: Int, title: String) {
notificationManager.notify(Notifications.ID_RESTORE_PROGRESS, progressNotification
.setContentTitle(title)
.setProgress(total, current, false)
.build())
}
/**
* Show the result notification with option to show the error log
*/
private fun showResultNotification(path: String?, file: String?) {
val content = mutableListOf(getString(R.string.restore_completed_content, restoreProgress
.toString(), errors.size.toString()))
val sourceMissingCount = sourcesMissing.distinct().size
if (sourceMissingCount > 0)
content.add(getString(R.string.sources_missing, sourceMissingCount))
if (lincensedManga > 0)
content.add(getString(R.string.x_licensed_manga, lincensedManga))
val trackingErrors = trackingErrors.distinct()
if (trackingErrors.isNotEmpty()) {
val trackingErrorsString = trackingErrors.distinct().joinToString("\n")
content.add(trackingErrorsString)
}
if (cancelled > 0)
content.add(getString(R.string.restore_completed_content_2, cancelled))
val restoreString = content.joinToString("\n")
val resultNotification = NotificationCompat.Builder(this, Notifications.CHANNEL_RESTORE)
.setContentTitle(getString(R.string.restore_completed))
.setContentText(restoreString)
.setStyle(NotificationCompat.BigTextStyle().bigText(restoreString))
.setSmallIcon(R.drawable.ic_tachi)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setColor(ContextCompat.getColor(this, R.color.colorAccent))
if (errors.size > 0 && !path.isNullOrEmpty() && !file.isNullOrEmpty()) {
resultNotification.addAction(R.drawable.ic_clear_grey_24dp_img, getString(R.string
.notification_action_error_log), getErrorLogIntent(path, file))
}
notificationManager.notify(Notifications.ID_RESTORE_COMPLETE, resultNotification.build())
}
/**Show an error notification if something happens that prevents the restore from starting/working
*
*/
private fun showErrorNotification(errorMessage: String) {
val resultNotification = NotificationCompat.Builder(this, Notifications.CHANNEL_RESTORE)
.setContentTitle(getString(R.string.restore_error))
.setContentText(errorMessage)
.setSmallIcon(R.drawable.ic_error_grey)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setColor(ContextCompat.getColor(this, R.color.md_red_500))
notificationManager.notify(Notifications.ID_RESTORE_ERROR, resultNotification.build())
}
/**Get the PendingIntent for the error log
*
*/
private fun getErrorLogIntent(path: String, file: String): PendingIntent {
val destFile = File(path, file!!)
val uri = destFile.getUriCompat(applicationContext)
return NotificationReceiver.openFileExplorerPendingActivity(this@BackupRestoreService, uri)
}
companion object {
/**
* Returns the status of the service.
*
* @param context the application context.
* @return true if the service is running, false otherwise.
*/
private fun isRunning(context: Context): Boolean =
context.isServiceRunning(BackupRestoreService::class.java)
/**
* Starts a service to restore a backup from Json
*
* @param context context of application
* @param uri path of Uri
*/
fun start(context: Context, uri: Uri) {
if (!isRunning(context)) {
val intent = Intent(context, BackupRestoreService::class.java).apply {
putExtra(BackupConst.EXTRA_URI, uri)
}
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
context.startService(intent)
} else {
context.startForegroundService(intent)
}
}
}
/**
* Stops the service.
*
* @param context the application context.
*/
fun stop(context: Context) {
context.stopService(Intent(context, BackupRestoreService::class.java))
} }
} }
/**
* [Observable] that fetches manga information
*
* @param manga manga that needs updating
* @param chapters chapters of manga that needs updating
* @param categories categories that need updating
*/
private fun mangaFetchObservable(source: Source, manga: Manga, chapters: List<Chapter>,
categories: List<String>, history: List<DHistory>,
tracks: List<Track>): Observable<Manga> {
return backupManager.restoreMangaFetchObservable(source, manga)
.onErrorReturn {
errors.add(Date() to "${manga.title} - ${it.message}")
if (it is SourceNotFoundException) {
sourcesMissing.add(it.id)
}
else {
errorsMini.add(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)
// Convert to the manga that contains new chapters.
.map { manga }
}
.doOnCompleted {
restoreProgress += 1
showRestoreProgress(restoreProgress, restoreAmount, manga.title, errors.size)
}
}
private fun mangaNoFetchObservable(source: Source, backupManga: Manga, chapters: List<Chapter>,
categories: List<String>, history: List<DHistory>,
tracks: List<Track>): Observable<Manga> {
return Observable.just(backupManga)
.flatMap { manga ->
if (!backupManager.restoreChaptersForManga(manga, chapters)) {
chapterFetchObservable(source, manga, chapters)
.map { manga }
} else {
Observable.just(manga)
}
}
.doOnNext {
restoreExtraForManga(it, categories, history, tracks)
}
.flatMap { manga ->
trackingFetchObservable(manga, tracks)
// Convert to the manga that contains new chapters.
.map { manga }
}
.doOnCompleted {
restoreProgress += 1
showRestoreProgress(restoreProgress, restoreAmount, backupManga.title, errors.size)
}
}
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 {
errors.add(Date() to "${manga.title} - ${it.message}")
errorsMini.add(it.message ?: "")
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)
.concatMap { 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}")
errorsMini.add(it.message ?: "")
track
}
} else {
val notLoggedIn = getString(R.string.not_logged_into, service?.name)
errors.add(Date() to "${manga.title} - $notLoggedIn")
errorsMini.add(notLoggedIn)
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, errors: Int,
content: String = getString(R.string.dialog_restoring_backup, title.chop(15))) {
val intent = Intent(BackupConst.INTENT_FILTER).apply {
putExtra(BackupConst.EXTRA_PROGRESS, progress)
putExtra(BackupConst.EXTRA_AMOUNT, amount)
putExtra(BackupConst.EXTRA_CONTENT, content)
putExtra(BackupConst.EXTRA_ERRORS, errors)
putExtra(BackupConst.ACTION, BackupConst.ACTION_SET_PROGRESS_DIALOG)
}
sendLocalBroadcast(intent)
}
} }

View File

@ -212,6 +212,25 @@ class DownloadManager(val context: Context) {
} }
} }
/**
* Deletes the directories of chapters that were read or have no match
*
* @param chapters the list of chapters to delete.
* @param manga the manga of the chapters.
* @param source the source of the chapters.
*/
fun cleanupChapters(allChapters: List<Chapter>, manga: Manga, source: Source) {
val filesWithNoChapter = provider.findUnmatchedChapterDirs(allChapters, manga, source)
filesWithNoChapter.forEach { it.delete() }
val readChapters = allChapters.filter { it.read }
val readChapterDirs = provider.findChapterDirs(readChapters, manga, source)
readChapterDirs.forEach { it.delete() }
cache.removeChapters(readChapters, manga)
if (cache.getDownloadCount(manga) == 0) {
provider.findChapterDirs(allChapters, manga, source).firstOrNull()?.parentFile?.delete()// Delete manga directory if empty
}
}
/** /**
* Deletes the directory of a downloaded manga. * Deletes the directory of a downloaded manga.
* *

View File

@ -100,6 +100,26 @@ class DownloadProvider(private val context: Context) {
return chapters.flatMap { getValidChapterDirNames(it) }.mapNotNull { mangaDir.findFile(it) } return chapters.flatMap { getValidChapterDirNames(it) }.mapNotNull { mangaDir.findFile(it) }
} }
/**
* Returns a list of all files in manga directory
*
* @param chapters the chapters to query.
* @param manga the manga of the chapter.
* @param source the source of the chapter.
*/
fun findUnmatchedChapterDirs(chapters: List<Chapter>, manga: Manga, source: Source): List<UniFile> {
val mangaDir = findMangaDir(manga, source) ?: return emptyList()
return mangaDir.listFiles()!!.asList().filter {
chapters.find { chp ->
(getValidChapterDirNames(chp) + "${getChapterDirName(chp)}_tmp").any { dir ->
mangaDir.findFile(
dir
) != null
}
} == null
}
}
/** /**
* Returns a list of downloaded directories for the chapters that exist. * Returns a list of downloaded directories for the chapters that exist.
* *

View File

@ -37,6 +37,10 @@ import eu.kanade.tachiyomi.util.isServiceRunning
import eu.kanade.tachiyomi.util.notification import eu.kanade.tachiyomi.util.notification
import eu.kanade.tachiyomi.util.notificationManager import eu.kanade.tachiyomi.util.notificationManager
import eu.kanade.tachiyomi.util.syncChaptersWithSource import eu.kanade.tachiyomi.util.syncChaptersWithSource
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import rx.Observable import rx.Observable
import rx.Subscription import rx.Subscription
import rx.schedulers.Schedulers import rx.schedulers.Schedulers
@ -72,6 +76,9 @@ class LibraryUpdateService(
*/ */
private var subscription: Subscription? = null private var subscription: Subscription? = null
var job: Job? = null
/** /**
* Pending intent of action that cancels the library update * Pending intent of action that cancels the library update
*/ */
@ -105,7 +112,8 @@ class LibraryUpdateService(
enum class Target { enum class Target {
CHAPTERS, // Manga chapters CHAPTERS, // Manga chapters
DETAILS, // Manga metadata DETAILS, // Manga metadata
TRACKING // Tracking metadata TRACKING, // Tracking metadata
CLEANUP // Clean up downloads
} }
companion object { companion object {
@ -203,37 +211,43 @@ class LibraryUpdateService(
* @return the start value of the command. * @return the start value of the command.
*/ */
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
if (intent == null) return Service.START_NOT_STICKY if (intent == null) return START_NOT_STICKY
val target = intent.getSerializableExtra(KEY_TARGET) as? Target val target = intent.getSerializableExtra(KEY_TARGET) as? Target
?: return Service.START_NOT_STICKY ?: return START_NOT_STICKY
// Unsubscribe from any previous subscription if needed. // Unsubscribe from any previous subscription if needed.
subscription?.unsubscribe() subscription?.unsubscribe()
val selectedScheme = preferences.libraryUpdatePrioritization().getOrDefault()
// Update favorite manga. Destroy service when completed or in case of an error. // Update favorite manga. Destroy service when completed or in case of an error.
subscription = Observable val mangaList = getMangaToUpdate(intent, target)
.defer { .sortedWith(rankingScheme[selectedScheme])
val selectedScheme = preferences.libraryUpdatePrioritization().getOrDefault()
val mangaList = getMangaToUpdate(intent, target)
.sortedWith(rankingScheme[selectedScheme])
// Update either chapter list or manga details. val handler = CoroutineExceptionHandler { _, exception ->
when (target) { Timber.e(exception)
Target.CHAPTERS -> updateChapterList(mangaList) stopSelf(startId)
Target.DETAILS -> updateDetails(mangaList) }
Target.TRACKING -> updateTrackings(mangaList) // Update either chapter list or manga details.
} if (target == Target.CLEANUP) {
job = GlobalScope.launch(handler) {
cleanupDownloads()
}
job?.invokeOnCompletion { stopSelf(startId) }
} else {
subscription = Observable.defer {
when (target) {
Target.CHAPTERS -> updateChapterList(mangaList)
Target.DETAILS -> updateDetails(mangaList)
else -> updateTrackings(mangaList)
} }
.subscribeOn(Schedulers.io()) }.subscribeOn(Schedulers.io()).subscribe({}, {
.subscribe({
}, {
Timber.e(it) Timber.e(it)
stopSelf(startId) stopSelf(startId)
}, { }, {
stopSelf(startId) stopSelf(startId)
}) })
}
return Service.START_REDELIVER_INTENT return START_REDELIVER_INTENT
} }
/** /**
@ -336,6 +350,15 @@ class LibraryUpdateService(
.map { manga -> manga.first } .map { manga -> manga.first }
} }
private fun cleanupDownloads() {
val mangaList = db.getMangas().executeAsBlocking()
for (manga in mangaList) {
val chapterList = db.getChapters(manga).executeAsBlocking()
val source = sourceManager.getOrStub(manga.source)
downloadManager.cleanupChapters(chapterList, manga, source)
}
}
fun downloadChapters(manga: Manga, chapters: List<Chapter>) { fun downloadChapters(manga: Manga, chapters: List<Chapter>) {
// we need to get the chapters from the db so we have chapter ids // we need to get the chapters from the db so we have chapter ids
val mangaChapters = db.getChapters(manga).executeAsBlocking() val mangaChapters = db.getChapters(manga).executeAsBlocking()

View File

@ -7,9 +7,11 @@ import android.content.BroadcastReceiver
import android.content.ClipData import android.content.ClipData
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.net.Uri
import android.os.Build import android.os.Build
import android.os.Handler import android.os.Handler
import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.backup.BackupRestoreService
import eu.kanade.tachiyomi.data.database.DatabaseHelper import eu.kanade.tachiyomi.data.database.DatabaseHelper
import eu.kanade.tachiyomi.data.database.models.Chapter import eu.kanade.tachiyomi.data.database.models.Chapter
import eu.kanade.tachiyomi.data.database.models.Manga import eu.kanade.tachiyomi.data.database.models.Manga
@ -31,6 +33,7 @@ import uy.kohesive.injekt.injectLazy
import java.io.File import java.io.File
import eu.kanade.tachiyomi.BuildConfig.APPLICATION_ID as ID import eu.kanade.tachiyomi.BuildConfig.APPLICATION_ID as ID
/** /**
* Global [BroadcastReceiver] that runs on UI thread * Global [BroadcastReceiver] that runs on UI thread
* Pending Broadcasts should be made from here. * Pending Broadcasts should be made from here.
@ -65,6 +68,7 @@ class NotificationReceiver : BroadcastReceiver() {
intent.getIntExtra(EXTRA_NOTIFICATION_ID, -1)) intent.getIntExtra(EXTRA_NOTIFICATION_ID, -1))
// Cancel library update and dismiss notification // Cancel library update and dismiss notification
ACTION_CANCEL_LIBRARY_UPDATE -> cancelLibraryUpdate(context) ACTION_CANCEL_LIBRARY_UPDATE -> cancelLibraryUpdate(context)
ACTION_CANCEL_RESTORE -> cancelRestoreUpdate(context)
// Open reader activity // Open reader activity
ACTION_OPEN_CHAPTER -> { ACTION_OPEN_CHAPTER -> {
openChapter(context, intent.getLongExtra(EXTRA_MANGA_ID, -1), openChapter(context, intent.getLongExtra(EXTRA_MANGA_ID, -1),
@ -164,7 +168,7 @@ class NotificationReceiver : BroadcastReceiver() {
} }
/** /**
* Method called when user wants to stop a library update * Method called when user wants to mark as read
* *
* @param context context of application * @param context context of application
* @param notificationId id of notification * @param notificationId id of notification
@ -185,6 +189,16 @@ class NotificationReceiver : BroadcastReceiver() {
} }
} }
/* Method called when user wants to stop a restore
*
* @param context context of application
* @param notificationId id of notification
*/
private fun cancelRestoreUpdate(context: Context) {
BackupRestoreService.stop(context)
Handler().post { dismissNotification(context, Notifications.ID_RESTORE_PROGRESS) }
}
companion object { companion object {
private const val NAME = "NotificationReceiver" private const val NAME = "NotificationReceiver"
@ -197,9 +211,13 @@ class NotificationReceiver : BroadcastReceiver() {
// Called to cancel library update. // Called to cancel library update.
private const val ACTION_CANCEL_LIBRARY_UPDATE = "$ID.$NAME.CANCEL_LIBRARY_UPDATE" private const val ACTION_CANCEL_LIBRARY_UPDATE = "$ID.$NAME.CANCEL_LIBRARY_UPDATE"
// Called to cancel library update. // Called to mark as read
private const val ACTION_MARK_AS_READ = "$ID.$NAME.MARK_AS_READ" private const val ACTION_MARK_AS_READ = "$ID.$NAME.MARK_AS_READ"
// Called to cancel restore
private const val ACTION_CANCEL_RESTORE = "$ID.$NAME.CANCEL_RESTORE"
// Called to open chapter // Called to open chapter
private const val ACTION_OPEN_CHAPTER = "$ID.$NAME.ACTION_OPEN_CHAPTER" private const val ACTION_OPEN_CHAPTER = "$ID.$NAME.ACTION_OPEN_CHAPTER"
@ -410,6 +428,19 @@ class NotificationReceiver : BroadcastReceiver() {
) )
} }
/**Returns the PendingIntent that will open the error log in an external text viewer
*
*/
internal fun openFileExplorerPendingActivity(context: Context, uri: Uri): PendingIntent {
val toLaunch = Intent(Intent.ACTION_VIEW).apply {
setDataAndType(uri, "text/plain")
flags = Intent.FLAG_ACTIVITY_NEW_TASK or
Intent.FLAG_GRANT_READ_URI_PERMISSION
}
return PendingIntent.getActivity(context, 0, toLaunch, 0)
}
/** /**
* Returns [PendingIntent] that marks a chapter as read and deletes it if preferred * Returns [PendingIntent] that marks a chapter as read and deletes it if preferred
* *
@ -441,5 +472,18 @@ class NotificationReceiver : BroadcastReceiver() {
} }
return PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT) return PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT)
} }
/**
* Returns [PendingIntent] that starts a service which stops the restore service
*
* @param context context of application
* @return [PendingIntent]
*/
internal fun cancelRestorePendingBroadcast(context: Context): PendingIntent {
val intent = Intent(context, NotificationReceiver::class.java).apply {
action = ACTION_CANCEL_RESTORE
}
return PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT)
}
} }
} }

View File

@ -44,6 +44,12 @@ object Notifications {
const val CHANNEL_UPDATES_TO_EXTS = "updates_ext_channel" const val CHANNEL_UPDATES_TO_EXTS = "updates_ext_channel"
const val ID_UPDATES_TO_EXTS = -401 const val ID_UPDATES_TO_EXTS = -401
const val CHANNEL_RESTORE = "backup_restore_channel"
const val ID_RESTORE_PROGRESS = -501
const val ID_RESTORE_COMPLETE = -502
const val ID_RESTORE_ERROR = -503
/** /**
* Creates the notification channels introduced in Android Oreo. * Creates the notification channels introduced in Android Oreo.
* *
@ -77,7 +83,10 @@ object Notifications {
CHANNEL_NEW_CHAPTERS, CHANNEL_NEW_CHAPTERS,
context.getString(R.string.channel_new_chapters), context.getString(R.string.channel_new_chapters),
NotificationManager.IMPORTANCE_DEFAULT NotificationManager.IMPORTANCE_DEFAULT
) ), NotificationChannel(CHANNEL_RESTORE, context.getString(R.string.channel_backup_restore),
NotificationManager.IMPORTANCE_LOW).apply {
setShowBadge(false)
}
) )
context.notificationManager.createNotificationChannels(channels) context.notificationManager.createNotificationChannels(channels)
} }

View File

@ -176,8 +176,7 @@ class ChaptersPresenter(
var observable = Observable.from(chapters).subscribeOn(Schedulers.io()) var observable = Observable.from(chapters).subscribeOn(Schedulers.io())
if (onlyUnread()) { if (onlyUnread()) {
observable = observable.filter { !it.read } observable = observable.filter { !it.read }
} } else if (onlyRead()) {
else if (onlyRead()) {
observable = observable.filter { it.read } observable = observable.filter { it.read }
} }
if (onlyDownloaded()) { if (onlyDownloaded()) {

View File

@ -126,7 +126,7 @@ class MangaInfoPresenter(
toggleFavorite() toggleFavorite()
} }
fun shareManga(cover:Bitmap) { fun shareManga(cover: Bitmap) {
val context = Injekt.get<Application>() val context = Injekt.get<Application>()
val destDir = File(context.cacheDir, "shared_image") val destDir = File(context.cacheDir, "shared_image")
@ -149,7 +149,7 @@ class MangaInfoPresenter(
val destFile = File(directory, filename) val destFile = File(directory, filename)
val stream: OutputStream = FileOutputStream(destFile) val stream: OutputStream = FileOutputStream(destFile)
cover.compress(Bitmap.CompressFormat.JPEG,75,stream) cover.compress(Bitmap.CompressFormat.JPEG, 75, stream)
stream.flush() stream.flush()
stream.close() stream.close()
return destFile return destFile

View File

@ -69,6 +69,13 @@ class SettingsAdvancedController : SettingsController() {
onClick { LibraryUpdateService.start(context, target = Target.TRACKING) } onClick { LibraryUpdateService.start(context, target = Target.TRACKING) }
} }
preference {
titleRes = R.string.pref_clean_downloads
summaryRes = R.string.pref_clean_downloads_summary
onClick { LibraryUpdateService.start(context, target = Target.CLEANUP) }
}
} }
private fun clearChapterCache() { private fun clearChapterCache() {

View File

@ -288,7 +288,7 @@ class SettingsBackupController : SettingsController() {
.onPositive { _, _ -> .onPositive { _, _ ->
val context = applicationContext val context = applicationContext
if (context != null) { if (context != null) {
RestoringBackupDialog().showDialog(router, TAG_RESTORING_BACKUP_DIALOG) activity?.toast(R.string.restoring_backup)
BackupRestoreService.start(context, args.getParcelable(KEY_URI)!!) BackupRestoreService.start(context, args.getParcelable(KEY_URI)!!)
} }
} }

View File

@ -0,0 +1,5 @@
<vector android:height="24dp" android:tint="#898989"
android:viewportHeight="24.0" android:viewportWidth="24.0"
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="#FF000000" android:pathData="M12,2C6.48,2 2,6.48 2,12s4.48,10 10,10 10,-4.48 10,-10S17.52,2 12,2zM13,17h-2v-2h2v2zM13,13h-2L11,7h2v6z"/>
</vector>

View File

@ -234,7 +234,7 @@
<string name="restore_completed">S\'ha completat la restauració</string> <string name="restore_completed">S\'ha completat la restauració</string>
<string name="error_opening_log">No s\'ha pogut obrir el registre</string> <string name="error_opening_log">No s\'ha pogut obrir el registre</string>
<string name="restore_completed_content">La restauració ha tardat %1$s. <string name="restore_completed_content">La restauració ha tardat %1$s.
\nS\'han produït $2$s errors.</string> \nS\'han produït %2$s errors.</string>
<string name="backup_restore_content">La restauració obté dades de les fonts, poden haver-hi despeses de l\'operador. <string name="backup_restore_content">La restauració obté dades de les fonts, poden haver-hi despeses de l\'operador.
\nAssegureu-vos que heu iniciat la sessió a les fonts en què cal abans de restaurar.</string> \nAssegureu-vos que heu iniciat la sessió a les fonts en què cal abans de restaurar.</string>
<string name="file_saved">S\'ha desat el fitxer a %1$s</string> <string name="file_saved">S\'ha desat el fitxer a %1$s</string>

View File

@ -306,13 +306,18 @@
<string name="dialog_restoring_source_not_found">Restoring backup\n%1$s source not found</string> <string name="dialog_restoring_source_not_found">Restoring backup\n%1$s source not found</string>
<string name="backup_created">Backup created</string> <string name="backup_created">Backup created</string>
<string name="restore_completed">Restore completed</string> <string name="restore_completed">Restore completed</string>
<string name="restore_error">Restore error</string>
<string name="error_opening_log">Could not open log</string> <string name="error_opening_log">Could not open log</string>
<string name="restore_completed_content">Restore took %1$s.\n%2$s errors found.</string> <string name="restore_completed_content">Restored %1$s. %2$s errors found</string>
<string name="backup_restore_content">Restore uses source to fetch data, carrier costs may apply.\n\nMake sure you have installed all necessary extensions and are logged in to sources and tracking services before restoring.</string> <string name="restore_completed_content_2">%1$d skipped</string>
<string name="backup_restore_content">Restore uses the network to fetch data, carrier costs may apply.\n\nMake sure you have installed all necessary extensions and are logged in to sources and tracking services before restoring.</string>
<string name="file_saved">File saved at %1$s</string> <string name="file_saved">File saved at %1$s</string>
<string name="backup_choice">What do you want to backup?</string> <string name="backup_choice">What do you want to backup?</string>
<string name="restoring_backup">Restoring backup</string> <string name="restoring_backup">Restoring backup</string>
<string name="creating_backup">Creating backup</string> <string name="creating_backup">Creating backup</string>
<string name="sources_missing">Sources missing: %1$d</string>
<string name="x_licensed_manga">%1$d manga are now licensed and could not be restored</string>
<string name="not_logged_into">Not logged into %1$s</string>
<!-- Advanced section --> <!-- Advanced section -->
<string name="pref_clear_chapter_cache">Clear chapter cache</string> <string name="pref_clear_chapter_cache">Clear chapter cache</string>
@ -330,6 +335,8 @@
<string name="pref_refresh_library_metadata_summary">Updates covers, genres, description and manga status information</string> <string name="pref_refresh_library_metadata_summary">Updates covers, genres, description and manga status information</string>
<string name="pref_refresh_library_tracking">Refresh tracking metadata</string> <string name="pref_refresh_library_tracking">Refresh tracking metadata</string>
<string name="pref_refresh_library_tracking_summary">Updates status, score and last chapter read from the tracking services</string> <string name="pref_refresh_library_tracking_summary">Updates status, score and last chapter read from the tracking services</string>
<string name="pref_clean_downloads">Clean up downloaded chapters</string>
<string name="pref_clean_downloads_summary">Deletes orphaned,tmp, and read chapter folders for entire library for each Manga</string>
<!-- About section --> <!-- About section -->
<string name="version">Version</string> <string name="version">Version</string>
@ -406,8 +413,6 @@
<string name="delete_downloads_for_manga">Delete downloaded chapters?</string> <string name="delete_downloads_for_manga">Delete downloaded chapters?</string>
<string name="copied_to_clipboard">%1$s copied to clipboard</string> <string name="copied_to_clipboard">%1$s copied to clipboard</string>
<string name="source_not_installed">Source not installed: %1$s</string> <string name="source_not_installed">Source not installed: %1$s</string>
<string name="sources_missing">Sources missing: %1$d</string>
<string name="not_logged_into">Not logged into %1$s</string>
<!-- Manga chapters fragment --> <!-- Manga chapters fragment -->
<string name="manga_chapters_tab">Chapters</string> <string name="manga_chapters_tab">Chapters</string>
@ -534,6 +539,8 @@
<string name="notification_no_connection_title">Sync canceled</string> <string name="notification_no_connection_title">Sync canceled</string>
<string name="notification_no_connection_body">Connection not available</string> <string name="notification_no_connection_body">Connection not available</string>
<string name="notification_action_error_log">View all errors</string>
<!-- File Picker Titles --> <!-- File Picker Titles -->
<string name="file_select_cover">Select cover image</string> <string name="file_select_cover">Select cover image</string>
<string name="file_select_backup">Select backup file</string> <string name="file_select_backup">Select backup file</string>
@ -581,6 +588,7 @@
<string name="channel_library">Library</string> <string name="channel_library">Library</string>
<string name="channel_downloader">Downloader</string> <string name="channel_downloader">Downloader</string>
<string name="channel_ext_updates">Extension Updates</string> <string name="channel_ext_updates">Extension Updates</string>
<string name="channel_backup_restore">Restore Backup</string>
<string name="channel_library_updates">Updating Library</string> <string name="channel_library_updates">Updating Library</string>
<string name="channel_new_chapters">New Chapters</string> <string name="channel_new_chapters">New Chapters</string>

View File

@ -14,7 +14,10 @@ import eu.kanade.tachiyomi.data.database.DatabaseHelper
import eu.kanade.tachiyomi.data.database.models.* import eu.kanade.tachiyomi.data.database.models.*
import eu.kanade.tachiyomi.source.SourceManager import eu.kanade.tachiyomi.source.SourceManager
import eu.kanade.tachiyomi.source.online.HttpSource import eu.kanade.tachiyomi.source.online.HttpSource
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import org.assertj.core.api.Assertions.assertThat import org.assertj.core.api.Assertions.assertThat
import org.assertj.core.api.Assertions.fail
import org.junit.Before import org.junit.Before
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith import org.junit.runner.RunWith
@ -23,7 +26,6 @@ import org.mockito.Mockito.*
import org.robolectric.RuntimeEnvironment import org.robolectric.RuntimeEnvironment
import org.robolectric.annotation.Config import org.robolectric.annotation.Config
import rx.Observable import rx.Observable
import rx.observers.TestSubscriber
import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.InjektModule import uy.kohesive.injekt.api.InjektModule
import uy.kohesive.injekt.api.InjektRegistrar import uy.kohesive.injekt.api.InjektRegistrar
@ -166,7 +168,7 @@ class BackupTest {
assertThat(favoriteManga[0].viewer).isEqualTo(3) assertThat(favoriteManga[0].viewer).isEqualTo(3)
// Update json with all options enabled // Update json with all options enabled
mangaEntries.add(backupManager.backupMangaObject(manga,1)) mangaEntries.add(backupManager.backupMangaObject(manga, 1))
// Change manga in database to default values // Change manga in database to default values
val dbManga = getSingleManga("One Piece") val dbManga = getSingleManga("One Piece")
@ -178,7 +180,7 @@ class BackupTest {
assertThat(favoriteManga[0].viewer).isEqualTo(0) assertThat(favoriteManga[0].viewer).isEqualTo(0)
// Restore local manga // Restore local manga
backupManager.restoreMangaNoFetch(manga,dbManga) backupManager.restoreMangaNoFetch(manga, dbManga)
// Test if restore successful // Test if restore successful
favoriteManga = backupManager.databaseHelper.getFavoriteMangas().executeAsBlocking() favoriteManga = backupManager.databaseHelper.getFavoriteMangas().executeAsBlocking()
@ -201,13 +203,16 @@ class BackupTest {
// Restore manga with fetch observable // Restore manga with fetch observable
val networkManga = getSingleManga("One Piece") val networkManga = getSingleManga("One Piece")
networkManga.description = "This is a description" networkManga.description = "This is a description"
`when`(source.fetchMangaDetails(jsonManga)).thenReturn(Observable.just(networkManga)) `when`(source.fetchMangaDetailsObservable(jsonManga)).thenReturn(Observable.just(networkManga))
val obs = backupManager.restoreMangaFetchObservable(source, jsonManga) GlobalScope.launch {
val testSubscriber = TestSubscriber<Manga>() try {
obs.subscribe(testSubscriber) backupManager.restoreMangaFetch(source, jsonManga)
} catch (e: Exception) {
fail("Unexpected onError events")
}
}
testSubscriber.assertNoErrors()
// Check if restore successful // Check if restore successful
val dbCats = backupManager.databaseHelper.getFavoriteMangas().executeAsBlocking() val dbCats = backupManager.databaseHelper.getFavoriteMangas().executeAsBlocking()
@ -231,7 +236,7 @@ class BackupTest {
// Create restore list // Create restore list
val chapters = ArrayList<Chapter>() val chapters = ArrayList<Chapter>()
for (i in 1..8){ for (i in 1..8) {
val chapter = getSingleChapter("Chapter $i") val chapter = getSingleChapter("Chapter $i")
chapter.read = true chapter.read = true
chapters.add(chapter) chapters.add(chapter)
@ -245,14 +250,16 @@ class BackupTest {
// Create list // Create list
val chaptersRemote = ArrayList<Chapter>() val chaptersRemote = ArrayList<Chapter>()
(1..10).mapTo(chaptersRemote) { getSingleChapter("Chapter $it") } (1..10).mapTo(chaptersRemote) { getSingleChapter("Chapter $it") }
`when`(source.fetchChapterList(manga)).thenReturn(Observable.just(chaptersRemote)) `when`(source.fetchChapterListObservable(manga)).thenReturn(Observable.just(chaptersRemote))
// Call restoreChapterFetchObservable // Call restoreChapterFetchObservable
val obs = backupManager.restoreChapterFetchObservable(source, manga, restoredChapters) GlobalScope.launch {
val testSubscriber = TestSubscriber<Pair<List<Chapter>, List<Chapter>>>() try {
obs.subscribe(testSubscriber) backupManager.restoreChapterFetch(source, manga, restoredChapters)
} catch (e: Exception) {
testSubscriber.assertNoErrors() fail("Unexpected onError events")
}
}
val dbCats = backupManager.databaseHelper.getChapters(manga).executeAsBlocking() val dbCats = backupManager.databaseHelper.getChapters(manga).executeAsBlocking()
assertThat(dbCats).hasSize(10) assertThat(dbCats).hasSize(10)
@ -263,7 +270,7 @@ class BackupTest {
* Test to check if history restore works * Test to check if history restore works
*/ */
@Test @Test
fun restoreHistoryForManga(){ fun restoreHistoryForManga() {
// Initialize json with version 2 // Initialize json with version 2
initializeJsonTest(2) initializeJsonTest(2)
@ -376,7 +383,7 @@ class BackupTest {
return category return category
} }
fun clearDatabase(){ fun clearDatabase() {
db.deleteMangas().executeAsBlocking() db.deleteMangas().executeAsBlocking()
db.deleteHistory().executeAsBlocking() db.deleteHistory().executeAsBlocking()
} }