Allow partial restores (library/settings)

Closes #3136
This commit is contained in:
arkon
2023-12-30 12:09:55 -05:00
parent 32c3269291
commit 5bba7af24a
9 changed files with 286 additions and 161 deletions

View File

@@ -1,6 +1,7 @@
package eu.kanade.presentation.more.settings.screen
import android.content.ActivityNotFoundException
import android.content.Context
import android.content.Intent
import android.net.Uri
import androidx.activity.compose.ManagedActivityResultLauncher
@@ -33,7 +34,9 @@ import eu.kanade.presentation.more.settings.widget.BasePreferenceWidget
import eu.kanade.presentation.more.settings.widget.PrefsHorizontalPadding
import eu.kanade.presentation.util.relativeTimeSpanString
import eu.kanade.tachiyomi.data.backup.create.BackupCreateJob
import eu.kanade.tachiyomi.data.backup.restore.BackupRestoreJob
import eu.kanade.tachiyomi.data.cache.ChapterCache
import eu.kanade.tachiyomi.util.system.DeviceUtil
import eu.kanade.tachiyomi.util.system.toast
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.persistentMapOf
@@ -139,6 +142,22 @@ object SettingsDataScreen : SearchableSettings {
val lastAutoBackup by backupPreferences.lastAutoBackupTimestamp().collectAsState()
val chooseBackup = rememberLauncherForActivityResult(
object : ActivityResultContracts.GetContent() {
override fun createIntent(context: Context, input: String): Intent {
val intent = super.createIntent(context, input)
return Intent.createChooser(intent, context.stringResource(MR.strings.file_select_backup))
}
},
) {
if (it == null) {
context.toast(MR.strings.file_null_uri_error)
return@rememberLauncherForActivityResult
}
navigator.push(RestoreBackupScreen(it))
}
return Preference.PreferenceGroup(
title = stringResource(MR.strings.label_backup),
preferenceItems = persistentListOf(
@@ -162,7 +181,18 @@ object SettingsDataScreen : SearchableSettings {
}
SegmentedButton(
checked = false,
onCheckedChange = { navigator.push(RestoreBackupScreen()) },
onCheckedChange = {
if (!BackupRestoreJob.isRunning(context)) {
if (DeviceUtil.isMiui && DeviceUtil.isMiuiOptimizationDisabled()) {
context.toast(MR.strings.restore_miui_warning)
}
// no need to catch because it's wrapped with a chooser
chooseBackup.launch("*/*")
} else {
context.toast(MR.strings.restore_in_progress)
}
},
shape = SegmentedButtonDefaults.itemShape(1, 2),
) {
Text(stringResource(MR.strings.pref_restore_backup))

View File

@@ -1,28 +1,26 @@
package eu.kanade.presentation.more.settings.screen.data
import android.content.Context
import android.content.Intent
import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material3.Button
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import cafe.adriel.voyager.core.model.StateScreenModel
import cafe.adriel.voyager.core.model.rememberScreenModel
import cafe.adriel.voyager.navigator.LocalNavigator
@@ -34,22 +32,23 @@ import eu.kanade.tachiyomi.data.backup.BackupFileValidator
import eu.kanade.tachiyomi.data.backup.restore.BackupRestoreJob
import eu.kanade.tachiyomi.data.backup.restore.RestoreOptions
import eu.kanade.tachiyomi.util.system.DeviceUtil
import eu.kanade.tachiyomi.util.system.copyToClipboard
import eu.kanade.tachiyomi.util.system.toast
import kotlinx.coroutines.flow.update
import tachiyomi.core.i18n.stringResource
import tachiyomi.i18n.MR
import tachiyomi.presentation.core.components.LabeledCheckbox
import tachiyomi.presentation.core.components.SectionCard
import tachiyomi.presentation.core.components.material.Scaffold
import tachiyomi.presentation.core.components.material.padding
import tachiyomi.presentation.core.i18n.stringResource
class RestoreBackupScreen : Screen() {
class RestoreBackupScreen(
private val uri: Uri,
) : Screen() {
@Composable
override fun Content() {
val context = LocalContext.current
val navigator = LocalNavigator.currentOrThrow
val model = rememberScreenModel { RestoreBackupScreenModel() }
val model = rememberScreenModel { RestoreBackupScreenModel(context, uri) }
val state by model.state.collectAsState()
Scaffold(
@@ -61,171 +60,181 @@ class RestoreBackupScreen : Screen() {
)
},
) { contentPadding ->
if (state.error != null) {
val onDismissRequest = model::clearError
when (val err = state.error) {
is InvalidRestore -> {
AlertDialog(
onDismissRequest = onDismissRequest,
title = { Text(text = stringResource(MR.strings.invalid_backup_file)) },
text = { Text(text = listOfNotNull(err.uri, err.message).joinToString("\n\n")) },
dismissButton = {
TextButton(
onClick = {
context.copyToClipboard(err.message, err.message)
onDismissRequest()
},
) {
Text(text = stringResource(MR.strings.action_copy_to_clipboard))
}
},
confirmButton = {
TextButton(onClick = onDismissRequest) {
Text(text = stringResource(MR.strings.action_ok))
}
},
)
}
is MissingRestoreComponents -> {
AlertDialog(
onDismissRequest = onDismissRequest,
title = { Text(text = stringResource(MR.strings.pref_restore_backup)) },
text = {
Column(
modifier = Modifier.verticalScroll(rememberScrollState()),
) {
val msg = buildString {
append(stringResource(MR.strings.backup_restore_content_full))
if (err.sources.isNotEmpty()) {
append(
"\n\n",
).append(stringResource(MR.strings.backup_restore_missing_sources))
err.sources.joinTo(
this,
separator = "\n- ",
prefix = "\n- ",
)
}
if (err.trackers.isNotEmpty()) {
append(
"\n\n",
).append(stringResource(MR.strings.backup_restore_missing_trackers))
err.trackers.joinTo(
this,
separator = "\n- ",
prefix = "\n- ",
)
}
}
Text(text = msg)
}
},
confirmButton = {
TextButton(
onClick = {
BackupRestoreJob.start(
context = context,
uri = err.uri,
options = state.options,
)
onDismissRequest()
},
) {
Text(text = stringResource(MR.strings.action_restore))
}
},
)
}
else -> onDismissRequest() // Unknown
}
}
val chooseBackup = rememberLauncherForActivityResult(
object : ActivityResultContracts.GetContent() {
override fun createIntent(context: Context, input: String): Intent {
val intent = super.createIntent(context, input)
return Intent.createChooser(intent, context.stringResource(MR.strings.file_select_backup))
}
},
) {
if (it == null) {
context.toast(MR.strings.file_null_uri_error)
return@rememberLauncherForActivityResult
}
val results = try {
BackupFileValidator(context).validate(it)
} catch (e: Exception) {
model.setError(InvalidRestore(it, e.message.toString()))
return@rememberLauncherForActivityResult
}
if (results.missingSources.isEmpty() && results.missingTrackers.isEmpty()) {
BackupRestoreJob.start(
context = context,
uri = it,
options = state.options,
)
return@rememberLauncherForActivityResult
}
model.setError(MissingRestoreComponents(it, results.missingSources, results.missingTrackers))
}
LazyColumn(
Column(
modifier = Modifier
.padding(contentPadding)
.fillMaxSize(),
) {
if (DeviceUtil.isMiui && DeviceUtil.isMiuiOptimizationDisabled()) {
item {
WarningBanner(MR.strings.restore_miui_warning)
LazyColumn(
modifier = Modifier.weight(1f),
) {
if (DeviceUtil.isMiui && DeviceUtil.isMiuiOptimizationDisabled()) {
item {
WarningBanner(MR.strings.restore_miui_warning)
}
}
}
item {
Button(
modifier = Modifier
.padding(horizontal = MaterialTheme.padding.medium)
.fillMaxWidth(),
onClick = {
if (!BackupRestoreJob.isRunning(context)) {
// no need to catch because it's wrapped with a chooser
chooseBackup.launch("*/*")
} else {
context.toast(MR.strings.restore_in_progress)
if (state.canRestore) {
item {
SectionCard {
RestoreOptions.options.forEach { option ->
LabeledCheckbox(
label = stringResource(option.label),
checked = option.getter(state.options),
onCheckedChange = {
model.toggle(option.setter, it)
},
modifier = Modifier.padding(horizontal = MaterialTheme.padding.medium),
)
}
}
},
) {
Text(stringResource(MR.strings.pref_restore_backup))
}
}
if (state.error != null) {
errorMessageItem(state, model)
}
}
// TODO: show validation errors inline
// TODO: show options for what to restore
HorizontalDivider()
Button(
enabled = state.canRestore && state.options.anyEnabled(),
modifier = Modifier
.padding(horizontal = 16.dp, vertical = 8.dp)
.fillMaxWidth(),
onClick = {
model.startRestore()
navigator.pop()
},
) {
Text(
text = stringResource(MR.strings.action_restore),
color = MaterialTheme.colorScheme.onPrimary,
)
}
}
}
}
private fun LazyListScope.errorMessageItem(
state: RestoreBackupScreenModel.State,
model: RestoreBackupScreenModel,
) {
item {
SectionCard {
Column(
modifier = Modifier.padding(horizontal = MaterialTheme.padding.medium),
verticalArrangement = Arrangement.spacedBy(MaterialTheme.padding.small),
) {
when (val err = state.error) {
is MissingRestoreComponents -> {
val msg = buildString {
append(stringResource(MR.strings.backup_restore_content_full))
if (err.sources.isNotEmpty()) {
append("\n\n")
append(stringResource(MR.strings.backup_restore_missing_sources))
err.sources.joinTo(
this,
separator = "\n- ",
prefix = "\n- ",
)
}
if (err.trackers.isNotEmpty()) {
append("\n\n")
append(stringResource(MR.strings.backup_restore_missing_trackers))
err.trackers.joinTo(
this,
separator = "\n- ",
prefix = "\n- ",
)
}
}
SelectionContainer {
Text(text = msg)
}
}
is InvalidRestore -> {
Text(text = stringResource(MR.strings.invalid_backup_file))
SelectionContainer {
Text(text = listOfNotNull(err.uri, err.message).joinToString("\n\n"))
}
}
else -> {
SelectionContainer {
Text(text = err.toString())
}
}
}
}
}
}
}
}
private class RestoreBackupScreenModel : StateScreenModel<RestoreBackupScreenModel.State>(State()) {
private class RestoreBackupScreenModel(
private val context: Context,
private val uri: Uri,
) : StateScreenModel<RestoreBackupScreenModel.State>(State()) {
fun setError(error: Any) {
init {
validate(uri)
}
private fun validate(uri: Uri) {
val results = try {
BackupFileValidator(context).validate(uri)
} catch (e: Exception) {
setError(
error = InvalidRestore(uri, e.message.toString()),
canRestore = false,
)
return
}
if (results.missingSources.isNotEmpty() || results.missingTrackers.isNotEmpty()) {
setError(
error = MissingRestoreComponents(uri, results.missingSources, results.missingTrackers),
canRestore = true,
)
return
}
setError(error = null, canRestore = true)
}
fun toggle(setter: (RestoreOptions, Boolean) -> RestoreOptions, enabled: Boolean) {
mutableState.update {
it.copy(error = error)
it.copy(
options = setter(it.options, enabled),
)
}
}
fun clearError() {
fun startRestore() {
BackupRestoreJob.start(
context = context,
uri = uri,
options = state.value.options,
)
}
private fun setError(error: Any?, canRestore: Boolean) {
mutableState.update {
it.copy(error = null)
it.copy(
error = error,
canRestore = canRestore,
)
}
}
@Immutable
data class State(
val error: Any? = null,
// TODO: allow user-selectable restore options
val canRestore: Boolean = false,
val options: RestoreOptions = RestoreOptions(),
)
}