Restore backups in background
This commit is contained in:
@@ -55,7 +55,7 @@ class StrictModeNotifier(
|
||||
.setContentIntent(
|
||||
PendingIntentCompat.getActivity(
|
||||
context,
|
||||
0,
|
||||
violation.hashCode(),
|
||||
ShareHelper(context).getShareTextIntent(violation.stackTraceToString()),
|
||||
0,
|
||||
false,
|
||||
|
||||
@@ -280,6 +280,10 @@
|
||||
<service
|
||||
android:name="org.koitharu.kotatsu.local.ui.LocalIndexUpdateService"
|
||||
android:label="@string/local_manga_processing" />
|
||||
<service
|
||||
android:name="org.koitharu.kotatsu.settings.backup.RestoreService"
|
||||
android:foregroundServiceType="dataSync"
|
||||
android:label="@string/restore_backup" />
|
||||
<service
|
||||
android:name="org.koitharu.kotatsu.local.ui.ImportService"
|
||||
android:foregroundServiceType="dataSync"
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
package org.koitharu.kotatsu.core.backup
|
||||
|
||||
import androidx.room.withTransaction
|
||||
import kotlinx.coroutines.flow.FlowCollector
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import org.koitharu.kotatsu.BuildConfig
|
||||
import org.koitharu.kotatsu.core.db.MangaDatabase
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.util.progress.Progress
|
||||
import org.koitharu.kotatsu.parsers.util.json.asTypedList
|
||||
import org.koitharu.kotatsu.parsers.util.json.getLongOrDefault
|
||||
import org.koitharu.kotatsu.parsers.util.json.mapJSON
|
||||
@@ -128,9 +130,11 @@ class BackupRepository @Inject constructor(
|
||||
return if (timestamp == 0L) null else Date(timestamp)
|
||||
}
|
||||
|
||||
suspend fun restoreHistory(entry: BackupEntry): CompositeResult {
|
||||
suspend fun restoreHistory(entry: BackupEntry, outProgress: FlowCollector<Progress>?): CompositeResult {
|
||||
val result = CompositeResult()
|
||||
for (item in entry.data.asTypedList<JSONObject>()) {
|
||||
val list = entry.data.asTypedList<JSONObject>()
|
||||
outProgress?.emit(Progress(progress = 0, total = list.size))
|
||||
for ((index, item) in list.withIndex()) {
|
||||
val mangaJson = item.getJSONObject("manga")
|
||||
val manga = JsonDeserializer(mangaJson).toMangaEntity()
|
||||
val tags = mangaJson.getJSONArray("tags").mapJSON {
|
||||
@@ -144,6 +148,7 @@ class BackupRepository @Inject constructor(
|
||||
db.getHistoryDao().upsert(history)
|
||||
}
|
||||
}
|
||||
outProgress?.emit(Progress(progress = index, total = list.size))
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -159,9 +164,11 @@ class BackupRepository @Inject constructor(
|
||||
return result
|
||||
}
|
||||
|
||||
suspend fun restoreFavourites(entry: BackupEntry): CompositeResult {
|
||||
suspend fun restoreFavourites(entry: BackupEntry, outProgress: FlowCollector<Progress>?): CompositeResult {
|
||||
val result = CompositeResult()
|
||||
for (item in entry.data.asTypedList<JSONObject>()) {
|
||||
val list = entry.data.asTypedList<JSONObject>()
|
||||
outProgress?.emit(Progress(progress = 0, total = list.size))
|
||||
for ((index, item) in list.withIndex()) {
|
||||
val mangaJson = item.getJSONObject("manga")
|
||||
val manga = JsonDeserializer(mangaJson).toMangaEntity()
|
||||
val tags = mangaJson.getJSONArray("tags").mapJSON {
|
||||
@@ -175,6 +182,7 @@ class BackupRepository @Inject constructor(
|
||||
db.getFavouritesDao().upsert(favourite)
|
||||
}
|
||||
}
|
||||
outProgress?.emit(Progress(progress = index, total = list.size))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -27,6 +27,10 @@ class CompositeResult {
|
||||
}
|
||||
}
|
||||
|
||||
operator fun plusAssign(error: Throwable) {
|
||||
errors.add(error)
|
||||
}
|
||||
|
||||
operator fun plusAssign(other: CompositeResult) {
|
||||
this.successCount += other.successCount
|
||||
this.errors += other.errors
|
||||
|
||||
@@ -54,6 +54,7 @@ import org.koitharu.kotatsu.list.ui.config.ListConfigBottomSheet
|
||||
import org.koitharu.kotatsu.list.ui.config.ListConfigSection
|
||||
import org.koitharu.kotatsu.local.ui.ImportDialogFragment
|
||||
import org.koitharu.kotatsu.local.ui.info.LocalInfoDialog
|
||||
import org.koitharu.kotatsu.main.ui.MainActivity
|
||||
import org.koitharu.kotatsu.main.ui.welcome.WelcomeSheet
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.model.MangaListFilter
|
||||
@@ -512,6 +513,8 @@ class AppRouter private constructor(
|
||||
|
||||
fun suggestionsIntent(context: Context) = Intent(context, SuggestionsActivity::class.java)
|
||||
|
||||
fun homeIntent(context: Context) = Intent(context, MainActivity::class.java)
|
||||
|
||||
fun mangaUpdatesIntent(context: Context) = Intent(context, UpdatesActivity::class.java)
|
||||
|
||||
fun readerSettingsIntent(context: Context) =
|
||||
@@ -561,9 +564,14 @@ class AppRouter private constructor(
|
||||
.putExtra(KEY_SOURCE, source.name)
|
||||
}
|
||||
|
||||
const val KEY_DATA = "data"
|
||||
const val KEY_ENTRIES = "entries"
|
||||
const val KEY_ERROR = "error"
|
||||
const val KEY_EXCLUDE = "exclude"
|
||||
const val KEY_FILE = "file"
|
||||
const val KEY_FILTER = "filter"
|
||||
const val KEY_ID = "id"
|
||||
const val KEY_INDEX = "index"
|
||||
const val KEY_LIST_SECTION = "list_section"
|
||||
const val KEY_MANGA = "manga"
|
||||
const val KEY_MANGA_LIST = "manga_list"
|
||||
@@ -573,12 +581,8 @@ class AppRouter private constructor(
|
||||
const val KEY_SOURCE = "source"
|
||||
const val KEY_TAB = "tab"
|
||||
const val KEY_TITLE = "title"
|
||||
const val KEY_USER_AGENT = "user_agent"
|
||||
const val KEY_URL = "url"
|
||||
const val KEY_ERROR = "error"
|
||||
const val KEY_FILE = "file"
|
||||
const val KEY_INDEX = "index"
|
||||
const val KEY_DATA = "data"
|
||||
const val KEY_USER_AGENT = "user_agent"
|
||||
|
||||
const val ACTION_HISTORY = "${BuildConfig.APPLICATION_ID}.action.MANAGE_HISTORY"
|
||||
const val ACTION_MANAGE_DOWNLOADS = "${BuildConfig.APPLICATION_ID}.action.MANAGE_DOWNLOADS"
|
||||
|
||||
@@ -14,6 +14,7 @@ import androidx.core.app.ServiceCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.net.toUri
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -42,6 +43,8 @@ abstract class CoroutineIntentService : BaseService() {
|
||||
intentJobContext.processIntent(intent)
|
||||
}
|
||||
}
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Throwable) {
|
||||
e.printStackTraceDebug()
|
||||
intentJobContext.onError(e)
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
package org.koitharu.kotatsu.core.util.ext
|
||||
|
||||
import android.annotation.TargetApi
|
||||
import android.content.ContentResolver
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.storage.StorageManager
|
||||
import android.provider.DocumentsContract
|
||||
import android.provider.OpenableColumns
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.core.net.toFile
|
||||
import org.koitharu.kotatsu.parsers.util.nullIfEmpty
|
||||
import org.koitharu.kotatsu.parsers.util.removeSuffix
|
||||
import java.io.File
|
||||
@@ -31,6 +34,21 @@ fun Uri.resolveFile(context: Context): File? {
|
||||
)
|
||||
}
|
||||
|
||||
fun ContentResolver.getFileDisplayName(uri: Uri): String? = runCatching {
|
||||
if (uri.isFileUri()) {
|
||||
return@runCatching uri.toFile().name
|
||||
}
|
||||
query(uri, null, null, null, null)?.use { cursor ->
|
||||
if (cursor.moveToFirst()) {
|
||||
cursor.getString(cursor.getColumnIndexOrThrow(OpenableColumns.DISPLAY_NAME))
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
}.onFailure { e ->
|
||||
e.printStackTraceDebug()
|
||||
}.getOrNull()
|
||||
|
||||
private fun getVolumePath(volumeId: String, context: Context): String? {
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
getVolumePathForAndroid11AndAbove(volumeId, context)
|
||||
@@ -63,7 +81,7 @@ private fun getVolumePathBeforeAndroid11(volumeId: String, context: Context): St
|
||||
it.printStackTraceDebug()
|
||||
}.getOrNull()
|
||||
|
||||
@TargetApi(Build.VERSION_CODES.R)
|
||||
@RequiresApi(Build.VERSION_CODES.R)
|
||||
private fun getVolumePathForAndroid11AndAbove(volumeId: String, context: Context): String? = runCatching {
|
||||
val storageManager = context.getSystemService(Context.STORAGE_SERVICE) as StorageManager
|
||||
storageManager.storageVolumes.firstNotNullOfOrNull { volume ->
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -97,9 +97,9 @@ class AppBackupAgent : BackupAgent() {
|
||||
}
|
||||
try {
|
||||
runBlocking {
|
||||
backup.getEntry(BackupEntry.Name.HISTORY)?.let { repository.restoreHistory(it) }
|
||||
backup.getEntry(BackupEntry.Name.HISTORY)?.let { repository.restoreHistory(it, null) }
|
||||
backup.getEntry(BackupEntry.Name.CATEGORIES)?.let { repository.restoreCategories(it) }
|
||||
backup.getEntry(BackupEntry.Name.FAVOURITES)?.let { repository.restoreFavourites(it) }
|
||||
backup.getEntry(BackupEntry.Name.FAVOURITES)?.let { repository.restoreFavourites(it, null) }
|
||||
backup.getEntry(BackupEntry.Name.BOOKMARKS)?.let { repository.restoreBookmarks(it) }
|
||||
backup.getEntry(BackupEntry.Name.SOURCES)?.let { repository.restoreSources(it) }
|
||||
backup.getEntry(BackupEntry.Name.SETTINGS)?.let { repository.restoreSettings(it) }
|
||||
|
||||
@@ -4,6 +4,7 @@ import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.Toast
|
||||
import androidx.core.view.isGone
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.fragment.app.viewModels
|
||||
@@ -11,7 +12,6 @@ import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.backup.CompositeResult
|
||||
import org.koitharu.kotatsu.core.nav.router
|
||||
import org.koitharu.kotatsu.core.ui.AlertDialogFragment
|
||||
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
||||
@@ -23,7 +23,6 @@ import org.koitharu.kotatsu.databinding.DialogRestoreBinding
|
||||
import java.text.DateFormat
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
@AndroidEntryPoint
|
||||
class RestoreDialogFragment : AlertDialogFragment<DialogRestoreBinding>(), OnListItemClickListener<BackupEntryModel>,
|
||||
@@ -43,8 +42,6 @@ class RestoreDialogFragment : AlertDialogFragment<DialogRestoreBinding>(), OnLis
|
||||
binding.buttonCancel.setOnClickListener(this)
|
||||
binding.buttonRestore.setOnClickListener(this)
|
||||
viewModel.availableEntries.observe(viewLifecycleOwner, adapter)
|
||||
viewModel.progress.observe(viewLifecycleOwner, this::onProgressChanged)
|
||||
viewModel.onRestoreDone.observeEvent(viewLifecycleOwner, this::onRestoreDone)
|
||||
viewModel.onError.observeEvent(viewLifecycleOwner, this::onError)
|
||||
combine(
|
||||
viewModel.isLoading,
|
||||
@@ -63,7 +60,15 @@ class RestoreDialogFragment : AlertDialogFragment<DialogRestoreBinding>(), OnLis
|
||||
override fun onClick(v: View) {
|
||||
when (v.id) {
|
||||
R.id.button_cancel -> dismiss()
|
||||
R.id.button_restore -> viewModel.restore()
|
||||
R.id.button_restore -> {
|
||||
if (startRestoreService()) {
|
||||
Toast.makeText(v.context, R.string.backup_restored_background, Toast.LENGTH_SHORT).show()
|
||||
router.closeWelcomeSheet()
|
||||
dismiss()
|
||||
} else {
|
||||
Toast.makeText(v.context, R.string.operation_not_supported, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,6 +92,14 @@ class RestoreDialogFragment : AlertDialogFragment<DialogRestoreBinding>(), OnLis
|
||||
}
|
||||
}
|
||||
|
||||
private fun startRestoreService(): Boolean {
|
||||
return RestoreService.start(
|
||||
context ?: return false,
|
||||
viewModel.uri ?: return false,
|
||||
viewModel.getCheckedEntries(),
|
||||
)
|
||||
}
|
||||
|
||||
private fun Date.formatBackupDate(): String {
|
||||
return getString(
|
||||
R.string.backup_date_,
|
||||
@@ -102,46 +115,4 @@ class RestoreDialogFragment : AlertDialogFragment<DialogRestoreBinding>(), OnLis
|
||||
.show()
|
||||
dismiss()
|
||||
}
|
||||
|
||||
private fun onProgressChanged(value: Float) {
|
||||
with(requireViewBinding().progressBar) {
|
||||
isVisible = true
|
||||
val wasIndeterminate = isIndeterminate
|
||||
isIndeterminate = value < 0
|
||||
if (value >= 0) {
|
||||
setProgressCompat((value * max).roundToInt(), !wasIndeterminate)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun onRestoreDone(result: CompositeResult) {
|
||||
val builder = MaterialAlertDialogBuilder(context ?: return)
|
||||
when {
|
||||
result.isEmpty -> {
|
||||
builder.setTitle(R.string.data_not_restored)
|
||||
.setMessage(R.string.data_not_restored_text)
|
||||
}
|
||||
|
||||
result.isAllSuccess -> {
|
||||
builder.setTitle(R.string.data_restored)
|
||||
.setMessage(R.string.data_restored_success)
|
||||
}
|
||||
|
||||
result.isAllFailed -> builder.setTitle(R.string.error)
|
||||
.setMessage(
|
||||
result.failures.map {
|
||||
it.getDisplayMessage(resources)
|
||||
}.distinct().joinToString("\n"),
|
||||
)
|
||||
|
||||
else -> builder.setTitle(R.string.data_restored)
|
||||
.setMessage(R.string.data_restored_with_errors)
|
||||
}
|
||||
builder.setPositiveButton(android.R.string.ok, null)
|
||||
.show()
|
||||
if (!result.isEmpty && !result.isAllFailed) {
|
||||
router.closeWelcomeSheet()
|
||||
}
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,14 +10,9 @@ import kotlinx.coroutines.runInterruptible
|
||||
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.BaseViewModel
|
||||
import org.koitharu.kotatsu.core.util.ext.MutableEventFlow
|
||||
import org.koitharu.kotatsu.core.util.ext.call
|
||||
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
||||
import org.koitharu.kotatsu.core.util.ext.toUriOrNull
|
||||
import org.koitharu.kotatsu.parsers.util.suspendlazy.suspendLazy
|
||||
import java.io.File
|
||||
import java.io.FileNotFoundException
|
||||
import java.util.Date
|
||||
@@ -32,30 +27,28 @@ class RestoreViewModel @Inject constructor(
|
||||
@ApplicationContext context: Context,
|
||||
) : BaseViewModel() {
|
||||
|
||||
private val backupInput = suspendLazy {
|
||||
val uri = savedStateHandle.get<String>(AppRouter.KEY_FILE)
|
||||
?.toUriOrNull() ?: throw FileNotFoundException()
|
||||
val contentResolver = context.contentResolver
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
val progress = MutableStateFlow(-1f)
|
||||
val onRestoreDone = MutableEventFlow<CompositeResult>()
|
||||
val uri = savedStateHandle.get<String>(AppRouter.KEY_FILE)?.toUriOrNull()
|
||||
private val contentResolver = context.contentResolver
|
||||
|
||||
val availableEntries = MutableStateFlow<List<BackupEntryModel>>(emptyList())
|
||||
val backupDate = MutableStateFlow<Date?>(null)
|
||||
|
||||
init {
|
||||
launchLoadingJob(Dispatchers.Default) {
|
||||
val backup = backupInput.get()
|
||||
loadBackupInfo()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun loadBackupInfo() {
|
||||
runInterruptible(Dispatchers.IO) {
|
||||
val tempFile = File.createTempFile("backup_", ".tmp")
|
||||
(uri?.let { contentResolver.openInputStream(it) } ?: throw FileNotFoundException()).use { input ->
|
||||
tempFile.outputStream().use { output ->
|
||||
input.copyTo(output)
|
||||
}
|
||||
}
|
||||
BackupZipInput.from(tempFile)
|
||||
}.use { backup ->
|
||||
val entries = backup.entries()
|
||||
availableEntries.value = BackupEntry.Name.entries.mapNotNull { entry ->
|
||||
if (entry == BackupEntry.Name.INDEX || entry !in entries) {
|
||||
@@ -71,15 +64,6 @@ class RestoreViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
runCatching {
|
||||
backupInput.peek()?.closeAndDelete()
|
||||
}.onFailure {
|
||||
it.printStackTraceDebug()
|
||||
}
|
||||
}
|
||||
|
||||
fun onItemClick(item: BackupEntryModel) {
|
||||
val map = availableEntries.value.associateByTo(EnumMap(BackupEntry.Name::class.java)) { it.name }
|
||||
map[item.name] = item.copy(isChecked = !item.isChecked)
|
||||
@@ -87,61 +71,10 @@ class RestoreViewModel @Inject constructor(
|
||||
availableEntries.value = map.values.sortedBy { it.name.ordinal }
|
||||
}
|
||||
|
||||
fun restore() {
|
||||
launchLoadingJob {
|
||||
val backup = backupInput.get()
|
||||
val checkedItems = availableEntries.value.mapNotNullTo(EnumSet.noneOf(BackupEntry.Name::class.java)) {
|
||||
if (it.isChecked) it.name else null
|
||||
}
|
||||
val result = CompositeResult()
|
||||
val step = 1f / 6f
|
||||
|
||||
progress.value = 0f
|
||||
if (BackupEntry.Name.HISTORY in checkedItems) {
|
||||
backup.getEntry(BackupEntry.Name.HISTORY)?.let {
|
||||
result += repository.restoreHistory(it)
|
||||
}
|
||||
}
|
||||
|
||||
progress.value += step
|
||||
if (BackupEntry.Name.CATEGORIES in checkedItems) {
|
||||
backup.getEntry(BackupEntry.Name.CATEGORIES)?.let {
|
||||
result += repository.restoreCategories(it)
|
||||
}
|
||||
}
|
||||
|
||||
progress.value += step
|
||||
if (BackupEntry.Name.FAVOURITES in checkedItems) {
|
||||
backup.getEntry(BackupEntry.Name.FAVOURITES)?.let {
|
||||
result += repository.restoreFavourites(it)
|
||||
}
|
||||
}
|
||||
|
||||
progress.value += step
|
||||
if (BackupEntry.Name.BOOKMARKS in checkedItems) {
|
||||
backup.getEntry(BackupEntry.Name.BOOKMARKS)?.let {
|
||||
result += repository.restoreBookmarks(it)
|
||||
}
|
||||
}
|
||||
|
||||
progress.value += step
|
||||
if (BackupEntry.Name.SOURCES in checkedItems) {
|
||||
backup.getEntry(BackupEntry.Name.SOURCES)?.let {
|
||||
result += repository.restoreSources(it)
|
||||
}
|
||||
}
|
||||
|
||||
progress.value += step
|
||||
if (BackupEntry.Name.SETTINGS in checkedItems) {
|
||||
backup.getEntry(BackupEntry.Name.SETTINGS)?.let {
|
||||
result += repository.restoreSettings(it)
|
||||
}
|
||||
}
|
||||
|
||||
progress.value = 1f
|
||||
onRestoreDone.call(result)
|
||||
fun getCheckedEntries(): Set<BackupEntry.Name> = availableEntries.value
|
||||
.mapNotNullTo(EnumSet.noneOf(BackupEntry.Name::class.java)) {
|
||||
if (it.isChecked) it.name else null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for inconsistent user selection
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -27,7 +27,7 @@ ksp = "2.0.21-1.0.28"
|
||||
leakcanary = "3.0-alpha-8"
|
||||
lifecycle = "2.8.7"
|
||||
markwon = "4.6.2"
|
||||
material = "1.13.0-alpha09"
|
||||
material = "1.13.0-alpha10"
|
||||
moshi = "1.15.2"
|
||||
okhttp = "4.12.0"
|
||||
okio = "3.10.2"
|
||||
|
||||
Reference in New Issue
Block a user