From c27586231aae61f32f99b5fbeb2ecab82ecca7fd Mon Sep 17 00:00:00 2001 From: Koitharu Date: Wed, 13 Dec 2023 14:37:05 +0200 Subject: [PATCH] Select which data will be restored from backup --- app/build.gradle | 2 +- .../kotatsu/core/backup/BackupEntry.kt | 20 +-- .../kotatsu/core/backup/BackupRepository.kt | 21 ++- .../kotatsu/core/backup/BackupZipInput.kt | 23 ++- .../kotatsu/core/backup/BackupZipOutput.kt | 2 +- .../kotatsu/main/ui/welcome/WelcomeSheet.kt | 6 + .../kotatsu/settings/backup/AppBackupAgent.kt | 18 ++- .../settings/backup/BackupEntriesAdapter.kt | 37 +++++ .../settings/backup/BackupEntryModel.kt | 43 ++++++ .../settings/backup/RestoreDialogFragment.kt | 69 ++++++++- .../settings/backup/RestoreViewModel.kt | 132 +++++++++++++----- app/src/main/res/layout/dialog_restore.xml | 78 +++++++++++ .../res/layout/item_checkable_multiple.xml | 1 + .../main/res/layout/item_checkable_single.xml | 1 + app/src/main/res/values/strings.xml | 2 + 15 files changed, 389 insertions(+), 66 deletions(-) create mode 100644 app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/BackupEntriesAdapter.kt create mode 100644 app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/BackupEntryModel.kt create mode 100644 app/src/main/res/layout/dialog_restore.xml diff --git a/app/build.gradle b/app/build.gradle index 293bbcce3..b9f376daa 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -82,7 +82,7 @@ afterEvaluate { } dependencies { //noinspection GradleDependency - implementation('com.github.KotatsuApp:kotatsu-parsers:97338f31c5') { + implementation('com.github.KotatsuApp:kotatsu-parsers:87f99addbb') { exclude group: 'org.json', module: 'json' } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/backup/BackupEntry.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/backup/BackupEntry.kt index 116d1fd74..ae92bff4e 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/backup/BackupEntry.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/backup/BackupEntry.kt @@ -3,18 +3,20 @@ package org.koitharu.kotatsu.core.backup import org.json.JSONArray class BackupEntry( - val name: String, + val name: Name, val data: JSONArray ) { - companion object Names { + enum class Name( + val key: String, + ) { - const val INDEX = "index" - const val HISTORY = "history" - const val CATEGORIES = "categories" - const val FAVOURITES = "favourites" - const val SETTINGS = "settings" - const val BOOKMARKS = "bookmarks" - const val SOURCES = "sources" + INDEX("index"), + HISTORY("history"), + CATEGORIES("categories"), + FAVOURITES("favourites"), + SETTINGS("settings"), + BOOKMARKS("bookmarks"), + SOURCES("sources"), } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/backup/BackupRepository.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/backup/BackupRepository.kt index 2444734b9..82ed23103 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/backup/BackupRepository.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/backup/BackupRepository.kt @@ -7,8 +7,10 @@ import org.koitharu.kotatsu.BuildConfig import org.koitharu.kotatsu.core.db.MangaDatabase import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.parsers.util.json.JSONIterator +import org.koitharu.kotatsu.parsers.util.json.getLongOrDefault import org.koitharu.kotatsu.parsers.util.json.mapJSON import org.koitharu.kotatsu.parsers.util.runCatchingCancellable +import java.util.Date import javax.inject.Inject private const val PAGE_SIZE = 10 @@ -20,7 +22,7 @@ class BackupRepository @Inject constructor( suspend fun dumpHistory(): BackupEntry { var offset = 0 - val entry = BackupEntry(BackupEntry.HISTORY, JSONArray()) + val entry = BackupEntry(BackupEntry.Name.HISTORY, JSONArray()) while (true) { val history = db.getHistoryDao().findAll(offset, PAGE_SIZE) if (history.isEmpty()) { @@ -41,7 +43,7 @@ class BackupRepository @Inject constructor( } suspend fun dumpCategories(): BackupEntry { - val entry = BackupEntry(BackupEntry.CATEGORIES, JSONArray()) + val entry = BackupEntry(BackupEntry.Name.CATEGORIES, JSONArray()) val categories = db.getFavouriteCategoriesDao().findAll() for (item in categories) { entry.data.put(JsonSerializer(item).toJson()) @@ -51,7 +53,7 @@ class BackupRepository @Inject constructor( suspend fun dumpFavourites(): BackupEntry { var offset = 0 - val entry = BackupEntry(BackupEntry.FAVOURITES, JSONArray()) + val entry = BackupEntry(BackupEntry.Name.FAVOURITES, JSONArray()) while (true) { val favourites = db.getFavouritesDao().findAll(offset, PAGE_SIZE) if (favourites.isEmpty()) { @@ -72,7 +74,7 @@ class BackupRepository @Inject constructor( } suspend fun dumpBookmarks(): BackupEntry { - val entry = BackupEntry(BackupEntry.BOOKMARKS, JSONArray()) + val entry = BackupEntry(BackupEntry.Name.BOOKMARKS, JSONArray()) val all = db.getBookmarksDao().findAll() for ((m, b) in all) { val json = JSONObject() @@ -90,7 +92,7 @@ class BackupRepository @Inject constructor( } fun dumpSettings(): BackupEntry { - val entry = BackupEntry(BackupEntry.SETTINGS, JSONArray()) + val entry = BackupEntry(BackupEntry.Name.SETTINGS, JSONArray()) val settingsDump = settings.getAllValues().toMutableMap() settingsDump.remove(AppSettings.KEY_APP_PASSWORD) settingsDump.remove(AppSettings.KEY_PROXY_PASSWORD) @@ -102,7 +104,7 @@ class BackupRepository @Inject constructor( } suspend fun dumpSources(): BackupEntry { - val entry = BackupEntry(BackupEntry.SOURCES, JSONArray()) + val entry = BackupEntry(BackupEntry.Name.SOURCES, JSONArray()) val all = db.getSourcesDao().findAll() for (source in all) { val json = JsonSerializer(source).toJson() @@ -112,7 +114,7 @@ class BackupRepository @Inject constructor( } fun createIndex(): BackupEntry { - val entry = BackupEntry(BackupEntry.INDEX, JSONArray()) + val entry = BackupEntry(BackupEntry.Name.INDEX, JSONArray()) val json = JSONObject() json.put("app_id", BuildConfig.APPLICATION_ID) json.put("app_version", BuildConfig.VERSION_CODE) @@ -121,6 +123,11 @@ class BackupRepository @Inject constructor( return entry } + fun getBackupDate(entry: BackupEntry?): Date? { + val timestamp = entry?.data?.optJSONObject(0)?.getLongOrDefault("created_at", 0) ?: 0 + return if (timestamp == 0L) null else Date(timestamp) + } + suspend fun restoreHistory(entry: BackupEntry): CompositeResult { val result = CompositeResult() for (item in entry.data.JSONIterator()) { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/backup/BackupZipInput.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/backup/BackupZipInput.kt index 5416836f9..61da0c264 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/backup/BackupZipInput.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/backup/BackupZipInput.kt @@ -1,25 +1,44 @@ package org.koitharu.kotatsu.core.backup +import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch import kotlinx.coroutines.runInterruptible import okio.Closeable import org.json.JSONArray +import org.koitharu.kotatsu.core.util.ext.processLifecycleScope import java.io.File +import java.util.EnumSet import java.util.zip.ZipFile class BackupZipInput(val file: File) : Closeable { private val zipFile = ZipFile(file) - suspend fun getEntry(name: String): BackupEntry? = runInterruptible(Dispatchers.IO) { - val entry = zipFile.getEntry(name) ?: return@runInterruptible null + suspend fun getEntry(name: BackupEntry.Name): BackupEntry? = runInterruptible(Dispatchers.IO) { + val entry = zipFile.getEntry(name.key) ?: return@runInterruptible null val json = zipFile.getInputStream(entry).use { JSONArray(it.bufferedReader().readText()) } BackupEntry(name, json) } + suspend fun entries(): Set = runInterruptible(Dispatchers.IO) { + zipFile.entries().toList().mapNotNullTo(EnumSet.noneOf(BackupEntry.Name::class.java)) { ze -> + BackupEntry.Name.entries.find { it.key == ze.name } + } + } + override fun close() { zipFile.close() } + + fun cleanupAsync() { + processLifecycleScope.launch(Dispatchers.IO, CoroutineStart.ATOMIC) { + runCatching { + close() + file.delete() + } + } + } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/backup/BackupZipOutput.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/backup/BackupZipOutput.kt index 199a751ae..97305905f 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/backup/BackupZipOutput.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/backup/BackupZipOutput.kt @@ -17,7 +17,7 @@ class BackupZipOutput(val file: File) : Closeable { private val output = ZipOutput(file, Deflater.BEST_COMPRESSION) suspend fun put(entry: BackupEntry) = runInterruptible(Dispatchers.IO) { - output.put(entry.name, entry.data.toString(2)) + output.put(entry.name.key, entry.data.toString(2)) } suspend fun finish() = runInterruptible(Dispatchers.IO) { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/main/ui/welcome/WelcomeSheet.kt b/app/src/main/kotlin/org/koitharu/kotatsu/main/ui/welcome/WelcomeSheet.kt index 7b0d824b1..a5f25dcef 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/main/ui/welcome/WelcomeSheet.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/main/ui/welcome/WelcomeSheet.kt @@ -123,5 +123,11 @@ class WelcomeSheet : BaseAdaptiveSheet(), ChipsView.OnChipC private const val TAG = "WelcomeSheet" fun show(fm: FragmentManager) = WelcomeSheet().showDistinct(fm, TAG) + + fun dismiss(fm: FragmentManager): Boolean { + val sheet = fm.findFragmentByTag(TAG) as? WelcomeSheet ?: return false + sheet.dismissAllowingStateLoss() + return true + } } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/AppBackupAgent.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/AppBackupAgent.kt index 9f24c2a75..2de76928c 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/AppBackupAgent.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/AppBackupAgent.kt @@ -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() diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/BackupEntriesAdapter.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/BackupEntriesAdapter.kt new file mode 100644 index 000000000..44ba6a831 --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/BackupEntriesAdapter.kt @@ -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, +) : BaseListAdapter() { + + init { + addDelegate(ListItemType.NAV_ITEM, backupEntryAD(clickListener)) + } +} + +private fun backupEntryAD( + clickListener: OnListItemClickListener, +) = adapterDelegateViewBinding( + { 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 + } + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/BackupEntryModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/BackupEntryModel.kt new file mode 100644 index 000000000..632807dec --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/BackupEntryModel.kt @@ -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) + } + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/RestoreDialogFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/RestoreDialogFragment.kt index 903b1ad30..d1515e841 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/RestoreDialogFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/RestoreDialogFragment.kt @@ -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() { +class RestoreDialogFragment : AlertDialogFragment(), OnListItemClickListener, + 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() { .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, 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() { } builder.setPositiveButton(android.R.string.ok, null) .show() + if (!result.isEmpty && !result.isAllFailed) { + WelcomeSheet.dismiss(parentFragmentManager) + } dismiss() } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/RestoreViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/RestoreViewModel.kt index 85c91cc0e..d1497f696 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/RestoreViewModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/RestoreViewModel.kt @@ -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(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() + val availableEntries = MutableStateFlow>(emptyList()) + val backupDate = MutableStateFlow(null) + init { - launchLoadingJob { - val uri = savedStateHandle.get(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, + ) + } + backupDate.value = repository.getBackupDate(backup.getEntry(BackupEntry.Name.INDEX)) + } + } + + 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 } - try { - val result = CompositeResult() - val step = 1f/6f + val result = CompositeResult() + val step = 1f / 6f - progress.value = 0f - backup.getEntry(BackupEntry.HISTORY)?.let { + 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) + } + } - progress.value = 1f - onRestoreDone.call(result) - } finally { - backup.close() - backup.file.delete() + /** + * Check for inconsistent user selection + * Favorites cannot be restored without categories + */ + private fun MutableMap.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) } } } diff --git a/app/src/main/res/layout/dialog_restore.xml b/app/src/main/res/layout/dialog_restore.xml new file mode 100644 index 000000000..45ff03f16 --- /dev/null +++ b/app/src/main/res/layout/dialog_restore.xml @@ -0,0 +1,78 @@ + + + + + + + + + + + + + +