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.google.gson.JsonArray
import com.google.gson.JsonElement
import com.google.gson.JsonObject
import com.google.gson.JsonParser
import com.google.gson.stream.JsonReader
import eu.kanade.tachiyomi.R
@ -32,18 +33,17 @@ import eu.kanade.tachiyomi.data.notification.Notifications
import eu.kanade.tachiyomi.data.track.TrackManager
import eu.kanade.tachiyomi.source.Source
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.sendLocalBroadcast
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
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
import timber.log.Timber
import uy.kohesive.injekt.injectLazy
@ -60,7 +60,7 @@ class BackupRestoreService : Service() {
* @param context the application context.
* @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)
/**
@ -103,10 +103,7 @@ class BackupRestoreService : Service() {
*/
private lateinit var wakeLock: PowerManager.WakeLock
/**
* Subscription where the update is done.
*/
private var subscription: Subscription? = null
private var job: Job? = null
/**
* The progress of a backup restore
@ -131,15 +128,12 @@ class BackupRestoreService : Service() {
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.
*/
override fun onCreate() {
super.onCreate()
notifier = BackupNotifier(this)
executor = Executors.newSingleThreadExecutor()
startForeground(Notifications.ID_RESTORE, notifier.showRestoreProgress().build())
@ -149,17 +143,21 @@ class BackupRestoreService : Service() {
wakeLock.acquire()
}
/**
* Method called when the service is destroyed. It destroys the running subscription and
* releases the wake lock.
*/
override fun stopService(name: Intent?): Boolean {
destroyJob()
return super.stopService(name)
}
override fun onDestroy() {
subscription?.unsubscribe()
executor.shutdown() // must be called after unsubscribe
destroyJob()
super.onDestroy()
}
private fun destroyJob() {
job?.cancel()
if (wakeLock.isHeld) {
wakeLock.release()
}
super.onDestroy()
}
/**
@ -176,146 +174,119 @@ class BackupRestoreService : Service() {
* @return the start value of the command.
*/
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.
subscription?.unsubscribe()
val errorIntent = Intent(BackupConst.INTENT_FILTER).apply {
putExtra(BackupConst.ACTION, BackupConst.ACTION_RESTORE_ERROR)
putExtra(BackupConst.EXTRA_ERROR_MESSAGE, exception.message)
}
sendLocalBroadcast(errorIntent)
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()
stopSelf(startId)
}
job = GlobalScope.launch(handler) {
restoreBackup(uri)
}
job?.invokeOnCompletion {
stopSelf(startId)
}
return START_NOT_STICKY
}
/**
* Returns an [Observable] containing restore process.
* Restores data from backup file.
*
* @param uri restore file
* @return [Observable<Manga>]
* @param uri backup file to restore
*/
private fun getRestoreObservable(uri: Uri): Observable<List<Manga>> {
private fun restoreBackup(uri: Uri) {
val startTime = System.currentTimeMillis()
return Observable.just(Unit)
.map {
val reader = JsonReader(contentResolver.openInputStream(uri)!!.bufferedReader())
val json = JsonParser.parseReader(reader).asJsonObject
val reader = JsonReader(contentResolver.openInputStream(uri)!!.bufferedReader())
val json = JsonParser.parseReader(reader).asJsonObject
// Get parser version
val version = json.get(VERSION)?.asInt ?: 1
// Get parser version
val version = json.get(VERSION)?.asInt ?: 1
// Initialize manager
backupManager = BackupManager(this, version)
// Initialize manager
backupManager = BackupManager(this, version)
val mangasJson = json.get(MANGAS).asJsonArray
val mangasJson = json.get(MANGAS).asJsonArray
restoreAmount = mangasJson.size() + 1 // +1 for categories
restoreProgress = 0
errors.clear()
restoreAmount = mangasJson.size() + 1 // +1 for categories
restoreProgress = 0
errors.clear()
// Restore categories
restoreCategories(json.get(CATEGORIES))
// Restore categories
restoreCategories(json.get(CATEGORIES))
mangasJson
}
.flatMap { Observable.from(it) }
.concatMap {
restoreManga(it)
}
.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)
putExtra(BackupConst.ACTION, BackupConst.ACTION_RESTORE_COMPLETED)
}
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() }
// Restore individual manga
mangasJson.forEach {
restoreManga(it.asJsonObject)
}
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)
putExtra(BackupConst.ACTION, BackupConst.ACTION_RESTORE_COMPLETED)
}
sendLocalBroadcast(completeIntent)
}
private fun restoreCategories(categoriesJson: JsonElement) {
backupManager.restoreCategories(categoriesJson.asJsonArray)
restoreProgress += 1
showRestoreProgress(restoreProgress, restoreAmount, getString(R.string.categories))
}
db.executeTransaction {
backupManager.restoreCategories(categoriesJson.asJsonArray)
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
val content =
getString(R.string.dialog_restoring_source_not_found, manga.title.chop(15))
showRestoreProgress(restoreProgress, restoreAmount, manga.title, content)
Observable.just(manga)
showRestoreProgress(restoreProgress, restoreAmount, getString(R.string.categories))
}
}
/**
* 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())
private fun restoreManga(mangaJson: JsonObject) {
db.executeTransaction {
val manga = backupManager.parser.fromJson<MangaImpl>(mangaJson.get(MANGA))
val chapters = backupManager.parser.fromJson<List<ChapterImpl>>(
mangaJson.get(CHAPTERS)
?: JsonArray()
)
val categories = backupManager.parser.fromJson<List<String>>(
mangaJson.get(CATEGORIES)
?: JsonArray()
)
val history = backupManager.parser.fromJson<List<DHistory>>(
mangaJson.get(HISTORY)
?: JsonArray()
)
val tracks = backupManager.parser.fromJson<List<TrackImpl>>(
mangaJson.get(TRACK)
?: JsonArray()
)
destFile.bufferedWriter().use { out ->
errors.forEach { (date, message) ->
out.write("[${sdf.format(date)}] $message\n")
}
}
return destFile
if (job?.isActive != true) {
throw Exception(getString(R.string.restoring_backup_canceled))
}
} 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 history history data from json
* @param tracks tracking data from json
* @return [Observable] containing manga restore information
*/
private fun getMangaRestoreObservable(
private fun restoreMangaData(
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) {
if (dbManga == null) {
// Manga not in database
mangaFetchObservable(source, manga, chapters, categories, history, tracks)
restoreMangaFetch(source, manga, chapters, categories, history, tracks)
} else { // Manga in database
// Copy information from manga already in database
backupManager.restoreMangaNoFetch(manga, dbManga)
// Fetch rest of manga information
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 categories categories that need updating
*/
private fun mangaFetchObservable(
private fun restoreMangaFetch(
source: Source,
manga: Manga,
chapters: List<Chapter>,
categories: List<String>,
history: List<DHistory>,
tracks: List<Track>
): Observable<Manga> {
return backupManager.restoreMangaFetchObservable(source, manga)
) {
backupManager.restoreMangaFetchObservable(source, manga)
.onErrorReturn {
errors.add(Date() to "${manga.title} - ${it.message}")
manga
@ -381,24 +351,19 @@ class BackupRestoreService : Service() {
}
.flatMap {
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,
backupManga: Manga,
chapters: List<Chapter>,
categories: List<String>,
history: List<DHistory>,
tracks: List<Track>
): Observable<Manga> {
return Observable.just(backupManga)
) {
Observable.just(backupManga)
.flatMap { manga ->
if (!backupManager.restoreChaptersForManga(manga, chapters)) {
chapterFetchObservable(source, manga, chapters)
@ -412,13 +377,8 @@ class BackupRestoreService : Service() {
}
.flatMap { manga ->
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>) {
@ -482,9 +442,30 @@ class BackupRestoreService : Service() {
private fun showRestoreProgress(
progress: Int,
amount: Int,
title: String,
content: String = title.chop(30)
title: String
) {
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)
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
onClick {
if (!isRestoreStarted) {
if (!BackupRestoreService.isRunning(context)) {
val intent = Intent(Intent.ACTION_GET_CONTENT)
intent.addCategory(Intent.CATEGORY_OPENABLE)
intent.type = "application/*"
@ -277,7 +277,6 @@ class SettingsBackupController : SettingsController() {
val context = applicationContext
if (context != null) {
BackupRestoreService.start(context, args.getParcelable(KEY_URI)!!)
isRestoreStarted = true
}
}
}
@ -303,8 +302,6 @@ class SettingsBackupController : SettingsController() {
notifier.showBackupError(intent.getStringExtra(BackupConst.EXTRA_ERROR_MESSAGE))
}
BackupConst.ACTION_RESTORE_COMPLETED -> {
isRestoreStarted = false
val time = intent.getLongExtra(BackupConst.EXTRA_TIME, 0)
val errorCount = intent.getIntExtra(BackupConst.EXTRA_ERRORS, 0)
val path = intent.getStringExtra(BackupConst.EXTRA_ERROR_FILE_PATH)
@ -312,8 +309,6 @@ class SettingsBackupController : SettingsController() {
notifier.showRestoreComplete(time, errorCount, path, file)
}
BackupConst.ACTION_RESTORE_ERROR -> {
isRestoreStarted = false
notifier.showRestoreError(intent.getStringExtra(BackupConst.EXTRA_ERROR_MESSAGE))
}
}
@ -326,6 +321,5 @@ class SettingsBackupController : SettingsController() {
const val CODE_BACKUP_DIR = 503
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))
setProgress(0, 0, true)
setOngoing(true)
}
notificationBuilder.show(Notifications.ID_BACKUP)
@ -43,6 +44,7 @@ internal class BackupNotifier(private val context: Context) {
// Remove progress bar
setProgress(0, 0, false)
setOngoing(false)
}
notificationBuilder.show(Notifications.ID_BACKUP)
@ -58,6 +60,7 @@ internal class BackupNotifier(private val context: Context) {
// Remove progress bar
setProgress(0, 0, false)
setOngoing(false)
// Clear old actions if they exist
if (mActions.isNotEmpty()) {
@ -80,6 +83,7 @@ internal class BackupNotifier(private val context: Context) {
setContentText(content)
setProgress(maxAmount, progress, false)
setOngoing(true)
// Clear old actions if they exist
if (mActions.isNotEmpty()) {
@ -105,6 +109,7 @@ internal class BackupNotifier(private val context: Context) {
// Remove progress bar
setProgress(0, 0, false)
setOngoing(false)
}
notificationBuilder.show(Notifications.ID_RESTORE)
@ -128,6 +133,7 @@ internal class BackupNotifier(private val context: Context) {
// Remove progress bar
setProgress(0, 0, false)
setOngoing(false)
// Clear old actions if they exist
if (mActions.isNotEmpty()) {

View file

@ -316,7 +316,6 @@
<string name="pref_backup_interval">Backup frequency</string>
<string name="pref_backup_slots">Max automatic backups</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="restore_completed">Restore completed</string>
<string name="restore_duration">%02d min, %02d sec</string>