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:
parent
f3b7eaf4a3
commit
f0a0ecfd4a
10 changed files with 92 additions and 58 deletions
|
@ -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(
|
||||||
|
|
|
@ -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),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 }
|
||||||
|
|
|
@ -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 },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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(
|
||||||
|
|
|
@ -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) }
|
||||||
|
}
|
|
@ -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,
|
|
@ -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>
|
||||||
|
|
|
@ -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),
|
||||||
|
|
Reference in a new issue