Restore backups in background

This commit is contained in:
Koitharu
2025-02-03 17:14:30 +02:00
parent c37f795dac
commit 008f2d705a
14 changed files with 1195 additions and 914 deletions

View File

@@ -55,7 +55,7 @@ class StrictModeNotifier(
.setContentIntent(
PendingIntentCompat.getActivity(
context,
0,
violation.hashCode(),
ShareHelper(context).getShareTextIntent(violation.stackTraceToString()),
0,
false,

View File

@@ -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"

View File

@@ -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
}

View File

@@ -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

View File

@@ -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"

View File

@@ -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)

View File

@@ -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 ->

View File

@@ -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()
}

View File

@@ -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) }

View File

@@ -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()
}
}

View File

@@ -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
}
}
}

View File

@@ -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

View File

@@ -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"