Allow creating backups without library entries

- In case you want a backup of just settings?
- Also disable backup options if dependent option is disabled (and fix being able to toggle disabled items)
- Also fix crash in RestoreBackupScreen due to attempt to parcelize Uri
- Make restore validation message a bit nicer
This commit is contained in:
arkon 2023-12-30 16:02:36 -05:00
parent f3b7eaf4a3
commit f0a0ecfd4a
10 changed files with 92 additions and 58 deletions

View file

@ -155,7 +155,7 @@ object SettingsDataScreen : SearchableSettings {
return@rememberLauncherForActivityResult return@rememberLauncherForActivityResult
} }
navigator.push(RestoreBackupScreen(it)) navigator.push(RestoreBackupScreen(it.toString()))
} }
return Preference.PreferenceGroup( return Preference.PreferenceGroup(

View file

@ -92,19 +92,9 @@ class CreateBackupScreen : Screen() {
item { item {
SectionCard(MR.strings.label_library) { SectionCard(MR.strings.label_library) {
Column {
LabeledCheckbox(
label = stringResource(MR.strings.manga),
checked = true,
onCheckedChange = {},
enabled = false,
modifier = Modifier.padding(horizontal = MaterialTheme.padding.medium),
)
Options(BackupOptions.libraryOptions, state, model) Options(BackupOptions.libraryOptions, state, model)
} }
} }
}
item { item {
SectionCard(MR.strings.label_settings) { SectionCard(MR.strings.label_settings) {
@ -153,6 +143,7 @@ class CreateBackupScreen : Screen() {
onCheckedChange = { onCheckedChange = {
model.toggle(option.setter, it) model.toggle(option.setter, it)
}, },
enabled = option.enabled(state.options),
modifier = Modifier.padding(horizontal = MaterialTheme.padding.medium), modifier = Modifier.padding(horizontal = MaterialTheme.padding.medium),
) )
} }

View file

@ -20,7 +20,12 @@ 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.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.core.net.toUri
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
@ -33,6 +38,7 @@ 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 kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import tachiyomi.core.util.lang.anyEnabled
import tachiyomi.i18n.MR import tachiyomi.i18n.MR
import tachiyomi.presentation.core.components.LabeledCheckbox import tachiyomi.presentation.core.components.LabeledCheckbox
import tachiyomi.presentation.core.components.SectionCard import tachiyomi.presentation.core.components.SectionCard
@ -41,7 +47,7 @@ import tachiyomi.presentation.core.components.material.padding
import tachiyomi.presentation.core.i18n.stringResource import tachiyomi.presentation.core.i18n.stringResource
class RestoreBackupScreen( class RestoreBackupScreen(
private val uri: Uri, private val uri: String,
) : Screen() { ) : Screen() {
@Composable @Composable
@ -99,10 +105,10 @@ class RestoreBackupScreen(
HorizontalDivider() HorizontalDivider()
Button( Button(
enabled = state.canRestore && state.options.anyEnabled(),
modifier = Modifier modifier = Modifier
.padding(horizontal = 16.dp, vertical = 8.dp) .padding(horizontal = 16.dp, vertical = 8.dp)
.fillMaxWidth(), .fillMaxWidth(),
enabled = state.canRestore && state.options.anyEnabled(),
onClick = { onClick = {
model.startRestore() model.startRestore()
navigator.pop() navigator.pop()
@ -126,47 +132,56 @@ class RestoreBackupScreen(
modifier = Modifier.padding(horizontal = MaterialTheme.padding.medium), modifier = Modifier.padding(horizontal = MaterialTheme.padding.medium),
verticalArrangement = Arrangement.spacedBy(MaterialTheme.padding.small), verticalArrangement = Arrangement.spacedBy(MaterialTheme.padding.small),
) { ) {
val msg = buildAnnotatedString {
when (error) { when (error) {
is MissingRestoreComponents -> { is MissingRestoreComponents -> {
val msg = buildString { appendLine(stringResource(MR.strings.backup_restore_content_full))
append(stringResource(MR.strings.backup_restore_content_full))
if (error.sources.isNotEmpty()) { if (error.sources.isNotEmpty()) {
append("\n\n") appendLine()
append(stringResource(MR.strings.backup_restore_missing_sources)) withStyle(SpanStyle(fontWeight = FontWeight.Bold)) {
appendLine(stringResource(MR.strings.backup_restore_missing_sources))
}
error.sources.joinTo( error.sources.joinTo(
this, this,
separator = "\n- ", separator = "\n- ",
prefix = "\n- ", prefix = "- ",
) )
} }
if (error.trackers.isNotEmpty()) { if (error.trackers.isNotEmpty()) {
append("\n\n") appendLine()
append(stringResource(MR.strings.backup_restore_missing_trackers)) withStyle(SpanStyle(fontWeight = FontWeight.Bold)) {
appendLine(stringResource(MR.strings.backup_restore_missing_trackers))
}
error.trackers.joinTo( error.trackers.joinTo(
this, this,
separator = "\n- ", separator = "\n- ",
prefix = "\n- ", prefix = "- ",
) )
} }
} }
SelectionContainer {
Text(text = msg)
}
}
is InvalidRestore -> { is InvalidRestore -> {
Text(text = stringResource(MR.strings.invalid_backup_file)) withStyle(SpanStyle(fontWeight = FontWeight.Bold)) {
appendLine(stringResource(MR.strings.invalid_backup_file))
SelectionContainer {
Text(text = listOfNotNull(error.uri, error.message).joinToString("\n\n"))
} }
appendLine(error.uri.toString())
appendLine()
withStyle(SpanStyle(fontWeight = FontWeight.Bold)) {
appendLine(stringResource(MR.strings.invalid_backup_file_error))
}
appendLine(error.message)
} }
else -> { else -> {
appendLine(error.toString())
}
}
}
SelectionContainer { SelectionContainer {
Text(text = error.toString()) Text(text = msg)
}
}
} }
} }
} }
@ -176,11 +191,11 @@ class RestoreBackupScreen(
private class RestoreBackupScreenModel( private class RestoreBackupScreenModel(
private val context: Context, private val context: Context,
private val uri: Uri, private val uri: String,
) : StateScreenModel<RestoreBackupScreenModel.State>(State()) { ) : StateScreenModel<RestoreBackupScreenModel.State>(State()) {
init { init {
validate(uri) validate(uri.toUri())
} }
fun toggle(setter: (RestoreOptions, Boolean) -> RestoreOptions, enabled: Boolean) { fun toggle(setter: (RestoreOptions, Boolean) -> RestoreOptions, enabled: Boolean) {
@ -194,7 +209,7 @@ private class RestoreBackupScreenModel(
fun startRestore() { fun startRestore() {
BackupRestoreJob.start( BackupRestoreJob.start(
context = context, context = context,
uri = uri, uri = uri.toUri(),
options = state.value.options, options = state.value.options,
) )
} }

View file

@ -3,9 +3,7 @@ package eu.kanade.tachiyomi.data.backup
import android.content.Context import android.content.Context
import android.net.Uri import android.net.Uri
import eu.kanade.tachiyomi.data.track.TrackerManager import eu.kanade.tachiyomi.data.track.TrackerManager
import tachiyomi.core.i18n.stringResource
import tachiyomi.domain.source.service.SourceManager import tachiyomi.domain.source.service.SourceManager
import tachiyomi.i18n.MR
import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get import uy.kohesive.injekt.api.get
@ -19,7 +17,6 @@ class BackupFileValidator(
/** /**
* Checks for critical backup file data. * Checks for critical backup file data.
* *
* @throws Exception if manga cannot be found.
* @return List of missing sources or missing trackers. * @return List of missing sources or missing trackers.
*/ */
fun validate(uri: Uri): Results { fun validate(uri: Uri): Results {
@ -29,10 +26,6 @@ class BackupFileValidator(
throw IllegalStateException(e) throw IllegalStateException(e)
} }
if (backup.backupManga.isEmpty()) {
throw IllegalStateException(context.stringResource(MR.strings.invalid_backup_file_missing_manga))
}
val sources = backup.backupSources.associate { it.sourceId to it.name } val sources = backup.backupSources.associate { it.sourceId to it.name }
val missingSources = sources val missingSources = sources
.filter { sourceManager.get(it.key) == null } .filter { sourceManager.get(it.key) == null }

View file

@ -17,25 +17,34 @@ data class BackupOptions(
companion object { companion object {
val libraryOptions = persistentListOf( val libraryOptions = persistentListOf(
Entry(
label = MR.strings.manga,
getter = BackupOptions::libraryEntries,
setter = { options, enabled -> options.copy(libraryEntries = enabled) },
),
Entry( Entry(
label = MR.strings.categories, label = MR.strings.categories,
getter = BackupOptions::categories, getter = BackupOptions::categories,
setter = { options, enabled -> options.copy(categories = enabled) }, setter = { options, enabled -> options.copy(categories = enabled) },
enabled = { it.libraryEntries },
), ),
Entry( Entry(
label = MR.strings.chapters, label = MR.strings.chapters,
getter = BackupOptions::chapters, getter = BackupOptions::chapters,
setter = { options, enabled -> options.copy(chapters = enabled) }, setter = { options, enabled -> options.copy(chapters = enabled) },
enabled = { it.libraryEntries },
), ),
Entry( Entry(
label = MR.strings.track, label = MR.strings.track,
getter = BackupOptions::tracking, getter = BackupOptions::tracking,
setter = { options, enabled -> options.copy(tracking = enabled) }, setter = { options, enabled -> options.copy(tracking = enabled) },
enabled = { it.libraryEntries },
), ),
Entry( Entry(
label = MR.strings.history, label = MR.strings.history,
getter = BackupOptions::history, getter = BackupOptions::history,
setter = { options, enabled -> options.copy(history = enabled) }, setter = { options, enabled -> options.copy(history = enabled) },
enabled = { it.libraryEntries },
), ),
) )
@ -54,6 +63,7 @@ data class BackupOptions(
label = MR.strings.private_settings, label = MR.strings.private_settings,
getter = BackupOptions::privateSettings, getter = BackupOptions::privateSettings,
setter = { options, enabled -> options.copy(privateSettings = enabled) }, setter = { options, enabled -> options.copy(privateSettings = enabled) },
enabled = { it.appSettings || it.sourceSettings },
), ),
) )
} }
@ -62,5 +72,6 @@ data class BackupOptions(
val label: StringResource, val label: StringResource,
val getter: (BackupOptions) -> Boolean, val getter: (BackupOptions) -> Boolean,
val setter: (BackupOptions, Boolean) -> BackupOptions, val setter: (BackupOptions, Boolean) -> BackupOptions,
val enabled: (BackupOptions) -> Boolean = { true },
) )
} }

View file

@ -10,8 +10,6 @@ data class RestoreOptions(
val sourceSettings: Boolean = true, val sourceSettings: Boolean = true,
) { ) {
fun anyEnabled() = library || appSettings || sourceSettings
companion object { companion object {
val options = persistentListOf( val options = persistentListOf(
Entry( Entry(

View file

@ -18,3 +18,9 @@ inline fun <reified T : Any> BooleanArray.asDataClass(): T {
require(properties.size == this.size) { "Boolean array size does not match data class property count" } require(properties.size == this.size) { "Boolean array size does not match data class property count" }
return T::class.primaryConstructor!!.call(*this.toTypedArray()) return T::class.primaryConstructor!!.call(*this.toTypedArray())
} }
fun <T : Any> T.anyEnabled(): Boolean {
return this::class.declaredMemberProperties
.filterIsInstance<KProperty1<T, Boolean>>()
.any { it.get(this) }
}

View file

@ -2,40 +2,55 @@ package tachiyomi.core.util.lang
import org.junit.jupiter.api.Assertions.assertArrayEquals import org.junit.jupiter.api.Assertions.assertArrayEquals
import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
import org.junit.jupiter.api.assertThrows import org.junit.jupiter.api.assertThrows
import org.junit.jupiter.api.parallel.Execution import org.junit.jupiter.api.parallel.Execution
import org.junit.jupiter.api.parallel.ExecutionMode import org.junit.jupiter.api.parallel.ExecutionMode
@Execution(ExecutionMode.CONCURRENT) @Execution(ExecutionMode.CONCURRENT)
class BooleanArrayExtensionsTest { class BooleanDataClassExtensionsTest {
@Test @Test
fun `converts to boolean array`() { fun `asBooleanArray converts data class to boolean array`() {
assertArrayEquals(booleanArrayOf(true, false), TestClass(foo = true, bar = false).asBooleanArray()) assertArrayEquals(booleanArrayOf(true, false), TestClass(foo = true, bar = false).asBooleanArray())
assertArrayEquals(booleanArrayOf(false, true), TestClass(foo = false, bar = true).asBooleanArray()) assertArrayEquals(booleanArrayOf(false, true), TestClass(foo = false, bar = true).asBooleanArray())
} }
@Test @Test
fun `throws error for invalid data classes`() { fun `asBooleanArray throws error for invalid data classes`() {
assertThrows<ClassCastException> { assertThrows<ClassCastException> {
InvalidTestClass(foo = true, bar = "").asBooleanArray() InvalidTestClass(foo = true, bar = "").asBooleanArray()
} }
} }
@Test @Test
fun `converts from boolean array`() { fun `asDataClass converts from boolean array`() {
assertEquals(booleanArrayOf(true, false).asDataClass<TestClass>(), TestClass(foo = true, bar = false)) assertEquals(booleanArrayOf(true, false).asDataClass<TestClass>(), TestClass(foo = true, bar = false))
assertEquals(booleanArrayOf(false, true).asDataClass<TestClass>(), TestClass(foo = false, bar = true)) assertEquals(booleanArrayOf(false, true).asDataClass<TestClass>(), TestClass(foo = false, bar = true))
} }
@Test @Test
fun `throws error for invalid boolean array`() { fun `asDataClass throws error for invalid boolean array`() {
assertThrows<IllegalArgumentException> { assertThrows<IllegalArgumentException> {
booleanArrayOf(true).asDataClass<TestClass>() booleanArrayOf(true).asDataClass<TestClass>()
} }
} }
@Test
fun `anyEnabled returns based on if any boolean property is enabled`() {
assertTrue(TestClass(foo = false, bar = true).anyEnabled())
assertFalse(TestClass(foo = false, bar = false).anyEnabled())
}
@Test
fun `anyEnabled throws error for invalid class`() {
assertThrows<ClassCastException> {
InvalidTestClass(foo = true, bar = "").anyEnabled()
}
}
data class TestClass( data class TestClass(
val foo: Boolean, val foo: Boolean,
val bar: Boolean, val bar: Boolean,

View file

@ -493,7 +493,8 @@
<string name="pref_backup_interval">Automatic backup frequency</string> <string name="pref_backup_interval">Automatic backup frequency</string>
<string name="action_create">Create</string> <string name="action_create">Create</string>
<string name="backup_created">Backup created</string> <string name="backup_created">Backup created</string>
<string name="invalid_backup_file">Invalid backup file</string> <string name="invalid_backup_file">Invalid backup file:</string>
<string name="invalid_backup_file_error">Full error:</string>
<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>

View file

@ -31,7 +31,11 @@ fun LabeledCheckbox(
.heightIn(min = 48.dp) .heightIn(min = 48.dp)
.clickable( .clickable(
role = Role.Checkbox, role = Role.Checkbox,
onClick = { onCheckedChange(!checked) }, onClick = {
if (enabled) {
onCheckedChange(!checked)
}
},
), ),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(MaterialTheme.padding.small), horizontalArrangement = Arrangement.spacedBy(MaterialTheme.padding.small),