备份可选网络、本地、网络与本地同时备份

This commit is contained in:
HapeLee
2026-05-12 18:48:17 +08:00
parent 8a6d01d5d1
commit bc1d21f1f2
5 changed files with 176 additions and 109 deletions
@@ -14,7 +14,6 @@ import io.legado.app.help.DirectLinkUpload
import io.legado.app.help.config.AppConfig
import io.legado.app.help.config.LocalConfig
import io.legado.app.help.config.OldThemeConfig
import io.legado.app.help.config.PersonalizationThemeConfig
import io.legado.app.help.config.ReadBookConfig
import io.legado.app.help.coroutine.Coroutine
import io.legado.app.model.BookCover
@@ -84,7 +83,6 @@ object Backup {
ReadBookConfig.configFileName,
ReadBookConfig.shareConfigFileName,
OldThemeConfig.configFileName,
PersonalizationThemeConfig.configFileName,
BookCover.configFileName,
"config.xml"
)
@@ -125,15 +123,15 @@ object Backup {
}
}
suspend fun backupLocked(context: Context, path: String?) {
suspend fun backupLocked(context: Context, path: String?, mode: String = "both") {
mutex.withLock {
withContext(IO) {
backup(context, path)
backup(context, path, mode)
}
}
}
private suspend fun backup(context: Context, path: String?) {
private suspend fun backup(context: Context, path: String?, mode: String = "both") {
LogUtils.d(TAG, "开始备份 path:$path")
LocalConfig.lastBackup = System.currentTimeMillis()
val aes = BackupAES()
@@ -183,8 +181,6 @@ object Backup {
FileUtils.createFileIfNotExist(backupPath + File.separator + BookCover.configFileName)
.writeText(GSON.toJson(it))
}
FileUtils.createFileIfNotExist(backupPath + File.separator + PersonalizationThemeConfig.configFileName)
.writeText(PersonalizationThemeConfig.toJson())
currentCoroutineContext().ensureActive()
appCtx.getSharedPreferences(backupPath, "config")?.let { sp ->
sp.edit(commit = true) {
@@ -223,23 +219,27 @@ object Backup {
zipFileName
}
if (ZipUtils.zipFiles(paths, zipFilePath)) {
when {
path.isNullOrBlank() -> {
copyBackup(context.getExternalFilesDir(null)!!, backupFileName)
}
if (mode == "both" || mode == "local") {
when {
path.isNullOrBlank() -> {
copyBackup(context.getExternalFilesDir(null)!!, backupFileName)
}
path.isContentScheme() -> {
copyBackup(context, path.toUri(), backupFileName)
}
path.isContentScheme() -> {
copyBackup(context, path.toUri(), backupFileName)
}
else -> {
copyBackup(File(path), backupFileName)
else -> {
copyBackup(File(path), backupFileName)
}
}
}
try {
AppWebDav.backUpWebDav(zipFileName)
} catch (e: Exception) {
AppLog.put("上传备份至webdav失败\n$e", e)
if (mode == "both" || mode == "webdav") {
try {
AppWebDav.backUpWebDav(zipFileName)
} catch (e: Exception) {
AppLog.put("上传备份至webdav失败\n$e", e)
}
}
}
FileUtils.delete(backupPath)
@@ -23,7 +23,6 @@ object BackupConfig {
private const val readConfigKey = "readConfig"
private const val themeConfigKey = "themeConfig"
private const val coverConfigKey = "coverConfig"
private const val personalizationThemeConfigKey = "personalizationThemeConfig"
private const val localBookKey = "localBook"
//配置忽略key
@@ -31,7 +30,6 @@ object BackupConfig {
readConfigKey,
PreferKey.themeMode,
themeConfigKey,
personalizationThemeConfigKey,
coverConfigKey,
PreferKey.bookshelfLayout,
PreferKey.showRss,
@@ -44,7 +42,6 @@ object BackupConfig {
appCtx.getString(R.string.read_config),
appCtx.getString(R.string.theme_mode),
appCtx.getString(R.string.theme_config),
"深度个性化主题",
appCtx.getString(R.string.cover_config),
appCtx.getString(R.string.bookshelf_layout),
appCtx.getString(R.string.show_rss),
@@ -125,8 +122,6 @@ object BackupConfig {
get() = ignoreConfig[themeConfigKey] == true
private val ignoreCoverConfig: Boolean
get() = ignoreConfig[coverConfigKey] == true
val ignorePersonalizationThemeConfig: Boolean
get() = ignoreConfig[personalizationThemeConfigKey] == true
private val ignoreBookshelfLayout: Boolean
get() = ignoreConfig[PreferKey.bookshelfLayout] == true
private val ignoreShowRss: Boolean
@@ -50,6 +50,11 @@ object BackupConfig {
true
)
var backupSyncMode by prefDelegate(
PreferKey.backupSyncMode,
"both"
)
var backupPath by prefDelegate<String?>(
PreferKey.backupPath,
null
@@ -14,6 +14,9 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Cloud
import androidx.compose.material.icons.filled.Lan
import androidx.compose.material.icons.filled.PhoneAndroid
import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material.icons.filled.VisibilityOff
import androidx.compose.material3.ExperimentalMaterial3Api
@@ -33,6 +36,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringArrayResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
@@ -54,7 +58,10 @@ import io.legado.app.ui.widget.components.card.SelectionItemCard
import io.legado.app.ui.widget.components.checkBox.CheckboxItem
import io.legado.app.ui.widget.components.filePicker.FilePickerSheet
import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet
import io.legado.app.ui.widget.components.modalBottomSheet.OptionCard
import io.legado.app.ui.widget.components.modalBottomSheet.OptionSheet
import io.legado.app.ui.widget.components.settingItem.ClickableSettingItem
import io.legado.app.ui.widget.components.settingItem.DropdownListSettingItem
import io.legado.app.ui.widget.components.settingItem.InputSettingItem
import io.legado.app.ui.widget.components.settingItem.SwitchSettingItem
import io.legado.app.ui.widget.components.topbar.GlassMediumFlexibleTopAppBar
@@ -90,6 +97,8 @@ fun BackupConfigScreen(
var showBackupFilePicker by remember { mutableStateOf(false) }
var showRestoreFilePicker by remember { mutableStateOf(false) }
var showRestoreSheet by remember { mutableStateOf(false) }
var showRestoreOptionSheet by remember { mutableStateOf(false) }
var showBackupOptionSheet by remember { mutableStateOf(false) }
var backupNames by remember { mutableStateOf<List<String>>(emptyList()) }
var showLoadingDialog by remember { mutableStateOf(false) }
@@ -134,7 +143,7 @@ fun BackupConfigScreen(
}
BackupConfig.backupPath = path
if (path.isNotEmpty()) {
startBackup(path, context, viewModel, {
startBackup(path, context, viewModel, "both", {
showLoadingDialog = false
scope.launch {
snackbarHostState.showSnackbar(context.getString(R.string.backup_success))
@@ -285,6 +294,15 @@ fun BackupConfigScreen(
checked = BackupConfig.autoCheckNewBackup,
onCheckedChange = { BackupConfig.autoCheckNewBackup = it }
)
DropdownListSettingItem(
title = stringResource(R.string.backup_sync_mode),
description = stringResource(R.string.backup_sync_mode_summary),
selectedValue = BackupConfig.backupSyncMode,
displayEntries = stringArrayResource(R.array.backup_sync_mode),
entryValues = stringArrayResource(R.array.backup_sync_mode_value),
onValueChange = { BackupConfig.backupSyncMode = it }
)
}
SplicedColumnGroup(title = stringResource(R.string.backup_restore)) {
@@ -295,94 +313,16 @@ fun BackupConfigScreen(
onClick = { showBackupFilePicker = true }
)
val backupText = stringResource(R.string.backup)
ClickableSettingItem(
title = stringResource(R.string.backup),
description = stringResource(R.string.backup_summary),
onClick = {
val backupPath = BackupConfig.backupPath
if (backupPath.isNullOrEmpty()) {
showRestoreFilePicker = true
} else {
confirmDialogTitle = backupText
confirmDialogText = "确定要备份吗?"
onConfirmAction = {
if (backupPath.isContentScheme()) {
startBackup(backupPath, context, viewModel, {
showLoadingDialog = false
scope.launch {
snackbarHostState.showSnackbar(context.getString(R.string.backup_success))
}
}, { error ->
showLoadingDialog = false
scope.launch {
snackbarHostState.showSnackbar(
context.getString(
R.string.backup_fail,
error
)
)
}
})
} else {
PermissionsCompat.Builder()
.addPermissions(*Permissions.Group.STORAGE)
.rationale(R.string.tip_perm_request_storage)
.onGranted {
startBackup(backupPath, context, viewModel, {
showLoadingDialog = false
scope.launch {
snackbarHostState.showSnackbar(
context.getString(
R.string.backup_success
)
)
}
}, { error ->
showLoadingDialog = false
scope.launch {
snackbarHostState.showSnackbar(
context.getString(
R.string.backup_fail,
error
)
)
}
})
}
.request()
}
}
showConfirmDialog = true
}
}
onClick = { showBackupOptionSheet = true }
)
ClickableSettingItem(
title = stringResource(R.string.restore),
description = stringResource(R.string.restore_summary),
onClick = {
scope.launch {
showLoadingDialog = true
loadingText = "加载中"
try {
val names = viewModel.getBackupNames()
backupNames = names
showRestoreSheet = true
} catch (e: Exception) {
confirmDialogTitle = "恢复"
confirmDialogText =
"WebDavError\n${e.localizedMessage}\n将从本地备份恢复。"
onConfirmAction = {
restoreFileLauncher.launch(arrayOf("application/zip"))
}
showConfirmDialog = true
} finally {
showLoadingDialog = false
}
}
}
onClick = { showRestoreOptionSheet = true }
)
ClickableSettingItem(
@@ -499,6 +439,84 @@ fun BackupConfigScreen(
}
)
OptionSheet(
show = showBackupOptionSheet,
onDismissRequest = { showBackupOptionSheet = false },
title = stringResource(R.string.backup)
) {
OptionCard(
icon = Icons.Default.PhoneAndroid,
text = stringResource(R.string.backup_to_local),
onClick = {
showBackupOptionSheet = false
executeBackup("local", context, viewModel, scope, snackbarHostState, {
showLoadingDialog = false
}, { showLoadingDialog = true; loadingText = it })
}
)
OptionCard(
icon = Icons.Default.Cloud,
text = stringResource(R.string.backup_to_network),
onClick = {
showBackupOptionSheet = false
executeBackup("webdav", context, viewModel, scope, snackbarHostState, {
showLoadingDialog = false
}, { showLoadingDialog = true; loadingText = it })
}
)
OptionCard(
icon = Icons.Default.Lan,
text = stringResource(R.string.backup_to_local_and_network),
onClick = {
showBackupOptionSheet = false
executeBackup("both", context, viewModel, scope, snackbarHostState, {
showLoadingDialog = false
}, { showLoadingDialog = true; loadingText = it })
}
)
}
OptionSheet(
show = showRestoreOptionSheet,
onDismissRequest = { showRestoreOptionSheet = false },
title = stringResource(R.string.restore)
) {
OptionCard(
icon = Icons.Default.PhoneAndroid,
text = "本地恢复",
onClick = {
showRestoreOptionSheet = false
restoreFileLauncher.launch(arrayOf("application/zip"))
}
)
OptionCard(
icon = Icons.Default.Cloud,
text = "网络恢复",
onClick = {
showRestoreOptionSheet = false
scope.launch {
showLoadingDialog = true
loadingText = "加载中"
try {
val names = viewModel.getBackupNames()
backupNames = names
showRestoreSheet = true
} catch (e: Exception) {
confirmDialogTitle = "恢复"
confirmDialogText =
"WebDavError\n${e.localizedMessage}\n将从本地备份恢复。"
onConfirmAction = {
restoreFileLauncher.launch(arrayOf("application/zip"))
}
showConfirmDialog = true
} finally {
showLoadingDialog = false
}
}
}
)
}
AppModalBottomSheet(
show = showRestoreSheet && backupNames.isNotEmpty(),
onDismissRequest = { showRestoreSheet = false },
@@ -621,8 +639,57 @@ private fun startBackup(
path: String,
context: Context,
viewModel: BackupConfigViewModel,
mode: String = "both",
onSuccess: () -> Unit,
onError: (String) -> Unit
) {
viewModel.backup(path, onSuccess, onError)
viewModel.backup(path, mode, onSuccess, onError)
}
private fun executeBackup(
mode: String,
context: Context,
viewModel: BackupConfigViewModel,
scope: kotlinx.coroutines.CoroutineScope,
snackbarHostState: SnackbarHostState,
hideLoading: () -> Unit,
showLoading: (String) -> Unit
) {
val backupPath = BackupConfig.backupPath
if (backupPath.isNullOrEmpty() && mode != "webdav") {
return
}
val path = backupPath ?: ""
showLoading(context.getString(R.string.backup))
if (path.isNotEmpty() && !path.isContentScheme()) {
PermissionsCompat.Builder()
.addPermissions(*Permissions.Group.STORAGE)
.rationale(R.string.tip_perm_request_storage)
.onGranted {
startBackup(path, context, viewModel, mode, {
hideLoading()
scope.launch {
snackbarHostState.showSnackbar(context.getString(R.string.backup_success))
}
}, { error ->
hideLoading()
scope.launch {
snackbarHostState.showSnackbar(context.getString(R.string.backup_fail, error))
}
})
}
.request()
} else {
startBackup(path, context, viewModel, mode, {
hideLoading()
scope.launch {
snackbarHostState.showSnackbar(context.getString(R.string.backup_success))
}
}, { error ->
hideLoading()
scope.launch {
snackbarHostState.showSnackbar(context.getString(R.string.backup_fail, error))
}
})
}
}
@@ -46,10 +46,10 @@ class BackupConfigViewModel(
}
}
fun backup(backupPath: String, onSuccess: () -> Unit, onError: (String) -> Unit) {
fun backup(backupPath: String, mode: String = "both", onSuccess: () -> Unit, onError: (String) -> Unit) {
viewModelScope.launch(Dispatchers.IO) {
try {
Backup.backupLocked(appCtx, backupPath)
Backup.backupLocked(appCtx, backupPath, mode)
withContext(Dispatchers.Main) {
onSuccess()
}