Restore backups in background
parent
c37f795dac
commit
008f2d705a
@ -0,0 +1,47 @@
|
||||
package org.koitharu.kotatsu.core.util.progress
|
||||
|
||||
data class Progress(
|
||||
val progress: Int,
|
||||
val total: Int,
|
||||
) : Comparable<Progress> {
|
||||
|
||||
val percent: Float
|
||||
get() = if (total == 0) 0f else progress / total.toFloat()
|
||||
|
||||
val isEmpty: Boolean
|
||||
get() = progress == 0
|
||||
|
||||
val isFull: Boolean
|
||||
get() = progress == total
|
||||
|
||||
override fun compareTo(other: Progress): Int = if (total == other.total) {
|
||||
progress.compareTo(other.progress)
|
||||
} else {
|
||||
percent.compareTo(other.percent)
|
||||
}
|
||||
|
||||
operator fun inc() = if (isFull) {
|
||||
this
|
||||
} else {
|
||||
copy(
|
||||
progress = progress + 1,
|
||||
total = total,
|
||||
)
|
||||
}
|
||||
|
||||
operator fun dec() = if (isEmpty) {
|
||||
this
|
||||
} else {
|
||||
copy(
|
||||
progress = progress - 1,
|
||||
total = total,
|
||||
)
|
||||
}
|
||||
|
||||
operator fun plus(child: Progress) = Progress(
|
||||
progress = progress * child.total + child.progress,
|
||||
total = total * child.total,
|
||||
)
|
||||
|
||||
fun percentSting() = (percent * 100f).toInt().toString()
|
||||
}
|
||||
@ -0,0 +1,287 @@
|
||||
package org.koitharu.kotatsu.settings.backup
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.Notification
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.ServiceInfo
|
||||
import android.net.Uri
|
||||
import androidx.core.app.NotificationChannelCompat
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.core.app.PendingIntentCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.runInterruptible
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.ErrorReporterReceiver
|
||||
import org.koitharu.kotatsu.core.backup.BackupEntry
|
||||
import org.koitharu.kotatsu.core.backup.BackupRepository
|
||||
import org.koitharu.kotatsu.core.backup.BackupZipInput
|
||||
import org.koitharu.kotatsu.core.backup.CompositeResult
|
||||
import org.koitharu.kotatsu.core.nav.AppRouter
|
||||
import org.koitharu.kotatsu.core.ui.CoroutineIntentService
|
||||
import org.koitharu.kotatsu.core.util.ext.checkNotificationPermission
|
||||
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
|
||||
import org.koitharu.kotatsu.core.util.ext.getFileDisplayName
|
||||
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
||||
import org.koitharu.kotatsu.core.util.ext.toUriOrNull
|
||||
import org.koitharu.kotatsu.core.util.progress.Progress
|
||||
import org.koitharu.kotatsu.parsers.util.mapToArray
|
||||
import org.koitharu.kotatsu.parsers.util.nullIfEmpty
|
||||
import java.io.File
|
||||
import java.io.FileNotFoundException
|
||||
import java.util.EnumSet
|
||||
import javax.inject.Inject
|
||||
import com.google.android.material.R as materialR
|
||||
|
||||
@AndroidEntryPoint
|
||||
class RestoreService : CoroutineIntentService() {
|
||||
|
||||
@Inject
|
||||
lateinit var repository: BackupRepository
|
||||
|
||||
private lateinit var notificationManager: NotificationManagerCompat
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
notificationManager = NotificationManagerCompat.from(applicationContext)
|
||||
}
|
||||
|
||||
override suspend fun IntentJobContext.processIntent(intent: Intent) {
|
||||
startForeground(this)
|
||||
val uri = intent.getStringExtra(AppRouter.KEY_DATA)?.toUriOrNull() ?: throw FileNotFoundException()
|
||||
val displayName = contentResolver.getFileDisplayName(uri)
|
||||
val entries = intent.getIntArrayExtra(AppRouter.KEY_ENTRIES)
|
||||
?.mapTo(EnumSet.noneOf(BackupEntry.Name::class.java)) { BackupEntry.Name.entries[it] }
|
||||
if (entries.isNullOrEmpty()) {
|
||||
throw IllegalArgumentException("No entries specified")
|
||||
}
|
||||
val result = runInterruptible(Dispatchers.IO) {
|
||||
val tempFile = File.createTempFile("backup_", ".tmp")
|
||||
(contentResolver.openInputStream(uri) ?: throw FileNotFoundException()).use { input ->
|
||||
tempFile.outputStream().use { output ->
|
||||
input.copyTo(output)
|
||||
}
|
||||
}
|
||||
BackupZipInput.from(tempFile)
|
||||
}.use { backupInput ->
|
||||
restoreImpl(displayName, backupInput, entries)
|
||||
}
|
||||
if (applicationContext.checkNotificationPermission(CHANNEL_ID)) {
|
||||
val notification = buildNotification(displayName, result)
|
||||
notificationManager.notify(TAG, startId, notification)
|
||||
}
|
||||
}
|
||||
|
||||
override fun IntentJobContext.onError(error: Throwable) {
|
||||
if (applicationContext.checkNotificationPermission(CHANNEL_ID)) {
|
||||
val result = CompositeResult()
|
||||
result += error
|
||||
val notification = buildNotification(null, result)
|
||||
notificationManager.notify(TAG, startId, notification)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun IntentJobContext.restoreImpl(
|
||||
displayName: String?,
|
||||
input: BackupZipInput,
|
||||
entries: Set<BackupEntry.Name>
|
||||
): CompositeResult {
|
||||
val result = CompositeResult()
|
||||
val showNotification = applicationContext.checkNotificationPermission(CHANNEL_ID)
|
||||
var progress = Progress(0, entries.size)
|
||||
|
||||
fun notify(childProgress: Progress? = null) {
|
||||
if (showNotification) {
|
||||
val p = childProgress?.let { progress + it } ?: progress
|
||||
notificationManager.notify(FOREGROUND_NOTIFICATION_ID, buildNotification(displayName, p))
|
||||
}
|
||||
}
|
||||
|
||||
notify()
|
||||
|
||||
if (BackupEntry.Name.HISTORY in entries) {
|
||||
input.getEntry(BackupEntry.Name.HISTORY)?.let {
|
||||
flow {
|
||||
result += repository.restoreHistory(it, this)
|
||||
}.collect { p ->
|
||||
notify(p)
|
||||
}
|
||||
}
|
||||
progress++
|
||||
}
|
||||
|
||||
notify()
|
||||
|
||||
if (BackupEntry.Name.CATEGORIES in entries) {
|
||||
input.getEntry(BackupEntry.Name.CATEGORIES)?.let {
|
||||
result += repository.restoreCategories(it)
|
||||
}
|
||||
progress++
|
||||
}
|
||||
|
||||
notify()
|
||||
|
||||
if (BackupEntry.Name.FAVOURITES in entries) {
|
||||
input.getEntry(BackupEntry.Name.FAVOURITES)?.let {
|
||||
flow {
|
||||
result += repository.restoreFavourites(it, this)
|
||||
}.collect { p ->
|
||||
notify(p)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
notify()
|
||||
|
||||
if (BackupEntry.Name.BOOKMARKS in entries) {
|
||||
input.getEntry(BackupEntry.Name.BOOKMARKS)?.let {
|
||||
result += repository.restoreBookmarks(it)
|
||||
}
|
||||
progress++
|
||||
}
|
||||
|
||||
notify()
|
||||
|
||||
if (BackupEntry.Name.SOURCES in entries) {
|
||||
input.getEntry(BackupEntry.Name.SOURCES)?.let {
|
||||
result += repository.restoreSources(it)
|
||||
}
|
||||
progress++
|
||||
}
|
||||
|
||||
notify()
|
||||
|
||||
if (BackupEntry.Name.SETTINGS in entries) {
|
||||
input.getEntry(BackupEntry.Name.SETTINGS)?.let {
|
||||
result += repository.restoreSettings(it)
|
||||
}
|
||||
progress++
|
||||
}
|
||||
|
||||
notify()
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
@SuppressLint("InlinedApi")
|
||||
private fun startForeground(jobContext: IntentJobContext) {
|
||||
val channel = NotificationChannelCompat.Builder(CHANNEL_ID, NotificationManagerCompat.IMPORTANCE_LOW)
|
||||
.setName(getString(R.string.restoring_backup))
|
||||
.setShowBadge(true)
|
||||
.setVibrationEnabled(false)
|
||||
.setSound(null, null)
|
||||
.setLightsEnabled(false)
|
||||
.build()
|
||||
notificationManager.createNotificationChannel(channel)
|
||||
|
||||
val notification = jobContext.buildNotification(null, null)
|
||||
|
||||
jobContext.setForeground(
|
||||
FOREGROUND_NOTIFICATION_ID,
|
||||
notification,
|
||||
ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC,
|
||||
)
|
||||
}
|
||||
|
||||
private fun IntentJobContext.buildNotification(fileName: String?, progress: Progress?): Notification {
|
||||
return NotificationCompat.Builder(applicationContext, CHANNEL_ID)
|
||||
.setContentTitle(getString(R.string.restoring_backup))
|
||||
.setPriority(NotificationCompat.PRIORITY_LOW)
|
||||
.setDefaults(0)
|
||||
.setSilent(true)
|
||||
.setOngoing(true)
|
||||
.setProgress(progress?.total ?: 0, progress?.progress ?: 0, progress == null)
|
||||
.setContentText(
|
||||
concatStrings(
|
||||
context = this@RestoreService,
|
||||
a = fileName,
|
||||
b = progress?.run { getString(R.string.percent_string_pattern, percentSting()) },
|
||||
),
|
||||
)
|
||||
.setSmallIcon(android.R.drawable.stat_sys_download)
|
||||
.setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE)
|
||||
.setCategory(NotificationCompat.CATEGORY_PROGRESS)
|
||||
.addAction(
|
||||
materialR.drawable.material_ic_clear_black_24dp,
|
||||
applicationContext.getString(android.R.string.cancel),
|
||||
getCancelIntent(),
|
||||
).build()
|
||||
}
|
||||
|
||||
private fun buildNotification(fileName: String?, result: CompositeResult): Notification {
|
||||
val notification = NotificationCompat.Builder(applicationContext, CHANNEL_ID)
|
||||
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
|
||||
.setDefaults(0)
|
||||
.setSilent(true)
|
||||
.setAutoCancel(true)
|
||||
.setSubText(fileName)
|
||||
|
||||
when {
|
||||
result.isEmpty -> notification.setContentTitle(getString(R.string.data_not_restored))
|
||||
.setContentText(getString(R.string.data_not_restored_text))
|
||||
.setSmallIcon(android.R.drawable.stat_notify_error)
|
||||
|
||||
result.isAllSuccess -> notification.setContentTitle(getString(R.string.data_restored))
|
||||
.setContentText(getString(R.string.data_restored_success))
|
||||
.setSmallIcon(R.drawable.ic_stat_done)
|
||||
|
||||
result.isAllFailed -> notification.setContentTitle(getString(R.string.error))
|
||||
.setContentText(
|
||||
result.failures.map { it.getDisplayMessage(resources) }.distinct().joinToString("\n"),
|
||||
)
|
||||
.setSmallIcon(android.R.drawable.stat_notify_error)
|
||||
|
||||
else -> notification.setContentTitle(getString(R.string.data_restored))
|
||||
.setContentText(getString(R.string.data_restored_with_errors))
|
||||
.setSmallIcon(R.drawable.ic_stat_done)
|
||||
}
|
||||
result.failures.firstOrNull()?.let { error ->
|
||||
ErrorReporterReceiver.getPendingIntent(applicationContext, error)?.let { reportIntent ->
|
||||
notification.addAction(
|
||||
R.drawable.ic_alert_outline,
|
||||
applicationContext.getString(R.string.report),
|
||||
reportIntent,
|
||||
)
|
||||
}
|
||||
}
|
||||
notification.setContentIntent(
|
||||
PendingIntentCompat.getActivity(
|
||||
applicationContext,
|
||||
0,
|
||||
AppRouter.homeIntent(this),
|
||||
0,
|
||||
false,
|
||||
),
|
||||
)
|
||||
return notification.build()
|
||||
}
|
||||
|
||||
private fun concatStrings(context: Context, a: String?, b: String?): String? = when {
|
||||
a.isNullOrEmpty() && b.isNullOrEmpty() -> null
|
||||
a.isNullOrEmpty() -> b?.nullIfEmpty()
|
||||
b.isNullOrEmpty() -> a.nullIfEmpty()
|
||||
else -> context.getString(R.string.download_summary_pattern, a, b)
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
private const val TAG = "restore"
|
||||
private const val CHANNEL_ID = "restore_backup"
|
||||
private const val FOREGROUND_NOTIFICATION_ID = 39
|
||||
|
||||
fun start(context: Context, uri: Uri, entries: Set<BackupEntry.Name>): Boolean = try {
|
||||
val intent = Intent(context, RestoreService::class.java)
|
||||
intent.putExtra(AppRouter.KEY_DATA, uri.toString())
|
||||
intent.putExtra(AppRouter.KEY_ENTRIES, entries.mapToArray { it.ordinal }.toIntArray())
|
||||
ContextCompat.startForegroundService(context, intent)
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
e.printStackTraceDebug()
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue