Browse Source

Allow partial restores (library/settings)

Closes #3136
arkon 1 year ago
parent
commit
5bba7af24a

+ 31 - 1
app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsDataScreen.kt

@@ -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))

+ 154 - 145
app/src/main/java/eu/kanade/presentation/more/settings/screen/data/RestoreBackupScreen.kt

@@ -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,171 +60,181 @@ 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))
-                                }
-                            },
-                        )
+            Column(
+                modifier = Modifier
+                    .padding(contentPadding)
+                    .fillMaxSize(),
+            ) {
+                LazyColumn(
+                    modifier = Modifier.weight(1f),
+                ) {
+                    if (DeviceUtil.isMiui && DeviceUtil.isMiuiOptimizationDisabled()) {
+                        item {
+                            WarningBanner(MR.strings.restore_miui_warning)
+                        }
                     }
-                    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))
+
+                    if (state.canRestore) {
+                        item {
+                            SectionCard {
+                                RestoreOptions.options.forEach { option ->
+                                    LabeledCheckbox(
+                                        label = stringResource(option.label),
+                                        checked = option.getter(state.options),
+                                        onCheckedChange = {
+                                            model.toggle(option.setter, it)
+                                        },
+                                        modifier = Modifier.padding(horizontal = MaterialTheme.padding.medium),
+                                    )
                                 }
-                            },
-                        )
+                            }
+                        }
                     }
-                    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 (state.error != null) {
+                        errorMessageItem(state, model)
                     }
-                },
-            ) {
-                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,
+                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,
                     )
-                    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)
-                    }
-                }
+    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))
 
-                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)
+                            SelectionContainer {
+                                Text(text = listOfNotNull(err.uri, err.message).joinToString("\n\n"))
                             }
-                        },
-                    ) {
-                        Text(stringResource(MR.strings.pref_restore_backup))
+                        }
+
+                        else -> {
+                            SelectionContainer {
+                                Text(text = err.toString())
+                            }
+                        }
                     }
                 }
-
-                // TODO: show validation errors inline
-                // TODO: show options for what to restore
             }
         }
     }
 }
 
-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(),
     )
 }

+ 2 - 2
app/src/main/java/eu/kanade/tachiyomi/data/backup/create/BackupCreateJob.kt

@@ -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

+ 2 - 2
app/src/main/java/eu/kanade/tachiyomi/data/backup/restore/BackupRestoreJob.kt

@@ -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
 

+ 35 - 2
app/src/main/java/eu/kanade/tachiyomi/data/backup/restore/RestoreOptions.kt

@@ -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,
+    )
+}

+ 3 - 0
core/build.gradle.kts

@@ -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)
 }

+ 5 - 3
app/src/main/java/eu/kanade/tachiyomi/util/lang/BooleanArrayExtensions.kt → core/src/main/java/tachiyomi/core/util/lang/BooleanArrayExtensions.kt

@@ -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()
 }
 

+ 48 - 0
core/src/test/kotlin/tachiyomi/core/util/lang/BooleanArrayExtensionsTest.kt

@@ -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,
+    )
+}

+ 1 - 1
i18n/src/commonMain/resources/MR/base/strings.xml

@@ -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>