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,121 +60,13 @@ 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(
modifier = Modifier
.padding(contentPadding)
.fillMaxSize(),
) {
LazyColumn(
modifier = Modifier.weight(1f),
) {
if (DeviceUtil.isMiui && DeviceUtil.isMiuiOptimizationDisabled()) {
item {
@ -183,49 +74,167 @@ class RestoreBackupScreen : Screen() {
}
}
if (state.canRestore) {
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)
}
SectionCard {
RestoreOptions.options.forEach { option ->
LabeledCheckbox(
label = stringResource(option.label),
checked = option.getter(state.options),
onCheckedChange = {
model.toggle(option.setter, it)
},
) {
Text(stringResource(MR.strings.pref_restore_backup))
modifier = Modifier.padding(horizontal = MaterialTheme.padding.medium),
)
}
}
}
}
// TODO: show validation errors inline
// TODO: show options for what to restore
if (state.error != null) {
errorMessageItem(state, model)
}
}
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(),
)
}

View File

@ -19,13 +19,13 @@ import com.hippo.unifile.UniFile
import eu.kanade.tachiyomi.data.backup.BackupNotifier
import eu.kanade.tachiyomi.data.backup.restore.BackupRestoreJob
import eu.kanade.tachiyomi.data.notification.Notifications
import eu.kanade.tachiyomi.util.lang.asBooleanArray
import eu.kanade.tachiyomi.util.lang.asDataClass
import eu.kanade.tachiyomi.util.system.cancelNotification
import eu.kanade.tachiyomi.util.system.isRunning
import eu.kanade.tachiyomi.util.system.setForegroundSafely
import eu.kanade.tachiyomi.util.system.workManager
import logcat.LogPriority
import tachiyomi.core.util.lang.asBooleanArray
import tachiyomi.core.util.lang.asDataClass
import tachiyomi.core.util.system.logcat
import tachiyomi.domain.backup.service.BackupPreferences
import tachiyomi.domain.storage.service.StorageManager

View File

@ -13,8 +13,6 @@ import androidx.work.WorkerParameters
import androidx.work.workDataOf
import eu.kanade.tachiyomi.data.backup.BackupNotifier
import eu.kanade.tachiyomi.data.notification.Notifications
import eu.kanade.tachiyomi.util.lang.asBooleanArray
import eu.kanade.tachiyomi.util.lang.asDataClass
import eu.kanade.tachiyomi.util.system.cancelNotification
import eu.kanade.tachiyomi.util.system.isRunning
import eu.kanade.tachiyomi.util.system.setForegroundSafely
@ -22,6 +20,8 @@ import eu.kanade.tachiyomi.util.system.workManager
import kotlinx.coroutines.CancellationException
import logcat.LogPriority
import tachiyomi.core.i18n.stringResource
import tachiyomi.core.util.lang.asBooleanArray
import tachiyomi.core.util.lang.asDataClass
import tachiyomi.core.util.system.logcat
import tachiyomi.i18n.MR

View File

@ -1,7 +1,40 @@
package eu.kanade.tachiyomi.data.backup.restore
import dev.icerock.moko.resources.StringResource
import kotlinx.collections.immutable.persistentListOf
import tachiyomi.i18n.MR
data class RestoreOptions(
val library: Boolean = true,
val appSettings: Boolean = true,
val sourceSettings: Boolean = true,
val library: Boolean = true,
)
) {
fun anyEnabled() = library || appSettings || sourceSettings
companion object {
val options = persistentListOf(
Entry(
label = MR.strings.label_library,
getter = RestoreOptions::library,
setter = { options, enabled -> options.copy(library = enabled) },
),
Entry(
label = MR.strings.app_settings,
getter = RestoreOptions::appSettings,
setter = { options, enabled -> options.copy(appSettings = enabled) },
),
Entry(
label = MR.strings.source_settings,
getter = RestoreOptions::sourceSettings,
setter = { options, enabled -> options.copy(sourceSettings = enabled) },
),
)
}
data class Entry(
val label: StringResource,
val getter: (RestoreOptions) -> Boolean,
val setter: (RestoreOptions, Boolean) -> RestoreOptions,
)
}

View File

@ -33,6 +33,7 @@ dependencies {
implementation(libs.unifile)
implementation(kotlinx.reflect)
api(kotlinx.coroutines.core)
api(kotlinx.serialization.json)
api(kotlinx.serialization.json.okio)
@ -46,4 +47,6 @@ dependencies {
// JavaScript engine
implementation(libs.bundles.js.engine)
testImplementation(libs.bundles.test)
}

View File

@ -1,13 +1,15 @@
package eu.kanade.tachiyomi.util.lang
package tachiyomi.core.util.lang
import kotlin.reflect.KProperty1
import kotlin.reflect.full.declaredMemberProperties
import kotlin.reflect.full.primaryConstructor
fun <T : Any> T.asBooleanArray(): BooleanArray {
return this::class.declaredMemberProperties
val constructorParams = this::class.primaryConstructor!!.parameters.map { it.name }
val properties = this::class.declaredMemberProperties
.filterIsInstance<KProperty1<T, Boolean>>()
.map { it.get(this) }
return constructorParams
.map { param -> properties.find { it.name == param }!!.get(this) }
.toBooleanArray()
}

View File

@ -0,0 +1,48 @@
package tachiyomi.core.util.lang
import org.junit.jupiter.api.Assertions.assertArrayEquals
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.assertThrows
import org.junit.jupiter.api.parallel.Execution
import org.junit.jupiter.api.parallel.ExecutionMode
@Execution(ExecutionMode.CONCURRENT)
class BooleanArrayExtensionsTest {
@Test
fun `converts to boolean array`() {
assertArrayEquals(booleanArrayOf(true, false), TestClass(foo = true, bar = false).asBooleanArray())
assertArrayEquals(booleanArrayOf(false, true), TestClass(foo = false, bar = true).asBooleanArray())
}
@Test
fun `throws error for invalid data classes`() {
assertThrows<ClassCastException> {
InvalidTestClass(foo = true, bar = "").asBooleanArray()
}
}
@Test
fun `converts from boolean array`() {
assertEquals(booleanArrayOf(true, false).asDataClass<TestClass>(), TestClass(foo = true, bar = false))
assertEquals(booleanArrayOf(false, true).asDataClass<TestClass>(), TestClass(foo = false, bar = true))
}
@Test
fun `throws error for invalid boolean array`() {
assertThrows<IllegalArgumentException> {
booleanArrayOf(true).asDataClass<TestClass>()
}
}
data class TestClass(
val foo: Boolean,
val bar: Boolean,
)
data class InvalidTestClass(
val foo: Boolean,
val bar: String,
)
}

View File

@ -497,7 +497,7 @@
<string name="invalid_backup_file_missing_manga">Backup does not contain any library entries.</string>
<string name="backup_restore_missing_sources">Missing sources:</string>
<string name="backup_restore_missing_trackers">Trackers not logged into:</string>
<string name="backup_restore_content_full">Data from the backup file will be restored.\n\nYou will need to install any missing extensions and log in to tracking services afterwards to use them.</string>
<string name="backup_restore_content_full">Data from the backup file will be restored.\n\nYou may need to install any missing extensions and log in to tracking services afterwards to use them.</string>
<string name="restore_completed">Restore completed</string>
<string name="restore_duration">%02d min, %02d sec</string>
<string name="backup_in_progress">Backup is already in progress</string>