diff --git a/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsDataScreen.kt b/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsDataScreen.kt index 46b37c9e89..964ea202ba 100644 --- a/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsDataScreen.kt +++ b/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)) diff --git a/app/src/main/java/eu/kanade/presentation/more/settings/screen/data/RestoreBackupScreen.kt b/app/src/main/java/eu/kanade/presentation/more/settings/screen/data/RestoreBackupScreen.kt index 2d6aebced9..c07aa47ef1 100644 --- a/app/src/main/java/eu/kanade/presentation/more/settings/screen/data/RestoreBackupScreen.kt +++ b/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)) - } - }, - ) - } - 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( + Column( modifier = Modifier .padding(contentPadding) .fillMaxSize(), ) { - if (DeviceUtil.isMiui && DeviceUtil.isMiuiOptimizationDisabled()) { - item { - WarningBanner(MR.strings.restore_miui_warning) + LazyColumn( + modifier = Modifier.weight(1f), + ) { + if (DeviceUtil.isMiui && DeviceUtil.isMiuiOptimizationDisabled()) { + item { + WarningBanner(MR.strings.restore_miui_warning) + } } - } - 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) + 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), + ) + } } - }, - ) { - Text(stringResource(MR.strings.pref_restore_backup)) + } + } + + if (state.error != null) { + errorMessageItem(state, model) } } - // TODO: show validation errors inline - // TODO: show options for what to restore + 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(State()) { +private class RestoreBackupScreenModel( + private val context: Context, + private val uri: Uri, +) : StateScreenModel(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(), ) } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/create/BackupCreateJob.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/create/BackupCreateJob.kt index ae546aa8fe..3d9af64032 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/backup/create/BackupCreateJob.kt +++ b/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 diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/restore/BackupRestoreJob.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/restore/BackupRestoreJob.kt index 470efcd83e..5d24e9fa63 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/backup/restore/BackupRestoreJob.kt +++ b/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 diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/restore/RestoreOptions.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/restore/RestoreOptions.kt index bd5bface9c..6905331fd9 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/backup/restore/RestoreOptions.kt +++ b/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, + ) +} diff --git a/core/build.gradle.kts b/core/build.gradle.kts index d629d74dc3..fd748d7000 100644 --- a/core/build.gradle.kts +++ b/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) } diff --git a/app/src/main/java/eu/kanade/tachiyomi/util/lang/BooleanArrayExtensions.kt b/core/src/main/java/tachiyomi/core/util/lang/BooleanArrayExtensions.kt similarity index 67% rename from app/src/main/java/eu/kanade/tachiyomi/util/lang/BooleanArrayExtensions.kt rename to core/src/main/java/tachiyomi/core/util/lang/BooleanArrayExtensions.kt index f7b5778893..b06cf6161e 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/util/lang/BooleanArrayExtensions.kt +++ b/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.asBooleanArray(): BooleanArray { - return this::class.declaredMemberProperties + val constructorParams = this::class.primaryConstructor!!.parameters.map { it.name } + val properties = this::class.declaredMemberProperties .filterIsInstance>() - .map { it.get(this) } + return constructorParams + .map { param -> properties.find { it.name == param }!!.get(this) } .toBooleanArray() } diff --git a/core/src/test/kotlin/tachiyomi/core/util/lang/BooleanArrayExtensionsTest.kt b/core/src/test/kotlin/tachiyomi/core/util/lang/BooleanArrayExtensionsTest.kt new file mode 100644 index 0000000000..59bf479e1d --- /dev/null +++ b/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 { + InvalidTestClass(foo = true, bar = "").asBooleanArray() + } + } + + @Test + fun `converts from boolean array`() { + assertEquals(booleanArrayOf(true, false).asDataClass(), TestClass(foo = true, bar = false)) + assertEquals(booleanArrayOf(false, true).asDataClass(), TestClass(foo = false, bar = true)) + } + + @Test + fun `throws error for invalid boolean array`() { + assertThrows { + booleanArrayOf(true).asDataClass() + } + } + + data class TestClass( + val foo: Boolean, + val bar: Boolean, + ) + + data class InvalidTestClass( + val foo: Boolean, + val bar: String, + ) +} diff --git a/i18n/src/commonMain/resources/MR/base/strings.xml b/i18n/src/commonMain/resources/MR/base/strings.xml index 8e77eaea9c..ab34beb794 100644 --- a/i18n/src/commonMain/resources/MR/base/strings.xml +++ b/i18n/src/commonMain/resources/MR/base/strings.xml @@ -497,7 +497,7 @@ Backup does not contain any library entries. Missing sources: Trackers not logged into: - 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. + 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. Restore completed %02d min, %02d sec Backup is already in progress