Select which data will be restored from backup
This commit is contained in:
@@ -54,7 +54,11 @@ class AppBackupAgent : BackupAgent() {
|
||||
mtime: Long
|
||||
) {
|
||||
if (destination?.name?.endsWith(".bk.zip") == true) {
|
||||
restoreBackupFile(data.fileDescriptor, size, BackupRepository(MangaDatabase(applicationContext), AppSettings(applicationContext)))
|
||||
restoreBackupFile(
|
||||
data.fileDescriptor,
|
||||
size,
|
||||
BackupRepository(MangaDatabase(applicationContext), AppSettings(applicationContext)),
|
||||
)
|
||||
destination.delete()
|
||||
} else {
|
||||
super.onRestoreFile(data, size, destination, type, mode, mtime)
|
||||
@@ -87,12 +91,12 @@ class AppBackupAgent : BackupAgent() {
|
||||
val backup = BackupZipInput(tempFile)
|
||||
try {
|
||||
runBlocking {
|
||||
backup.getEntry(BackupEntry.HISTORY)?.let { repository.restoreHistory(it) }
|
||||
backup.getEntry(BackupEntry.CATEGORIES)?.let { repository.restoreCategories(it) }
|
||||
backup.getEntry(BackupEntry.FAVOURITES)?.let { repository.restoreFavourites(it) }
|
||||
backup.getEntry(BackupEntry.BOOKMARKS)?.let { repository.restoreBookmarks(it) }
|
||||
backup.getEntry(BackupEntry.SOURCES)?.let { repository.restoreSources(it) }
|
||||
backup.getEntry(BackupEntry.SETTINGS)?.let { repository.restoreSettings(it) }
|
||||
backup.getEntry(BackupEntry.Name.HISTORY)?.let { repository.restoreHistory(it) }
|
||||
backup.getEntry(BackupEntry.Name.CATEGORIES)?.let { repository.restoreCategories(it) }
|
||||
backup.getEntry(BackupEntry.Name.FAVOURITES)?.let { repository.restoreFavourites(it) }
|
||||
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) }
|
||||
}
|
||||
} finally {
|
||||
backup.close()
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
package org.koitharu.kotatsu.settings.backup
|
||||
|
||||
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
|
||||
import org.koitharu.kotatsu.core.ui.BaseListAdapter
|
||||
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
||||
import org.koitharu.kotatsu.core.util.ext.setChecked
|
||||
import org.koitharu.kotatsu.databinding.ItemCheckableMultipleBinding
|
||||
import org.koitharu.kotatsu.list.ui.ListModelDiffCallback.Companion.PAYLOAD_CHECKED_CHANGED
|
||||
import org.koitharu.kotatsu.list.ui.adapter.ListItemType
|
||||
|
||||
class BackupEntriesAdapter(
|
||||
clickListener: OnListItemClickListener<BackupEntryModel>,
|
||||
) : BaseListAdapter<BackupEntryModel>() {
|
||||
|
||||
init {
|
||||
addDelegate(ListItemType.NAV_ITEM, backupEntryAD(clickListener))
|
||||
}
|
||||
}
|
||||
|
||||
private fun backupEntryAD(
|
||||
clickListener: OnListItemClickListener<BackupEntryModel>,
|
||||
) = adapterDelegateViewBinding<BackupEntryModel, BackupEntryModel, ItemCheckableMultipleBinding>(
|
||||
{ layoutInflater, parent -> ItemCheckableMultipleBinding.inflate(layoutInflater, parent, false) },
|
||||
) {
|
||||
|
||||
binding.root.setOnClickListener { v ->
|
||||
clickListener.onItemClick(item, v)
|
||||
}
|
||||
|
||||
bind { payloads ->
|
||||
with(binding.root) {
|
||||
setText(item.titleResId)
|
||||
setChecked(item.isChecked, PAYLOAD_CHECKED_CHANGED in payloads)
|
||||
isEnabled = item.isEnabled
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package org.koitharu.kotatsu.settings.backup
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.backup.BackupEntry
|
||||
import org.koitharu.kotatsu.list.ui.ListModelDiffCallback
|
||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||
|
||||
data class BackupEntryModel(
|
||||
val name: BackupEntry.Name,
|
||||
val isChecked: Boolean,
|
||||
val isEnabled: Boolean,
|
||||
) : ListModel {
|
||||
|
||||
@get:StringRes
|
||||
val titleResId: Int
|
||||
get() = when (name) {
|
||||
BackupEntry.Name.INDEX -> 0 // should not appear here
|
||||
BackupEntry.Name.HISTORY -> R.string.history
|
||||
BackupEntry.Name.CATEGORIES -> R.string.favourites_categories
|
||||
BackupEntry.Name.FAVOURITES -> R.string.favourites
|
||||
BackupEntry.Name.SETTINGS -> R.string.settings
|
||||
BackupEntry.Name.BOOKMARKS -> R.string.bookmarks
|
||||
BackupEntry.Name.SOURCES -> R.string.remote_sources
|
||||
}
|
||||
|
||||
override fun areItemsTheSame(other: ListModel): Boolean {
|
||||
return other is BackupEntryModel && other.name == name
|
||||
}
|
||||
|
||||
override fun getChangePayload(previousState: ListModel): Any? {
|
||||
if (previousState !is BackupEntryModel) {
|
||||
return null
|
||||
}
|
||||
return if (previousState.isEnabled != isEnabled) {
|
||||
ListModelDiffCallback.PAYLOAD_ANYTHING_CHANGED
|
||||
} else if (previousState.isChecked != isChecked) {
|
||||
ListModelDiffCallback.PAYLOAD_CHECKED_CHANGED
|
||||
} else {
|
||||
super.getChangePayload(previousState)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,40 +3,58 @@ package org.koitharu.kotatsu.settings.backup
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.core.view.isGone
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.fragment.app.FragmentManager
|
||||
import androidx.fragment.app.viewModels
|
||||
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.ui.AlertDialogFragment
|
||||
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
||||
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
|
||||
import org.koitharu.kotatsu.core.util.ext.observe
|
||||
import org.koitharu.kotatsu.core.util.ext.observeEvent
|
||||
import org.koitharu.kotatsu.core.util.ext.textAndVisible
|
||||
import org.koitharu.kotatsu.core.util.ext.withArgs
|
||||
import org.koitharu.kotatsu.databinding.DialogProgressBinding
|
||||
import org.koitharu.kotatsu.databinding.DialogRestoreBinding
|
||||
import org.koitharu.kotatsu.main.ui.welcome.WelcomeSheet
|
||||
import java.text.DateFormat
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
@AndroidEntryPoint
|
||||
class RestoreDialogFragment : AlertDialogFragment<DialogProgressBinding>() {
|
||||
class RestoreDialogFragment : AlertDialogFragment<DialogRestoreBinding>(), OnListItemClickListener<BackupEntryModel>,
|
||||
View.OnClickListener {
|
||||
|
||||
private val viewModel: RestoreViewModel by viewModels()
|
||||
|
||||
override fun onCreateViewBinding(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
) = DialogProgressBinding.inflate(inflater, container, false)
|
||||
) = DialogRestoreBinding.inflate(inflater, container, false)
|
||||
|
||||
override fun onViewBindingCreated(binding: DialogProgressBinding, savedInstanceState: Bundle?) {
|
||||
override fun onViewBindingCreated(binding: DialogRestoreBinding, savedInstanceState: Bundle?) {
|
||||
super.onViewBindingCreated(binding, savedInstanceState)
|
||||
binding.textViewTitle.setText(R.string.restore_backup)
|
||||
binding.textViewSubtitle.setText(R.string.preparing_)
|
||||
|
||||
val adapter = BackupEntriesAdapter(this)
|
||||
binding.recyclerView.adapter = adapter
|
||||
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,
|
||||
viewModel.availableEntries,
|
||||
viewModel.backupDate,
|
||||
::Triple,
|
||||
).observe(viewLifecycleOwner, this::onLoadingChanged)
|
||||
}
|
||||
|
||||
override fun onBuildDialog(builder: MaterialAlertDialogBuilder): MaterialAlertDialogBuilder {
|
||||
@@ -44,6 +62,40 @@ class RestoreDialogFragment : AlertDialogFragment<DialogProgressBinding>() {
|
||||
.setCancelable(false)
|
||||
}
|
||||
|
||||
override fun onClick(v: View) {
|
||||
when (v.id) {
|
||||
R.id.button_cancel -> dismiss()
|
||||
R.id.button_restore -> viewModel.restore()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onItemClick(item: BackupEntryModel, view: View) {
|
||||
viewModel.onItemClick(item)
|
||||
}
|
||||
|
||||
private fun onLoadingChanged(value: Triple<Boolean, List<BackupEntryModel>, Date?>) {
|
||||
val (isLoading, entries, backupDate) = value
|
||||
val hasEntries = entries.isNotEmpty()
|
||||
with(requireViewBinding()) {
|
||||
progressBar.isVisible = isLoading
|
||||
recyclerView.isGone = isLoading
|
||||
textViewSubtitle.textAndVisible =
|
||||
when {
|
||||
!isLoading -> backupDate?.formatBackupDate()
|
||||
hasEntries -> getString(R.string.processing_)
|
||||
else -> getString(R.string.loading_)
|
||||
}
|
||||
buttonRestore.isEnabled = !isLoading && entries.any { it.isChecked }
|
||||
}
|
||||
}
|
||||
|
||||
private fun Date.formatBackupDate(): String {
|
||||
return getString(
|
||||
R.string.backup_date_,
|
||||
SimpleDateFormat.getDateTimeInstance(DateFormat.MEDIUM, DateFormat.SHORT).format(this),
|
||||
)
|
||||
}
|
||||
|
||||
private fun onError(e: Throwable) {
|
||||
MaterialAlertDialogBuilder(context ?: return)
|
||||
.setNegativeButton(R.string.close, null)
|
||||
@@ -89,6 +141,9 @@ class RestoreDialogFragment : AlertDialogFragment<DialogProgressBinding>() {
|
||||
}
|
||||
builder.setPositiveButton(android.R.string.ok, null)
|
||||
.show()
|
||||
if (!result.isEmpty && !result.isAllFailed) {
|
||||
WelcomeSheet.dismiss(parentFragmentManager)
|
||||
}
|
||||
dismiss()
|
||||
}
|
||||
|
||||
|
||||
@@ -15,8 +15,12 @@ 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.toUriOrNull
|
||||
import org.koitharu.kotatsu.parsers.util.SuspendLazy
|
||||
import java.io.File
|
||||
import java.io.FileNotFoundException
|
||||
import java.util.Date
|
||||
import java.util.EnumMap
|
||||
import java.util.EnumSet
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
@@ -26,63 +30,127 @@ class RestoreViewModel @Inject constructor(
|
||||
@ApplicationContext context: Context,
|
||||
) : BaseViewModel() {
|
||||
|
||||
private val backupInput = SuspendLazy {
|
||||
val uri = savedStateHandle.get<String>(RestoreDialogFragment.ARG_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(tempFile)
|
||||
}
|
||||
}
|
||||
|
||||
val progress = MutableStateFlow(-1f)
|
||||
val onRestoreDone = MutableEventFlow<CompositeResult>()
|
||||
|
||||
val availableEntries = MutableStateFlow<List<BackupEntryModel>>(emptyList())
|
||||
val backupDate = MutableStateFlow<Date?>(null)
|
||||
|
||||
init {
|
||||
launchLoadingJob {
|
||||
val uri = savedStateHandle.get<String>(RestoreDialogFragment.ARG_FILE)
|
||||
?.toUriOrNull() ?: throw FileNotFoundException()
|
||||
val contentResolver = context.contentResolver
|
||||
|
||||
val backup = runInterruptible(Dispatchers.IO) {
|
||||
val tempFile = File.createTempFile("backup_", ".tmp")
|
||||
(contentResolver.openInputStream(uri) ?: throw FileNotFoundException()).use { input ->
|
||||
tempFile.outputStream().use { output ->
|
||||
input.copyTo(output)
|
||||
}
|
||||
launchLoadingJob(Dispatchers.Default) {
|
||||
val backup = backupInput.get()
|
||||
val entries = backup.entries()
|
||||
availableEntries.value = BackupEntry.Name.entries.mapNotNull { entry ->
|
||||
if (entry == BackupEntry.Name.INDEX || entry !in entries) {
|
||||
return@mapNotNull null
|
||||
}
|
||||
BackupZipInput(tempFile)
|
||||
BackupEntryModel(
|
||||
name = entry,
|
||||
isChecked = true,
|
||||
isEnabled = true,
|
||||
)
|
||||
}
|
||||
try {
|
||||
val result = CompositeResult()
|
||||
val step = 1f/6f
|
||||
backupDate.value = repository.getBackupDate(backup.getEntry(BackupEntry.Name.INDEX))
|
||||
}
|
||||
}
|
||||
|
||||
progress.value = 0f
|
||||
backup.getEntry(BackupEntry.HISTORY)?.let {
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
backupInput.peek()?.cleanupAsync()
|
||||
}
|
||||
|
||||
fun onItemClick(item: BackupEntryModel) {
|
||||
val map = availableEntries.value.associateByTo(EnumMap(BackupEntry.Name::class.java)) { it.name }
|
||||
map[item.name] = item.copy(isChecked = !item.isChecked)
|
||||
map.validate()
|
||||
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
|
||||
backup.getEntry(BackupEntry.CATEGORIES)?.let {
|
||||
progress.value += step
|
||||
if (BackupEntry.Name.CATEGORIES in checkedItems) {
|
||||
backup.getEntry(BackupEntry.Name.CATEGORIES)?.let {
|
||||
result += repository.restoreCategories(it)
|
||||
}
|
||||
}
|
||||
|
||||
progress.value += step
|
||||
backup.getEntry(BackupEntry.FAVOURITES)?.let {
|
||||
progress.value += step
|
||||
if (BackupEntry.Name.FAVOURITES in checkedItems) {
|
||||
backup.getEntry(BackupEntry.Name.FAVOURITES)?.let {
|
||||
result += repository.restoreFavourites(it)
|
||||
}
|
||||
}
|
||||
|
||||
progress.value += step
|
||||
backup.getEntry(BackupEntry.BOOKMARKS)?.let {
|
||||
progress.value += step
|
||||
if (BackupEntry.Name.BOOKMARKS in checkedItems) {
|
||||
backup.getEntry(BackupEntry.Name.BOOKMARKS)?.let {
|
||||
result += repository.restoreBookmarks(it)
|
||||
}
|
||||
}
|
||||
|
||||
progress.value += step
|
||||
backup.getEntry(BackupEntry.SOURCES)?.let {
|
||||
progress.value += step
|
||||
if (BackupEntry.Name.SOURCES in checkedItems) {
|
||||
backup.getEntry(BackupEntry.Name.SOURCES)?.let {
|
||||
result += repository.restoreSources(it)
|
||||
}
|
||||
}
|
||||
|
||||
progress.value += step
|
||||
backup.getEntry(BackupEntry.SETTINGS)?.let {
|
||||
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)
|
||||
} finally {
|
||||
backup.close()
|
||||
backup.file.delete()
|
||||
progress.value = 1f
|
||||
onRestoreDone.call(result)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for inconsistent user selection
|
||||
* Favorites cannot be restored without categories
|
||||
*/
|
||||
private fun MutableMap<BackupEntry.Name, BackupEntryModel>.validate() {
|
||||
val favorites = this[BackupEntry.Name.FAVOURITES] ?: return
|
||||
val categories = this[BackupEntry.Name.CATEGORIES]
|
||||
if (categories?.isChecked == true) {
|
||||
if (!favorites.isEnabled) {
|
||||
this[BackupEntry.Name.FAVOURITES] = favorites.copy(isEnabled = true)
|
||||
}
|
||||
} else {
|
||||
if (favorites.isEnabled) {
|
||||
this[BackupEntry.Name.FAVOURITES] = favorites.copy(isEnabled = false, isChecked = false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user