Consider individual manga as transactions rather than entire restore job (closes #2482)

This commit is contained in:
arkon 2020-04-25 16:39:00 -04:00
parent 0f48563e29
commit 96c55db7ca
5 changed files with 154 additions and 167 deletions

View file

@ -10,6 +10,7 @@ import android.os.PowerManager
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.JsonElement import com.google.gson.JsonElement
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
@ -32,18 +33,17 @@ 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.Source
import eu.kanade.tachiyomi.ui.setting.backup.BackupNotifier import eu.kanade.tachiyomi.ui.setting.backup.BackupNotifier
import eu.kanade.tachiyomi.util.lang.chop
import eu.kanade.tachiyomi.util.system.isServiceRunning import eu.kanade.tachiyomi.util.system.isServiceRunning
import eu.kanade.tachiyomi.util.system.sendLocalBroadcast import eu.kanade.tachiyomi.util.system.sendLocalBroadcast
import java.io.File import java.io.File
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.Date import java.util.Date
import java.util.Locale import java.util.Locale
import java.util.concurrent.ExecutorService import kotlinx.coroutines.CoroutineExceptionHandler
import java.util.concurrent.Executors import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import rx.Observable import rx.Observable
import rx.Subscription
import rx.schedulers.Schedulers
import timber.log.Timber import timber.log.Timber
import uy.kohesive.injekt.injectLazy import uy.kohesive.injekt.injectLazy
@ -60,7 +60,7 @@ class BackupRestoreService : Service() {
* @param context the application context. * @param context the application context.
* @return true if the service is running, false otherwise. * @return true if the service is running, false otherwise.
*/ */
private fun isRunning(context: Context): Boolean = fun isRunning(context: Context): Boolean =
context.isServiceRunning(BackupRestoreService::class.java) context.isServiceRunning(BackupRestoreService::class.java)
/** /**
@ -103,10 +103,7 @@ class BackupRestoreService : Service() {
*/ */
private lateinit var wakeLock: PowerManager.WakeLock private lateinit var wakeLock: PowerManager.WakeLock
/** private var job: Job? = null
* Subscription where the update is done.
*/
private var subscription: Subscription? = null
/** /**
* The progress of a backup restore * The progress of a backup restore
@ -131,15 +128,12 @@ class BackupRestoreService : Service() {
private lateinit var notifier: BackupNotifier private lateinit var notifier: BackupNotifier
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()
notifier = BackupNotifier(this) notifier = BackupNotifier(this)
executor = Executors.newSingleThreadExecutor()
startForeground(Notifications.ID_RESTORE, notifier.showRestoreProgress().build()) startForeground(Notifications.ID_RESTORE, notifier.showRestoreProgress().build())
@ -149,17 +143,21 @@ class BackupRestoreService : Service() {
wakeLock.acquire() wakeLock.acquire()
} }
/** override fun stopService(name: Intent?): Boolean {
* Method called when the service is destroyed. It destroys the running subscription and destroyJob()
* releases the wake lock. return super.stopService(name)
*/ }
override fun onDestroy() { override fun onDestroy() {
subscription?.unsubscribe() destroyJob()
executor.shutdown() // must be called after unsubscribe super.onDestroy()
}
private fun destroyJob() {
job?.cancel()
if (wakeLock.isHeld) { if (wakeLock.isHeld) {
wakeLock.release() wakeLock.release()
} }
super.onDestroy()
} }
/** /**
@ -176,146 +174,119 @@ class BackupRestoreService : Service() {
* @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 START_NOT_STICKY val uri = intent?.getParcelableExtra<Uri>(BackupConst.EXTRA_URI) ?: return START_NOT_STICKY
val uri = intent.getParcelableExtra<Uri>(BackupConst.EXTRA_URI) // Cancel any previous job if needed.
job?.cancel()
val handler = CoroutineExceptionHandler { _, exception ->
Timber.e(exception)
writeErrorLog()
// Unsubscribe from any previous subscription if needed. val errorIntent = Intent(BackupConst.INTENT_FILTER).apply {
subscription?.unsubscribe() putExtra(BackupConst.ACTION, BackupConst.ACTION_RESTORE_ERROR)
putExtra(BackupConst.EXTRA_ERROR_MESSAGE, exception.message)
}
sendLocalBroadcast(errorIntent)
subscription = Observable.using( stopSelf(startId)
{ db.lowLevel().beginTransaction() }, }
{ getRestoreObservable(uri).doOnNext { db.lowLevel().setTransactionSuccessful() } }, job = GlobalScope.launch(handler) {
{ executor.execute { db.lowLevel().endTransaction() } } restoreBackup(uri)
) }
.doAfterTerminate { stopSelf(startId) } job?.invokeOnCompletion {
.subscribeOn(Schedulers.from(executor)) stopSelf(startId)
.subscribe() }
return START_NOT_STICKY return START_NOT_STICKY
} }
/** /**
* Returns an [Observable] containing restore process. * Restores data from backup file.
* *
* @param uri restore file * @param uri backup file to restore
* @return [Observable<Manga>]
*/ */
private fun getRestoreObservable(uri: Uri): Observable<List<Manga>> { private fun restoreBackup(uri: Uri) {
val startTime = System.currentTimeMillis() val startTime = System.currentTimeMillis()
return Observable.just(Unit) val reader = JsonReader(contentResolver.openInputStream(uri)!!.bufferedReader())
.map { val json = JsonParser.parseReader(reader).asJsonObject
val reader = JsonReader(contentResolver.openInputStream(uri)!!.bufferedReader())
val json = JsonParser.parseReader(reader).asJsonObject
// Get parser version // Get parser version
val version = json.get(VERSION)?.asInt ?: 1 val version = json.get(VERSION)?.asInt ?: 1
// Initialize manager // Initialize manager
backupManager = BackupManager(this, version) backupManager = BackupManager(this, version)
val mangasJson = json.get(MANGAS).asJsonArray val mangasJson = json.get(MANGAS).asJsonArray
restoreAmount = mangasJson.size() + 1 // +1 for categories restoreAmount = mangasJson.size() + 1 // +1 for categories
restoreProgress = 0 restoreProgress = 0
errors.clear() errors.clear()
// Restore categories // Restore categories
restoreCategories(json.get(CATEGORIES)) restoreCategories(json.get(CATEGORIES))
mangasJson // Restore individual manga
} mangasJson.forEach {
.flatMap { Observable.from(it) } restoreManga(it.asJsonObject)
.concatMap { }
restoreManga(it)
} val endTime = System.currentTimeMillis()
.toList() val time = endTime - startTime
.doOnNext {
val endTime = System.currentTimeMillis() val logFile = writeErrorLog()
val time = endTime - startTime val completeIntent = Intent(BackupConst.INTENT_FILTER).apply {
val logFile = writeErrorLog() putExtra(BackupConst.EXTRA_TIME, time)
val completeIntent = Intent(BackupConst.INTENT_FILTER).apply { putExtra(BackupConst.EXTRA_ERRORS, errors.size)
putExtra(BackupConst.EXTRA_TIME, time) putExtra(BackupConst.EXTRA_ERROR_FILE_PATH, logFile.parent)
putExtra(BackupConst.EXTRA_ERRORS, errors.size) putExtra(BackupConst.EXTRA_ERROR_FILE, logFile.name)
putExtra(BackupConst.EXTRA_ERROR_FILE_PATH, logFile.parent) putExtra(BackupConst.ACTION, BackupConst.ACTION_RESTORE_COMPLETED)
putExtra(BackupConst.EXTRA_ERROR_FILE, logFile.name) }
putExtra(BackupConst.ACTION, BackupConst.ACTION_RESTORE_COMPLETED) sendLocalBroadcast(completeIntent)
}
sendLocalBroadcast(completeIntent)
}
.doOnError { error ->
Timber.e(error)
writeErrorLog()
val errorIntent = Intent(BackupConst.INTENT_FILTER).apply {
putExtra(BackupConst.ACTION, BackupConst.ACTION_RESTORE_ERROR)
putExtra(BackupConst.EXTRA_ERROR_MESSAGE, error.message)
}
sendLocalBroadcast(errorIntent)
}
.onErrorReturn { emptyList() }
} }
private fun restoreCategories(categoriesJson: JsonElement) { private fun restoreCategories(categoriesJson: JsonElement) {
backupManager.restoreCategories(categoriesJson.asJsonArray) db.executeTransaction {
restoreProgress += 1 backupManager.restoreCategories(categoriesJson.asJsonArray)
showRestoreProgress(restoreProgress, restoreAmount, getString(R.string.categories))
}
private fun restoreManga(mangaJson: JsonElement): Observable<out Manga>? {
val obj = mangaJson.asJsonObject
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 observable = getMangaRestoreObservable(manga, chapters, categories, history, tracks)
return if (observable != null) {
observable
} else {
errors.add(Date() to "${manga.title} - ${getString(R.string.source_not_found)}")
restoreProgress += 1 restoreProgress += 1
val content = showRestoreProgress(restoreProgress, restoreAmount, getString(R.string.categories))
getString(R.string.dialog_restoring_source_not_found, manga.title.chop(15))
showRestoreProgress(restoreProgress, restoreAmount, manga.title, content)
Observable.just(manga)
} }
} }
/** private fun restoreManga(mangaJson: JsonObject) {
* Write errors to error log db.executeTransaction {
*/ val manga = backupManager.parser.fromJson<MangaImpl>(mangaJson.get(MANGA))
private fun writeErrorLog(): File { val chapters = backupManager.parser.fromJson<List<ChapterImpl>>(
try { mangaJson.get(CHAPTERS)
if (errors.isNotEmpty()) { ?: JsonArray()
val destFile = File(externalCacheDir, "tachiyomi_restore.txt") )
val sdf = SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS", Locale.getDefault()) val categories = backupManager.parser.fromJson<List<String>>(
mangaJson.get(CATEGORIES)
?: JsonArray()
)
val history = backupManager.parser.fromJson<List<DHistory>>(
mangaJson.get(HISTORY)
?: JsonArray()
)
val tracks = backupManager.parser.fromJson<List<TrackImpl>>(
mangaJson.get(TRACK)
?: JsonArray()
)
destFile.bufferedWriter().use { out -> if (job?.isActive != true) {
errors.forEach { (date, message) -> throw Exception(getString(R.string.restoring_backup_canceled))
out.write("[${sdf.format(date)}] $message\n")
}
}
return destFile
} }
} catch (e: Exception) {
// Empty try {
restoreMangaData(manga, chapters, categories, history, tracks)
} catch (e: Exception) {
errors.add(Date() to "${manga.title} - ${getString(R.string.source_not_found)}")
}
restoreProgress += 1
showRestoreProgress(restoreProgress, restoreAmount, manga.title)
} }
return File("")
} }
/** /**
@ -326,27 +297,26 @@ class BackupRestoreService : Service() {
* @param categories categories data from json * @param categories categories data from json
* @param history history data from json * @param history history data from json
* @param tracks tracking data from json * @param tracks tracking data from json
* @return [Observable] containing manga restore information
*/ */
private fun getMangaRestoreObservable( private fun restoreMangaData(
manga: Manga, manga: Manga,
chapters: List<Chapter>, chapters: List<Chapter>,
categories: List<String>, categories: List<String>,
history: List<DHistory>, history: List<DHistory>,
tracks: List<Track> tracks: List<Track>
): Observable<Manga>? { ) {
// Get source // Get source
val source = backupManager.sourceManager.getOrStub(manga.source) val source = backupManager.sourceManager.getOrStub(manga.source)
val dbManga = backupManager.getMangaFromDatabase(manga) val dbManga = backupManager.getMangaFromDatabase(manga)
return if (dbManga == null) { if (dbManga == null) {
// Manga not in database // Manga not in database
mangaFetchObservable(source, manga, chapters, categories, history, tracks) restoreMangaFetch(source, manga, chapters, categories, history, tracks)
} else { // Manga in database } else { // Manga in database
// Copy information from manga already in database // Copy information from manga already in database
backupManager.restoreMangaNoFetch(manga, dbManga) backupManager.restoreMangaNoFetch(manga, dbManga)
// Fetch rest of manga information // Fetch rest of manga information
mangaNoFetchObservable(source, manga, chapters, categories, history, tracks) restoreMangaNoFetch(source, manga, chapters, categories, history, tracks)
} }
} }
@ -357,15 +327,15 @@ class BackupRestoreService : Service() {
* @param chapters chapters of manga that needs updating * @param chapters chapters of manga that needs updating
* @param categories categories that need updating * @param categories categories that need updating
*/ */
private fun mangaFetchObservable( private fun restoreMangaFetch(
source: Source, source: Source,
manga: Manga, manga: Manga,
chapters: List<Chapter>, chapters: List<Chapter>,
categories: List<String>, categories: List<String>,
history: List<DHistory>, history: List<DHistory>,
tracks: List<Track> tracks: List<Track>
): Observable<Manga> { ) {
return backupManager.restoreMangaFetchObservable(source, manga) backupManager.restoreMangaFetchObservable(source, manga)
.onErrorReturn { .onErrorReturn {
errors.add(Date() to "${manga.title} - ${it.message}") errors.add(Date() to "${manga.title} - ${it.message}")
manga manga
@ -381,24 +351,19 @@ class BackupRestoreService : Service() {
} }
.flatMap { .flatMap {
trackingFetchObservable(it, tracks) trackingFetchObservable(it, tracks)
// Convert to the manga that contains new chapters.
.map { manga }
}
.doOnCompleted {
restoreProgress += 1
showRestoreProgress(restoreProgress, restoreAmount, manga.title)
} }
.subscribe()
} }
private fun mangaNoFetchObservable( private fun restoreMangaNoFetch(
source: Source, source: Source,
backupManga: Manga, backupManga: Manga,
chapters: List<Chapter>, chapters: List<Chapter>,
categories: List<String>, categories: List<String>,
history: List<DHistory>, history: List<DHistory>,
tracks: List<Track> tracks: List<Track>
): Observable<Manga> { ) {
return Observable.just(backupManga) Observable.just(backupManga)
.flatMap { manga -> .flatMap { manga ->
if (!backupManager.restoreChaptersForManga(manga, chapters)) { if (!backupManager.restoreChaptersForManga(manga, chapters)) {
chapterFetchObservable(source, manga, chapters) chapterFetchObservable(source, manga, chapters)
@ -412,13 +377,8 @@ class BackupRestoreService : Service() {
} }
.flatMap { manga -> .flatMap { manga ->
trackingFetchObservable(manga, tracks) trackingFetchObservable(manga, tracks)
// Convert to the manga that contains new chapters.
.map { manga }
}
.doOnCompleted {
restoreProgress += 1
showRestoreProgress(restoreProgress, restoreAmount, backupManga.title)
} }
.subscribe()
} }
private fun restoreExtraForManga(manga: Manga, categories: List<String>, history: List<DHistory>, tracks: List<Track>) { private fun restoreExtraForManga(manga: Manga, categories: List<String>, history: List<DHistory>, tracks: List<Track>) {
@ -482,9 +442,30 @@ class BackupRestoreService : Service() {
private fun showRestoreProgress( private fun showRestoreProgress(
progress: Int, progress: Int,
amount: Int, amount: Int,
title: String, title: String
content: String = title.chop(30)
) { ) {
notifier.showRestoreProgress(content, progress, amount) notifier.showRestoreProgress(title, progress, amount)
}
/**
* Write errors to error log
*/
private fun writeErrorLog(): File {
try {
if (errors.isNotEmpty()) {
val destFile = File(externalCacheDir, "tachiyomi_restore.txt")
val sdf = SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS", Locale.getDefault())
destFile.bufferedWriter().use { out ->
errors.forEach { (date, message) ->
out.write("[${sdf.format(date)}] $message\n")
}
}
return destFile
}
} catch (e: Exception) {
// Empty
}
return File("")
} }
} }

View file

@ -46,5 +46,12 @@ open class DatabaseHelper(context: Context) :
inline fun inTransaction(block: () -> Unit) = db.inTransaction(block) inline fun inTransaction(block: () -> Unit) = db.inTransaction(block)
fun lowLevel() = db.lowLevel() fun executeTransaction(block: () -> Unit) {
db.lowLevel().beginTransaction()
block()
db.lowLevel().setTransactionSuccessful()
db.lowLevel().endTransaction()
}
} }

View file

@ -88,7 +88,7 @@ class SettingsBackupController : SettingsController() {
summaryRes = R.string.pref_restore_backup_summ summaryRes = R.string.pref_restore_backup_summ
onClick { onClick {
if (!isRestoreStarted) { if (!BackupRestoreService.isRunning(context)) {
val intent = Intent(Intent.ACTION_GET_CONTENT) val intent = Intent(Intent.ACTION_GET_CONTENT)
intent.addCategory(Intent.CATEGORY_OPENABLE) intent.addCategory(Intent.CATEGORY_OPENABLE)
intent.type = "application/*" intent.type = "application/*"
@ -277,7 +277,6 @@ class SettingsBackupController : SettingsController() {
val context = applicationContext val context = applicationContext
if (context != null) { if (context != null) {
BackupRestoreService.start(context, args.getParcelable(KEY_URI)!!) BackupRestoreService.start(context, args.getParcelable(KEY_URI)!!)
isRestoreStarted = true
} }
} }
} }
@ -303,8 +302,6 @@ class SettingsBackupController : SettingsController() {
notifier.showBackupError(intent.getStringExtra(BackupConst.EXTRA_ERROR_MESSAGE)) notifier.showBackupError(intent.getStringExtra(BackupConst.EXTRA_ERROR_MESSAGE))
} }
BackupConst.ACTION_RESTORE_COMPLETED -> { BackupConst.ACTION_RESTORE_COMPLETED -> {
isRestoreStarted = false
val time = intent.getLongExtra(BackupConst.EXTRA_TIME, 0) val time = intent.getLongExtra(BackupConst.EXTRA_TIME, 0)
val errorCount = intent.getIntExtra(BackupConst.EXTRA_ERRORS, 0) val errorCount = intent.getIntExtra(BackupConst.EXTRA_ERRORS, 0)
val path = intent.getStringExtra(BackupConst.EXTRA_ERROR_FILE_PATH) val path = intent.getStringExtra(BackupConst.EXTRA_ERROR_FILE_PATH)
@ -312,8 +309,6 @@ class SettingsBackupController : SettingsController() {
notifier.showRestoreComplete(time, errorCount, path, file) notifier.showRestoreComplete(time, errorCount, path, file)
} }
BackupConst.ACTION_RESTORE_ERROR -> { BackupConst.ACTION_RESTORE_ERROR -> {
isRestoreStarted = false
notifier.showRestoreError(intent.getStringExtra(BackupConst.EXTRA_ERROR_MESSAGE)) notifier.showRestoreError(intent.getStringExtra(BackupConst.EXTRA_ERROR_MESSAGE))
} }
} }
@ -326,6 +321,5 @@ class SettingsBackupController : SettingsController() {
const val CODE_BACKUP_DIR = 503 const val CODE_BACKUP_DIR = 503
var isBackupStarted = false var isBackupStarted = false
var isRestoreStarted = false
} }
} }

View file

@ -31,6 +31,7 @@ internal class BackupNotifier(private val context: Context) {
setContentText(context.getString(R.string.creating_backup)) setContentText(context.getString(R.string.creating_backup))
setProgress(0, 0, true) setProgress(0, 0, true)
setOngoing(true)
} }
notificationBuilder.show(Notifications.ID_BACKUP) notificationBuilder.show(Notifications.ID_BACKUP)
@ -43,6 +44,7 @@ internal class BackupNotifier(private val context: Context) {
// Remove progress bar // Remove progress bar
setProgress(0, 0, false) setProgress(0, 0, false)
setOngoing(false)
} }
notificationBuilder.show(Notifications.ID_BACKUP) notificationBuilder.show(Notifications.ID_BACKUP)
@ -58,6 +60,7 @@ internal class BackupNotifier(private val context: Context) {
// Remove progress bar // Remove progress bar
setProgress(0, 0, false) setProgress(0, 0, false)
setOngoing(false)
// Clear old actions if they exist // Clear old actions if they exist
if (mActions.isNotEmpty()) { if (mActions.isNotEmpty()) {
@ -80,6 +83,7 @@ internal class BackupNotifier(private val context: Context) {
setContentText(content) setContentText(content)
setProgress(maxAmount, progress, false) setProgress(maxAmount, progress, false)
setOngoing(true)
// Clear old actions if they exist // Clear old actions if they exist
if (mActions.isNotEmpty()) { if (mActions.isNotEmpty()) {
@ -105,6 +109,7 @@ internal class BackupNotifier(private val context: Context) {
// Remove progress bar // Remove progress bar
setProgress(0, 0, false) setProgress(0, 0, false)
setOngoing(false)
} }
notificationBuilder.show(Notifications.ID_RESTORE) notificationBuilder.show(Notifications.ID_RESTORE)
@ -128,6 +133,7 @@ internal class BackupNotifier(private val context: Context) {
// Remove progress bar // Remove progress bar
setProgress(0, 0, false) setProgress(0, 0, false)
setOngoing(false)
// Clear old actions if they exist // Clear old actions if they exist
if (mActions.isNotEmpty()) { if (mActions.isNotEmpty()) {

View file

@ -316,7 +316,6 @@
<string name="pref_backup_interval">Backup frequency</string> <string name="pref_backup_interval">Backup frequency</string>
<string name="pref_backup_slots">Max automatic backups</string> <string name="pref_backup_slots">Max automatic backups</string>
<string name="source_not_found">Source not found</string> <string name="source_not_found">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_duration">%02d min, %02d sec</string> <string name="restore_duration">%02d min, %02d sec</string>