From 9f90ee358b8bee6713ef679aef7893f44fcc8f28 Mon Sep 17 00:00:00 2001 From: arkon Date: Thu, 21 Dec 2023 22:47:23 -0500 Subject: [PATCH] Initial move of restore backup into a separate screen --- .../settings/screen/SettingsDataScreen.kt | 211 +++------------ .../screen/data/RestoreBackupScreen.kt | 242 ++++++++++++++++++ .../data/backup/restore/BackupRestorer.kt | 6 +- 3 files changed, 280 insertions(+), 179 deletions(-) create mode 100644 app/src/main/java/eu/kanade/presentation/more/settings/screen/data/RestoreBackupScreen.kt diff --git a/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsDataScreen.kt b/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsDataScreen.kt index 210ce9296c..8ffc0e0769 100644 --- a/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsDataScreen.kt +++ b/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsDataScreen.kt @@ -1,28 +1,24 @@ package eu.kanade.presentation.more.settings.screen import android.content.ActivityNotFoundException -import android.content.Context import android.content.Intent import android.net.Uri import android.os.Environment import android.text.format.Formatter -import android.widget.Toast import androidx.activity.compose.ManagedActivityResultLauncher import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.AlertDialog +import androidx.compose.material3.MultiChoiceSegmentedButtonRow +import androidx.compose.material3.SegmentedButton +import androidx.compose.material3.SegmentedButtonDefaults import androidx.compose.material3.Text -import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.ReadOnlyComposable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue @@ -34,17 +30,13 @@ import cafe.adriel.voyager.navigator.currentOrThrow import com.hippo.unifile.UniFile import eu.kanade.presentation.more.settings.Preference import eu.kanade.presentation.more.settings.screen.data.CreateBackupScreen +import eu.kanade.presentation.more.settings.screen.data.RestoreBackupScreen 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.BackupFileValidator import eu.kanade.tachiyomi.data.backup.create.BackupCreateJob -import eu.kanade.tachiyomi.data.backup.restore.BackupRestoreJob -import eu.kanade.tachiyomi.data.backup.restore.RestoreOptions import eu.kanade.tachiyomi.data.cache.ChapterCache import eu.kanade.tachiyomi.util.storage.DiskUtil -import eu.kanade.tachiyomi.util.system.DeviceUtil -import eu.kanade.tachiyomi.util.system.copyToClipboard import eu.kanade.tachiyomi.util.system.toast import logcat.LogPriority import tachiyomi.core.i18n.stringResource @@ -142,14 +134,42 @@ object SettingsDataScreen : SearchableSettings { @Composable private fun getBackupAndRestoreGroup(backupPreferences: BackupPreferences): Preference.PreferenceGroup { val context = LocalContext.current + val navigator = LocalNavigator.currentOrThrow + val lastAutoBackup by backupPreferences.lastAutoBackupTimestamp().collectAsState() return Preference.PreferenceGroup( title = stringResource(MR.strings.label_backup), preferenceItems = listOf( // Manual actions - getCreateBackupPref(), - getRestoreBackupPref(), + Preference.PreferenceItem.CustomPreference( + title = stringResource(MR.strings.label_backup), + ) { + BasePreferenceWidget( + subcomponent = { + MultiChoiceSegmentedButtonRow( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = PrefsHorizontalPadding), + ) { + SegmentedButton( + checked = false, + onCheckedChange = { navigator.push(CreateBackupScreen()) }, + shape = SegmentedButtonDefaults.itemShape(0, 2), + ) { + Text(stringResource(MR.strings.pref_create_backup)) + } + SegmentedButton( + checked = false, + onCheckedChange = { navigator.push(RestoreBackupScreen()) }, + shape = SegmentedButtonDefaults.itemShape(1, 2), + ) { + Text(stringResource(MR.strings.pref_restore_backup)) + } + } + }, + ) + }, // Automatic backups Preference.PreferenceItem.ListPreference( @@ -176,156 +196,6 @@ object SettingsDataScreen : SearchableSettings { ) } - @Composable - private fun getCreateBackupPref(): Preference.PreferenceItem.TextPreference { - val navigator = LocalNavigator.currentOrThrow - return Preference.PreferenceItem.TextPreference( - title = stringResource(MR.strings.pref_create_backup), - subtitle = stringResource(MR.strings.pref_create_backup_summ), - onClick = { navigator.push(CreateBackupScreen()) }, - ) - } - - @Composable - private fun getRestoreBackupPref(): Preference.PreferenceItem.TextPreference { - val context = LocalContext.current - var error by remember { mutableStateOf(null) } - if (error != null) { - val onDismissRequest = { error = null } - when (val err = 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, - // TODO: allow user-selectable restore options - options = RestoreOptions( - appSettings = true, - sourceSettings = true, - library = true, - ), - ) - onDismissRequest() - }, - ) { - Text(text = stringResource(MR.strings.action_restore)) - } - }, - ) - } - else -> error = null // 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().validate(context, it) - } catch (e: Exception) { - error = InvalidRestore(it, e.message.toString()) - return@rememberLauncherForActivityResult - } - - if (results.missingSources.isEmpty() && results.missingTrackers.isEmpty()) { - BackupRestoreJob.start( - context = context, - uri = it, - // TODO: allow user-selectable restore options - options = RestoreOptions( - appSettings = true, - sourceSettings = true, - library = true, - ), - ) - return@rememberLauncherForActivityResult - } - - error = MissingRestoreComponents(it, results.missingSources, results.missingTrackers) - } - - return Preference.PreferenceItem.TextPreference( - title = stringResource(MR.strings.pref_restore_backup), - subtitle = stringResource(MR.strings.pref_restore_backup_summ), - onClick = { - if (!BackupRestoreJob.isRunning(context)) { - if (DeviceUtil.isMiui && DeviceUtil.isMiuiOptimizationDisabled()) { - context.toast(MR.strings.restore_miui_warning, Toast.LENGTH_LONG) - } - // no need to catch because it's wrapped with a chooser - chooseBackup.launch("*/*") - } else { - context.toast(MR.strings.restore_in_progress) - } - }, - ) - } - @Composable private fun getDataGroup(): Preference.PreferenceGroup { val scope = rememberCoroutineScope() @@ -394,14 +264,3 @@ object SettingsDataScreen : SearchableSettings { } } } - -private data class MissingRestoreComponents( - val uri: Uri, - val sources: List, - val trackers: List, -) - -private data class InvalidRestore( - val uri: Uri? = null, - val message: String, -) diff --git a/app/src/main/java/eu/kanade/presentation/more/settings/screen/data/RestoreBackupScreen.kt b/app/src/main/java/eu/kanade/presentation/more/settings/screen/data/RestoreBackupScreen.kt new file mode 100644 index 0000000000..f1f3be877a --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/more/settings/screen/data/RestoreBackupScreen.kt @@ -0,0 +1,242 @@ +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.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.material3.Button +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 cafe.adriel.voyager.core.model.StateScreenModel +import cafe.adriel.voyager.core.model.rememberScreenModel +import cafe.adriel.voyager.navigator.LocalNavigator +import cafe.adriel.voyager.navigator.currentOrThrow +import eu.kanade.presentation.components.AppBar +import eu.kanade.presentation.components.WarningBanner +import eu.kanade.presentation.util.Screen +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.material.Scaffold +import tachiyomi.presentation.core.components.material.padding +import tachiyomi.presentation.core.i18n.stringResource + +class RestoreBackupScreen : Screen() { + + @Composable + override fun Content() { + val context = LocalContext.current + val navigator = LocalNavigator.currentOrThrow + val model = rememberScreenModel { RestoreBackupScreenModel() } + val state by model.state.collectAsState() + + Scaffold( + topBar = { + AppBar( + title = stringResource(MR.strings.pref_restore_backup), + navigateUp = navigator::pop, + scrollBehavior = it, + ) + }, + ) { 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().validate(context, 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( + modifier = Modifier + .padding(contentPadding) + .fillMaxSize(), + ) { + 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) + } + }, + ) { + Text(stringResource(MR.strings.pref_restore_backup)) + } + } + + // TODO: show validation errors inline + // TODO: show options for what to restore + } + } + } +} + +private class RestoreBackupScreenModel : StateScreenModel(State()) { + + fun setError(error: Any) { + mutableState.update { + it.copy(error = error) + } + } + + fun clearError() { + mutableState.update { + it.copy(error = null) + } + } + + @Immutable + data class State( + val error: Any? = null, + // TODO: allow user-selectable restore options + val options: RestoreOptions = RestoreOptions(), + ) +} + +private data class MissingRestoreComponents( + val uri: Uri, + val sources: List, + val trackers: List, +) + +private data class InvalidRestore( + val uri: Uri? = null, + val message: String, +) diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/restore/BackupRestorer.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/restore/BackupRestorer.kt index 063f592de3..30133fbbd9 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/backup/restore/BackupRestorer.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/backup/restore/BackupRestorer.kt @@ -172,9 +172,9 @@ class BackupRestorer( } data class RestoreOptions( - val appSettings: Boolean, - val sourceSettings: Boolean, - val library: Boolean, + val appSettings: Boolean = true, + val sourceSettings: Boolean = true, + val library: Boolean = true, ) { fun toBooleanArray() = booleanArrayOf(appSettings, sourceSettings, library)