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 package eu.kanade.presentation.more.settings.screen
import android.content.ActivityNotFoundException import android.content.ActivityNotFoundException
import android.content.Context
import android.content.Intent import android.content.Intent
import android.net.Uri import android.net.Uri
import androidx.activity.compose.ManagedActivityResultLauncher 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.more.settings.widget.PrefsHorizontalPadding
import eu.kanade.presentation.util.relativeTimeSpanString import eu.kanade.presentation.util.relativeTimeSpanString
import eu.kanade.tachiyomi.data.backup.create.BackupCreateJob 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.data.cache.ChapterCache
import eu.kanade.tachiyomi.util.system.DeviceUtil
import eu.kanade.tachiyomi.util.system.toast import eu.kanade.tachiyomi.util.system.toast
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.persistentMapOf import kotlinx.collections.immutable.persistentMapOf
@ -139,6 +142,22 @@ object SettingsDataScreen : SearchableSettings {
val lastAutoBackup by backupPreferences.lastAutoBackupTimestamp().collectAsState() 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( return Preference.PreferenceGroup(
title = stringResource(MR.strings.label_backup), title = stringResource(MR.strings.label_backup),
preferenceItems = persistentListOf( preferenceItems = persistentListOf(
@ -162,7 +181,18 @@ object SettingsDataScreen : SearchableSettings {
} }
SegmentedButton( SegmentedButton(
checked = false, 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), shape = SegmentedButtonDefaults.itemShape(1, 2),
) { ) {
Text(stringResource(MR.strings.pref_restore_backup)) Text(stringResource(MR.strings.pref_restore_backup))

View File

@ -1,28 +1,26 @@
package eu.kanade.presentation.more.settings.screen.data package eu.kanade.presentation.more.settings.screen.data
import android.content.Context import android.content.Context
import android.content.Intent
import android.net.Uri import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.compose.foundation.layout.Arrangement
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext 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.StateScreenModel
import cafe.adriel.voyager.core.model.rememberScreenModel import cafe.adriel.voyager.core.model.rememberScreenModel
import cafe.adriel.voyager.navigator.LocalNavigator 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.BackupRestoreJob
import eu.kanade.tachiyomi.data.backup.restore.RestoreOptions import eu.kanade.tachiyomi.data.backup.restore.RestoreOptions
import eu.kanade.tachiyomi.util.system.DeviceUtil 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 kotlinx.coroutines.flow.update
import tachiyomi.core.i18n.stringResource
import tachiyomi.i18n.MR 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.Scaffold
import tachiyomi.presentation.core.components.material.padding import tachiyomi.presentation.core.components.material.padding
import tachiyomi.presentation.core.i18n.stringResource import tachiyomi.presentation.core.i18n.stringResource
class RestoreBackupScreen : Screen() { class RestoreBackupScreen(
private val uri: Uri,
) : Screen() {
@Composable @Composable
override fun Content() { override fun Content() {
val context = LocalContext.current val context = LocalContext.current
val navigator = LocalNavigator.currentOrThrow val navigator = LocalNavigator.currentOrThrow
val model = rememberScreenModel { RestoreBackupScreenModel() } val model = rememberScreenModel { RestoreBackupScreenModel(context, uri) }
val state by model.state.collectAsState() val state by model.state.collectAsState()
Scaffold( Scaffold(
@ -61,171 +60,181 @@ class RestoreBackupScreen : Screen() {
) )
}, },
) { contentPadding -> ) { contentPadding ->
if (state.error != null) { Column(
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 modifier = Modifier
.padding(contentPadding) .padding(contentPadding)
.fillMaxSize(), .fillMaxSize(),
) { ) {
if (DeviceUtil.isMiui && DeviceUtil.isMiuiOptimizationDisabled()) { LazyColumn(
item { modifier = Modifier.weight(1f),
WarningBanner(MR.strings.restore_miui_warning) ) {
if (DeviceUtil.isMiui && DeviceUtil.isMiuiOptimizationDisabled()) {
item {
WarningBanner(MR.strings.restore_miui_warning)
}
} }
}
item { if (state.canRestore) {
Button( item {
modifier = Modifier SectionCard {
.padding(horizontal = MaterialTheme.padding.medium) RestoreOptions.options.forEach { option ->
.fillMaxWidth(), LabeledCheckbox(
onClick = { label = stringResource(option.label),
if (!BackupRestoreJob.isRunning(context)) { checked = option.getter(state.options),
// no need to catch because it's wrapped with a chooser onCheckedChange = {
chooseBackup.launch("*/*") model.toggle(option.setter, it)
} else { },
context.toast(MR.strings.restore_in_progress) 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 HorizontalDivider()
// TODO: show options for what to restore
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 { 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 { mutableState.update {
it.copy(error = null) it.copy(
error = error,
canRestore = canRestore,
)
} }
} }
@Immutable @Immutable
data class State( data class State(
val error: Any? = null, val error: Any? = null,
// TODO: allow user-selectable restore options val canRestore: Boolean = false,
val options: RestoreOptions = RestoreOptions(), 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.BackupNotifier
import eu.kanade.tachiyomi.data.backup.restore.BackupRestoreJob import eu.kanade.tachiyomi.data.backup.restore.BackupRestoreJob
import eu.kanade.tachiyomi.data.notification.Notifications 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.cancelNotification
import eu.kanade.tachiyomi.util.system.isRunning import eu.kanade.tachiyomi.util.system.isRunning
import eu.kanade.tachiyomi.util.system.setForegroundSafely import eu.kanade.tachiyomi.util.system.setForegroundSafely
import eu.kanade.tachiyomi.util.system.workManager import eu.kanade.tachiyomi.util.system.workManager
import logcat.LogPriority import logcat.LogPriority
import tachiyomi.core.util.lang.asBooleanArray
import tachiyomi.core.util.lang.asDataClass
import tachiyomi.core.util.system.logcat import tachiyomi.core.util.system.logcat
import tachiyomi.domain.backup.service.BackupPreferences import tachiyomi.domain.backup.service.BackupPreferences
import tachiyomi.domain.storage.service.StorageManager import tachiyomi.domain.storage.service.StorageManager

View File

@ -13,8 +13,6 @@ import androidx.work.WorkerParameters
import androidx.work.workDataOf import androidx.work.workDataOf
import eu.kanade.tachiyomi.data.backup.BackupNotifier import eu.kanade.tachiyomi.data.backup.BackupNotifier
import eu.kanade.tachiyomi.data.notification.Notifications 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.cancelNotification
import eu.kanade.tachiyomi.util.system.isRunning import eu.kanade.tachiyomi.util.system.isRunning
import eu.kanade.tachiyomi.util.system.setForegroundSafely import eu.kanade.tachiyomi.util.system.setForegroundSafely
@ -22,6 +20,8 @@ import eu.kanade.tachiyomi.util.system.workManager
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import logcat.LogPriority import logcat.LogPriority
import tachiyomi.core.i18n.stringResource 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.core.util.system.logcat
import tachiyomi.i18n.MR import tachiyomi.i18n.MR

View File

@ -1,7 +1,40 @@
package eu.kanade.tachiyomi.data.backup.restore 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( data class RestoreOptions(
val library: Boolean = true,
val appSettings: Boolean = true, val appSettings: Boolean = true,
val sourceSettings: 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(libs.unifile)
implementation(kotlinx.reflect)
api(kotlinx.coroutines.core) api(kotlinx.coroutines.core)
api(kotlinx.serialization.json) api(kotlinx.serialization.json)
api(kotlinx.serialization.json.okio) api(kotlinx.serialization.json.okio)
@ -46,4 +47,6 @@ dependencies {
// JavaScript engine // JavaScript engine
implementation(libs.bundles.js.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.KProperty1
import kotlin.reflect.full.declaredMemberProperties import kotlin.reflect.full.declaredMemberProperties
import kotlin.reflect.full.primaryConstructor import kotlin.reflect.full.primaryConstructor
fun <T : Any> T.asBooleanArray(): BooleanArray { 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>>() .filterIsInstance<KProperty1<T, Boolean>>()
.map { it.get(this) } return constructorParams
.map { param -> properties.find { it.name == param }!!.get(this) }
.toBooleanArray() .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="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_sources">Missing sources:</string>
<string name="backup_restore_missing_trackers">Trackers not logged into:</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_completed">Restore completed</string>
<string name="restore_duration">%02d min, %02d sec</string> <string name="restore_duration">%02d min, %02d sec</string>
<string name="backup_in_progress">Backup is already in progress</string> <string name="backup_in_progress">Backup is already in progress</string>