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

View File

@ -1,13 +1,18 @@
package eu.kanade.tachiyomi.data.backup
import android.app.PendingIntent
import android.app.Service
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.os.IBinder
import android.os.PowerManager
import androidx.core.app.NotificationCompat
import androidx.core.content.ContextCompat
import com.github.salomonbrys.kotson.fromJson
import com.google.gson.JsonArray
import com.google.gson.JsonObject
import com.google.gson.JsonParser
import com.google.gson.stream.JsonReader
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.database.DatabaseHelper
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.source.Source
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.sendLocalBroadcast
import rx.Observable
import rx.Subscription
import rx.schedulers.Schedulers
import eu.kanade.tachiyomi.util.notificationManager
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
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
import java.util.concurrent.ExecutorService
import java.util.concurrent.Executors
/**
* Restores backup from json file
*/
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.
@ -88,27 +57,29 @@ class BackupRestoreService : Service() {
/**
* Subscription where the update is done.
*/
private var subscription: Subscription? = null
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
private var totalAmount = 0
/**
* 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
*/
private val errorsMini = mutableListOf<String>()
private val trackingErrors = mutableListOf<String>()
/**
@ -116,6 +87,11 @@ class BackupRestoreService : Service() {
*/
private val sourcesMissing = mutableListOf<Long>()
/**
* List containing missing sources
*/
private var lincensedManga = 0
/**
* Backup manager
*/
@ -132,17 +108,15 @@ class BackupRestoreService : Service() {
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.
*/
override fun onCreate() {
super.onCreate()
startForeground(Notifications.ID_RESTORE_PROGRESS, progressNotification.build())
wakeLock = (getSystemService(Context.POWER_SERVICE) as PowerManager).newWakeLock(
PowerManager.PARTIAL_WAKE_LOCK, "BackupRestoreService:WakeLock")
wakeLock.acquire()
executor = Executors.newSingleThreadExecutor()
wakeLock.acquire(10 * 60 * 1000L /*10 minutes*/)
}
/**
@ -150,8 +124,7 @@ class BackupRestoreService : Service() {
* releases the wake lock.
*/
override fun onDestroy() {
subscription?.unsubscribe()
executor.shutdown() // must be called after unsubscribe
job?.cancel()
if (wakeLock.isHeld) {
wakeLock.release()
}
@ -177,30 +150,32 @@ class BackupRestoreService : Service() {
val uri = intent.getParcelableExtra<Uri>(BackupConst.EXTRA_URI) ?: return START_NOT_STICKY
// Unsubscribe from any previous subscription if needed.
subscription?.unsubscribe()
subscription = Observable.using(
{ db.lowLevel().beginTransaction() },
{ getRestoreObservable(uri).doOnNext { db.lowLevel().setTransactionSuccessful() } },
{ executor.execute { db.lowLevel().endTransaction() } })
.doAfterTerminate { stopSelf(startId) }
.subscribeOn(Schedulers.from(executor))
.subscribe()
job?.cancel()
val handler = CoroutineExceptionHandler { _, exception ->
Timber.e(exception)
showErrorNotification(exception.message!!)
stopSelf(startId)
}
job = GlobalScope.launch(handler) {
restoreBackup(uri!!)
}
job?.invokeOnCompletion { stopSelf(startId) }
return START_NOT_STICKY
}
/**
* Returns an [Observable] containing restore process.
*
* @param uri restore file
* @return [Observable<Manga>]
*/
private fun getRestoreObservable(uri: Uri): Observable<List<Manga>> {
val startTime = System.currentTimeMillis()
override fun stopService(name: Intent?): Boolean {
job?.cancel()
if (wakeLock.isHeld) {
wakeLock.release()
}
return super.stopService(name)
}
return Observable.just(Unit)
.map {
/**
* Restore a backup json file
*/
private suspend fun restoreBackup(uri: Uri) {
val reader = JsonReader(contentResolver.openInputStream(uri)!!.bufferedReader())
val json = JsonParser().parse(reader).asJsonObject
@ -212,73 +187,127 @@ class BackupRestoreService : Service() {
val mangasJson = json.get(MANGAS).asJsonArray
restoreAmount = mangasJson.size() + 1 // +1 for categories
restoreProgress = 0
// +1 for categories
totalAmount = mangasJson.size() + 1
trackingErrors.clear()
sourcesMissing.clear()
lincensedManga = 0
errors.clear()
errorsMini.clear()
cancelled = 0
// Restore categories
json.get(CATEGORIES)?.let {
backupManager.restoreCategories(it.asJsonArray)
restoreProgress += 1
showRestoreProgress(restoreProgress, restoreAmount, "Categories added", errors.size)
restoreCategories(json, backupManager)
mangasJson.forEach {
restoreManga(it.asJsonObject, backupManager)
}
mangasJson
notificationManager.cancel(Notifications.ID_RESTORE_PROGRESS)
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)
}
.flatMap { Observable.from(it) }
.concatMap {
val obj = it.asJsonObject
/**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)
val observable = getMangaRestoreObservable(manga, chapters, categories, history, tracks)
if (observable != null) {
observable
} else {
errors.add(Date() to "${manga.title} - ${getString(R.string.source_not_found)}")
try {
if (job?.isCancelled == false) {
showProgressNotification(restoreProgress, totalAmount, manga.title)
restoreProgress += 1
val content = getString(R.string.dialog_restoring_source_not_found, manga.title.chop(15))
showRestoreProgress(restoreProgress, restoreAmount, manga.title, errors.size, content)
Observable.just(manga)
}
else {
throw java.lang.Exception("Job was cancelled")
}
.toList()
.doOnNext {
val endTime = System.currentTimeMillis()
val time = endTime - startTime
val logFile = writeErrorLog()
val completeIntent = Intent(BackupConst.INTENT_FILTER).apply {
putExtra(BackupConst.EXTRA_TIME, time)
putExtra(BackupConst.EXTRA_ERRORS, errors.size)
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)
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)
}
.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)
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)
}
else if (e.message?.contains("licensed", true) == true) {
lincensedManga++
}
errors.add("${manga.title} - ${cause?.message ?: e.message}")
return
}
errors.add("${manga.title} - ${e.message}")
}
}
/**
* [refreshes tracking information
* @param manga manga that needs updating.
* @param tracks list containing tracks from restore file.
*/
private fun trackingFetch(manga: Manga, tracks: List<Track>) {
tracks.forEach { track ->
val service = trackManager.getService(track.sync_id)
if (service != null && service.isLogged) {
service.refresh(track)
.doOnNext { db.insertTrack(it).executeAsBlocking() }
.onErrorReturn {
errors.add("${manga.title} - ${it.message}")
track
}
} else {
errors.add("${manga.title} - ${service?.name} not logged in")
val notLoggedIn = getString(R.string.not_logged_into, service?.name)
trackingErrors.add(notLoggedIn)
}
sendLocalBroadcast(errorIntent)
}
.onErrorReturn { emptyList() }
}
/**
@ -291,186 +320,149 @@ class BackupRestoreService : Service() {
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")
errors.forEach { message ->
out.write("$message\n")
}
}
return destFile
}
} catch (e: Exception) {
// Empty
Timber.e(e)
}
return File("")
}
/**
* Returns a manga restore observable
*
* @param manga manga data from json
* @param chapters chapters data from json
* @param categories categories data from json
* @param history history data from json
* @param tracks tracking data from json
* @return [Observable] containing manga restore information
* keep a partially constructed progress notification for resuse
*/
private fun getMangaRestoreObservable(manga: Manga, chapters: List<Chapter>,
categories: List<String>, history: List<DHistory>,
tracks: List<Track>): Observable<Manga>? {
// Get source
val source = backupManager.sourceManager.getOrStub(manga.source)
val dbManga = backupManager.getMangaFromDatabase(manga)
return if (dbManga == null) {
// Manga not in database
mangaFetchObservable(source, manga, chapters, categories, history, tracks)
} else { // Manga in database
// Copy information from manga already in database
backupManager.restoreMangaNoFetch(manga, dbManga)
// Fetch rest of manga information
mangaNoFetchObservable(source, manga, chapters, categories, history, tracks)
}
private val progressNotification by lazy {
NotificationCompat.Builder(this, Notifications.CHANNEL_RESTORE)
.setContentTitle(getString(R.string.app_name))
.setSmallIcon(R.drawable.ic_tachi)
.setOngoing(true)
.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)
}
/**
* [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
* Pending intent of action that cancels the library update
*/
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 val cancelIntent by lazy {
NotificationReceiver.cancelRestorePendingBroadcast(this)
}
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 }
/**
* 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 {
Observable.just(manga)
context.startForegroundService(intent)
}
}
.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
* Stops the service.
*
* @param source source of manga
* @param manga manga that needs updating
* @return [Observable] that contains manga
* @param context the application context.
*/
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()
fun stop(context: Context) {
context.stopService(Intent(context, BackupRestoreService::class.java))
}
}
}
/**
* 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.
*

View File

@ -100,6 +100,26 @@ class DownloadProvider(private val context: Context) {
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.
*

View File

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

View File

@ -7,9 +7,11 @@ import android.content.BroadcastReceiver
import android.content.ClipData
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.os.Handler
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.models.Chapter
import eu.kanade.tachiyomi.data.database.models.Manga
@ -31,6 +33,7 @@ import uy.kohesive.injekt.injectLazy
import java.io.File
import eu.kanade.tachiyomi.BuildConfig.APPLICATION_ID as ID
/**
* Global [BroadcastReceiver] that runs on UI thread
* Pending Broadcasts should be made from here.
@ -65,6 +68,7 @@ class NotificationReceiver : BroadcastReceiver() {
intent.getIntExtra(EXTRA_NOTIFICATION_ID, -1))
// Cancel library update and dismiss notification
ACTION_CANCEL_LIBRARY_UPDATE -> cancelLibraryUpdate(context)
ACTION_CANCEL_RESTORE -> cancelRestoreUpdate(context)
// Open reader activity
ACTION_OPEN_CHAPTER -> {
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 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 {
private const val NAME = "NotificationReceiver"
@ -197,9 +211,13 @@ class NotificationReceiver : BroadcastReceiver() {
// Called to 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"
// Called to cancel restore
private const val ACTION_CANCEL_RESTORE = "$ID.$NAME.CANCEL_RESTORE"
// Called to 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
*
@ -441,5 +472,18 @@ class NotificationReceiver : BroadcastReceiver() {
}
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 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.
*
@ -77,7 +83,10 @@ object Notifications {
CHANNEL_NEW_CHAPTERS,
context.getString(R.string.channel_new_chapters),
NotificationManager.IMPORTANCE_DEFAULT
)
), NotificationChannel(CHANNEL_RESTORE, context.getString(R.string.channel_backup_restore),
NotificationManager.IMPORTANCE_LOW).apply {
setShowBadge(false)
}
)
context.notificationManager.createNotificationChannels(channels)
}

View File

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

View File

@ -69,6 +69,13 @@ class SettingsAdvancedController : SettingsController() {
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() {

View File

@ -288,7 +288,7 @@ class SettingsBackupController : SettingsController() {
.onPositive { _, _ ->
val context = applicationContext
if (context != null) {
RestoringBackupDialog().showDialog(router, TAG_RESTORING_BACKUP_DIALOG)
activity?.toast(R.string.restoring_backup)
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="error_opening_log">No s\'ha pogut obrir el registre</string>
<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.
\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>

View File

@ -306,13 +306,18 @@
<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="restore_completed">Restore completed</string>
<string name="restore_error">Restore error</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="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">Restored %1$s. %2$s errors found</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="backup_choice">What do you want to backup?</string>
<string name="restoring_backup">Restoring 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 -->
<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_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_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 -->
<string name="version">Version</string>
@ -406,8 +413,6 @@
<string name="delete_downloads_for_manga">Delete downloaded chapters?</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="sources_missing">Sources missing: %1$d</string>
<string name="not_logged_into">Not logged into %1$s</string>
<!-- Manga chapters fragment -->
<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_body">Connection not available</string>
<string name="notification_action_error_log">View all errors</string>
<!-- File Picker Titles -->
<string name="file_select_cover">Select cover image</string>
<string name="file_select_backup">Select backup file</string>
@ -581,6 +588,7 @@
<string name="channel_library">Library</string>
<string name="channel_downloader">Downloader</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_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.source.SourceManager
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.fail
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
@ -23,7 +26,6 @@ import org.mockito.Mockito.*
import org.robolectric.RuntimeEnvironment
import org.robolectric.annotation.Config
import rx.Observable
import rx.observers.TestSubscriber
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.InjektModule
import uy.kohesive.injekt.api.InjektRegistrar
@ -201,13 +203,16 @@ class BackupTest {
// Restore manga with fetch observable
val networkManga = getSingleManga("One Piece")
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)
val testSubscriber = TestSubscriber<Manga>()
obs.subscribe(testSubscriber)
GlobalScope.launch {
try {
backupManager.restoreMangaFetch(source, jsonManga)
} catch (e: Exception) {
fail("Unexpected onError events")
}
}
testSubscriber.assertNoErrors()
// Check if restore successful
val dbCats = backupManager.databaseHelper.getFavoriteMangas().executeAsBlocking()
@ -245,14 +250,16 @@ class BackupTest {
// Create list
val chaptersRemote = ArrayList<Chapter>()
(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
val obs = backupManager.restoreChapterFetchObservable(source, manga, restoredChapters)
val testSubscriber = TestSubscriber<Pair<List<Chapter>, List<Chapter>>>()
obs.subscribe(testSubscriber)
testSubscriber.assertNoErrors()
GlobalScope.launch {
try {
backupManager.restoreChapterFetch(source, manga, restoredChapters)
} catch (e: Exception) {
fail("Unexpected onError events")
}
}
val dbCats = backupManager.databaseHelper.getChapters(manga).executeAsBlocking()
assertThat(dbCats).hasSize(10)