Select which data will be restored from backup
This commit is contained in:
@@ -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"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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<BackupEntry.Name> = 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -123,5 +123,11 @@ class WelcomeSheet : BaseAdaptiveSheet<SheetWelcomeBinding>(), 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
78
app/src/main/res/layout/dialog_restore.xml
Normal file
78
app/src/main/res/layout/dialog_restore.xml
Normal file
@@ -0,0 +1,78 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="vertical"
|
||||
android:paddingVertical="?dialogPreferredPadding">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/textView_title"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginHorizontal="?dialogPreferredPadding"
|
||||
android:paddingBottom="@dimen/margin_normal"
|
||||
android:text="@string/restore_backup"
|
||||
android:textAppearance="?textAppearanceTitleLarge" />
|
||||
|
||||
<com.google.android.material.progressindicator.LinearProgressIndicator
|
||||
android:id="@+id/progressBar"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginHorizontal="?dialogPreferredPadding"
|
||||
android:indeterminate="true"
|
||||
android:max="100"
|
||||
android:visibility="gone"
|
||||
tools:visibility="visible" />
|
||||
|
||||
<androidx.recyclerview.widget.RecyclerView
|
||||
android:id="@+id/recyclerView"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:scrollIndicators="top|bottom"
|
||||
android:visibility="gone"
|
||||
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
|
||||
tools:itemCount="6"
|
||||
tools:listitem="@layout/item_checkable_multiple"
|
||||
tools:visibility="visible" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/textView_subtitle"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginHorizontal="?dialogPreferredPadding"
|
||||
android:layout_marginTop="@dimen/margin_small"
|
||||
android:textAppearance="?attr/textAppearanceLabelMedium"
|
||||
android:textColor="?android:textColorSecondary"
|
||||
tools:text="@tools:sample/lorem[10]" />
|
||||
|
||||
<LinearLayout
|
||||
style="?buttonBarStyle"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginHorizontal="?dialogPreferredPadding"
|
||||
android:layout_marginTop="@dimen/margin_normal"
|
||||
android:gravity="end"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<Button
|
||||
android:id="@+id/button_cancel"
|
||||
style="?buttonBarButtonStyle"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@android:string/cancel" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/button_restore"
|
||||
style="?buttonBarButtonStyle"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/restore" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</LinearLayout>
|
||||
@@ -5,6 +5,7 @@
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="?android:listPreferredItemHeightSmall"
|
||||
android:background="?selectableItemBackground"
|
||||
android:drawablePadding="?android:listPreferredItemPaddingStart"
|
||||
android:gravity="center_vertical"
|
||||
android:paddingStart="?android:listPreferredItemPaddingStart"
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="?android:listPreferredItemHeightSmall"
|
||||
android:background="?selectableItemBackground"
|
||||
android:drawablePadding="?android:listPreferredItemPaddingStart"
|
||||
android:gravity="center_vertical"
|
||||
android:paddingStart="?android:listPreferredItemPaddingStart"
|
||||
|
||||
@@ -546,4 +546,6 @@
|
||||
<string name="disable_battery_optimization_summary_downloads">Might help with getting the download started if you have any issues with it</string>
|
||||
<string name="welcome_text">Please select which content sources you would like to enable. This can also be configured later in settings</string>
|
||||
<string name="sync_auth">Login to sync account</string>
|
||||
<string name="restore">Restore</string>
|
||||
<string name="backup_date_">Backup date: %s</string>
|
||||
</resources>
|
||||
|
||||
Reference in New Issue
Block a user