fix(ui): 进度展示语义化与失败可见性
修复备份工具用户判断数据安全时的多个误导问题:
- 单 app 完成不再 emit "done",改用 "appdone" → 显示"已完成"
原行为:50 个 app 备份过程中 UI 反复闪"完成",用户易误判结束、杀进程
- restic 恢复接入 onProgress:解析"恢复进度: N%",进度条动起来
原行为:GB 级快照下载时 UI 卡死在 0/N,像挂掉
- 失败时进度条/计数走 error 色,progressCurrent 只算成功数
原行为:3/10 成功也显示"完成 (10/10)",掩盖 7 个失败
- 流式备份正则放宽到 (\d{1,3})(?:\.\d+)?% + coerceIn(0,1)
原行为:restic 输出"100%"不匹配,最后一步反馈丢失
- restic 恢复失败清空 selectedSnapshot/packages,避免半残状态
- 抽公共 ProgressBlock 组件,BackupScreen/RestoreScreen 各 65 行重复 → 1 个调用
- catch/finally 完整重置 progress 字段
- 新增 StageDisplayNameTest(11 个测试)含 partial≠done 回归
This commit is contained in:
@@ -36,7 +36,7 @@ object BackupOperation {
|
||||
val current: Int,
|
||||
val total: Int,
|
||||
val packageName: String,
|
||||
val stage: String, // "apk", "data", "obb", "ssaid", "done"
|
||||
val stage: String, // "apk", "data", "obb", "ssaid", "appdone" (per-app finish), "done" (reserved for overall)
|
||||
val message: String,
|
||||
)
|
||||
|
||||
@@ -189,7 +189,7 @@ object BackupOperation {
|
||||
failAtomic.incrementAndGet()
|
||||
val pkg = app.packageName.value
|
||||
Log.e(TAG, "backupApps: $pkg backup failed: ${e.message}", e)
|
||||
emit(BackupProgress(index + 1, totalCount, pkg, "done", "备份失败: ${e.message}"))
|
||||
emit(BackupProgress(index + 1, totalCount, pkg, "appdone", "备份失败: ${e.message}"))
|
||||
}
|
||||
}
|
||||
}.awaitAll()
|
||||
@@ -350,7 +350,7 @@ object BackupOperation {
|
||||
userDeSize = udResult.second
|
||||
if (udResult.first == null) {
|
||||
failAtomic.incrementAndGet()
|
||||
emit(BackupProgress(index + 1, totalCount, pkgName, "done", "数据备份失败"))
|
||||
emit(BackupProgress(index + 1, totalCount, pkgName, "appdone", "数据备份失败"))
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -366,7 +366,7 @@ object BackupOperation {
|
||||
obbSize = BackupAppDataOps.backupObb(pkgName, appDir, config.compressionMethod)
|
||||
if (obbSize == null) {
|
||||
failAtomic.incrementAndGet()
|
||||
emit(BackupProgress(index + 1, totalCount, pkgName, "done", "OBB 备份失败"))
|
||||
emit(BackupProgress(index + 1, totalCount, pkgName, "appdone", "OBB 备份失败"))
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -422,7 +422,7 @@ object BackupOperation {
|
||||
)
|
||||
|
||||
successAtomic.incrementAndGet()
|
||||
emit(BackupProgress(index + 1, totalCount, pkgName, "done", "完成"))
|
||||
emit(BackupProgress(index + 1, totalCount, pkgName, "appdone", "完成"))
|
||||
}
|
||||
|
||||
internal suspend fun buildAppDetailsJson(
|
||||
|
||||
@@ -28,7 +28,7 @@ object RestoreOperation {
|
||||
val current: Int,
|
||||
val total: Int,
|
||||
val packageName: String,
|
||||
val stage: String, // "install", "data", "obb", "ssaid", "permissions", "done"
|
||||
val stage: String, // "install", "data", "obb", "ssaid", "permissions", "appdone" (per-app finish), "done" (reserved for overall)
|
||||
val message: String,
|
||||
)
|
||||
|
||||
@@ -114,7 +114,7 @@ object RestoreOperation {
|
||||
LogUtil.i(TAG, "restoreApps: pkg=$pkg appBackupDir=${appBackupDir.absolutePath} exists=$dirExists")
|
||||
if (!dirExists) {
|
||||
failAtomic.incrementAndGet()
|
||||
emit(RestoreProgress(index + 1, packages.size, pkg, "done", "备份目录不存在"))
|
||||
emit(RestoreProgress(index + 1, packages.size, pkg, "appdone", "备份目录不存在"))
|
||||
return@withPermit
|
||||
}
|
||||
|
||||
@@ -125,7 +125,7 @@ object RestoreOperation {
|
||||
|
||||
if (!installed) {
|
||||
failAtomic.incrementAndGet()
|
||||
emit(RestoreProgress(index + 1, packages.size, pkg, "done", "安装失败"))
|
||||
emit(RestoreProgress(index + 1, packages.size, pkg, "appdone", "安装失败"))
|
||||
return@withPermit
|
||||
}
|
||||
|
||||
@@ -140,7 +140,7 @@ object RestoreOperation {
|
||||
val dataOk = RestoreAppDataOps.restoreData(pkg, userId, appBackupDir, tarCmd, zstdCmd)
|
||||
if (!dataOk) {
|
||||
failAtomic.incrementAndGet()
|
||||
emit(RestoreProgress(index + 1, packages.size, pkg, "done", "数据恢复失败"))
|
||||
emit(RestoreProgress(index + 1, packages.size, pkg, "appdone", "数据恢复失败"))
|
||||
return@withPermit
|
||||
}
|
||||
|
||||
@@ -170,7 +170,7 @@ object RestoreOperation {
|
||||
RestoreAppDataOps.fixDataOwnership(pkg, userId) { pkgName -> resolveAppUid(pkgName) }
|
||||
|
||||
successAtomic.incrementAndGet()
|
||||
emit(RestoreProgress(index + 1, packages.size, pkg, "done", "完成"))
|
||||
emit(RestoreProgress(index + 1, packages.size, pkg, "appdone", "完成"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,47 +76,18 @@ fun BackupScreen(viewModel: BackupViewModel = viewModel()) {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Status with progress bar ──
|
||||
if (state.isRunning) {
|
||||
Column(
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
// 进度条
|
||||
LinearProgressIndicator(
|
||||
progress = { state.progressPercent / 100f },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
// 状态文本
|
||||
Text(
|
||||
text = state.statusText,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
// ETA 和详细信息
|
||||
if (state.etaSeconds > 0) {
|
||||
Text(
|
||||
text = "预计剩余: ${formatEta(state.etaSeconds)}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
if (state.currentStage.isNotEmpty()) {
|
||||
Text(
|
||||
text = "阶段: ${state.currentStage}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Text(
|
||||
text = state.statusText,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
|
||||
)
|
||||
}
|
||||
// ── Progress ──
|
||||
ProgressBlock(
|
||||
isRunning = state.isRunning,
|
||||
statusText = state.statusText,
|
||||
progressCurrent = state.progressCurrent,
|
||||
progressTotal = state.progressTotal,
|
||||
progressStage = state.progressStage,
|
||||
progressPackageName = state.progressPackageName,
|
||||
progressMessage = state.progressMessage,
|
||||
progressPercent = state.progressPercent,
|
||||
stageDisplayName = ::backupStageDisplayName,
|
||||
)
|
||||
|
||||
// ── App list ──
|
||||
LazyColumn(
|
||||
@@ -193,20 +164,3 @@ private fun AppListItem(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化 ETA 为人类可读的字符串。
|
||||
*/
|
||||
private fun formatEta(seconds: Long): String {
|
||||
if (seconds <= 0) return "计算中..."
|
||||
|
||||
val hours = seconds / 3600
|
||||
val minutes = (seconds % 3600) / 60
|
||||
val secs = seconds % 60
|
||||
|
||||
return when {
|
||||
hours > 0 -> "${hours}小时${minutes}分${secs}秒"
|
||||
minutes > 0 -> "${minutes}分${secs}秒"
|
||||
else -> "${secs}秒"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,11 +42,13 @@ data class BackupUiState(
|
||||
val statusText: String = "请先扫描应用",
|
||||
val isRunning: Boolean = false,
|
||||
val isScanning: Boolean = false,
|
||||
// 进度相关字段
|
||||
val progressPercent: Float = 0f,
|
||||
val etaSeconds: Long = 0,
|
||||
val currentStage: String = "",
|
||||
val currentApp: String = "",
|
||||
// ── 结构化进度字段 ──
|
||||
val progressCurrent: Int = 0,
|
||||
val progressTotal: Int = 0,
|
||||
val progressStage: String = "", // "apk"/"data"/"obb"/"ssaid"/"appdone"(per-app) /"restic"/"done"/"partial"
|
||||
val progressPackageName: String = "",
|
||||
val progressMessage: String = "",
|
||||
val progressPercent: Float? = null, // restic 百分比(0.0~1.0),null 表示不确定
|
||||
)
|
||||
|
||||
/** 备份操作的一次性事件。 */
|
||||
@@ -185,7 +187,18 @@ class BackupViewModel(
|
||||
val toBackup = s.allApps.filter { it.packageName.value in s.selectedApps }
|
||||
if (toBackup.isEmpty()) return
|
||||
|
||||
_state.update { it.copy(isRunning = true, statusText = "开始备份 ${toBackup.size} 个应用…") }
|
||||
_state.update {
|
||||
it.copy(
|
||||
isRunning = true,
|
||||
statusText = "开始备份 ${toBackup.size} 个应用…",
|
||||
progressCurrent = 0,
|
||||
progressTotal = toBackup.size,
|
||||
progressStage = "",
|
||||
progressPackageName = "",
|
||||
progressMessage = "",
|
||||
progressPercent = null,
|
||||
)
|
||||
}
|
||||
|
||||
currentJob =
|
||||
viewModelScope.launch {
|
||||
@@ -203,7 +216,6 @@ class BackupViewModel(
|
||||
|
||||
// 2. 执行备份
|
||||
val outputDir = File(s.config.outputPath.ifEmpty { context.filesDir.absolutePath })
|
||||
val backupProgressTracker = com.example.androidbackupgui.backup.BackupProgressTracker(toBackup.size)
|
||||
val backupResult =
|
||||
withContext(Dispatchers.IO) {
|
||||
BackupOperation.backupApps(
|
||||
@@ -214,31 +226,30 @@ class BackupViewModel(
|
||||
userId = s.config.backupUserId.toString(),
|
||||
noDataBackup = s.excludeDataFromBackup,
|
||||
onProgress = { progress ->
|
||||
backupProgressTracker.startApp(progress.packageName)
|
||||
backupProgressTracker.updateStage(progress.stage, progress.message)
|
||||
if (progress.stage == "done") {
|
||||
backupProgressTracker.completeApp()
|
||||
}
|
||||
val progressInfo = backupProgressTracker.getProgress()
|
||||
_state.update {
|
||||
it.copy(
|
||||
statusText = progressInfo.message,
|
||||
progressPercent = progressInfo.percent,
|
||||
etaSeconds = progressInfo.etaSeconds,
|
||||
currentStage = progressInfo.stage,
|
||||
currentApp = progressInfo.packageName,
|
||||
statusText = "[${progress.current}/${progress.total}] ${progress.packageName}: ${progress.message}",
|
||||
progressCurrent = progress.current,
|
||||
progressTotal = progress.total,
|
||||
progressStage = progress.stage,
|
||||
progressPackageName = progress.packageName,
|
||||
progressMessage = progress.message,
|
||||
progressPercent = null,
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
val failed = backupResult.failCount
|
||||
_state.update {
|
||||
it.copy(
|
||||
statusText = "备份完成!成功: ${backupResult.successCount} 失败: ${backupResult.failCount} 耗时: ${backupResult.elapsedMs / 1000}s",
|
||||
progressPercent = 100f,
|
||||
etaSeconds = 0,
|
||||
currentStage = "完成",
|
||||
currentApp = "",
|
||||
statusText = "备份${if (failed > 0) "完成(部分失败)" else "完成"}!成功: ${backupResult.successCount} 失败: $failed 耗时: ${backupResult.elapsedMs / 1000}s",
|
||||
progressCurrent = backupResult.successCount,
|
||||
progressTotal = toBackup.size,
|
||||
progressStage = if (failed > 0) "partial" else "done",
|
||||
progressPackageName = "",
|
||||
progressMessage = if (failed > 0) "失败 $failed 个" else "完成",
|
||||
progressPercent = null,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -270,9 +281,21 @@ class BackupViewModel(
|
||||
append("\n建议: ${errorInfo.suggestion}")
|
||||
}
|
||||
}
|
||||
_state.update { it.copy(statusText = errorMessage) }
|
||||
_state.update {
|
||||
it.copy(
|
||||
statusText = errorMessage,
|
||||
progressStage = "partial",
|
||||
progressMessage = e.message ?: "异常",
|
||||
progressPercent = null,
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
_state.update { it.copy(isRunning = false) }
|
||||
_state.update {
|
||||
it.copy(
|
||||
isRunning = false,
|
||||
progressPercent = null,
|
||||
)
|
||||
}
|
||||
try {
|
||||
context.startService(Intent(context, BackupService::class.java).apply { action = ACTION_STOP_BACKUP })
|
||||
} catch (_: Exception) {
|
||||
@@ -312,7 +335,26 @@ class BackupViewModel(
|
||||
backendUser = s.config.resticBackendUser,
|
||||
backendPass = backendPass,
|
||||
backendShare = s.config.resticBackendShare,
|
||||
onProgress = { msg -> _state.update { it.copy(statusText = msg) } },
|
||||
onProgress = { msg ->
|
||||
// 流式备份进度消息包含百分比信息,尝试解析。
|
||||
// 兼容整数 "100%" 与小数 "12.34%",并 coerce 到 [0,1]。
|
||||
val pct =
|
||||
Regex("""(\d{1,3})(?:\.\d+)?%""")
|
||||
.find(msg)
|
||||
?.groupValues
|
||||
?.get(1)
|
||||
?.toFloatOrNull()
|
||||
?.div(100f)
|
||||
?.coerceIn(0f, 1f)
|
||||
_state.update {
|
||||
it.copy(
|
||||
statusText = msg,
|
||||
progressStage = "restic",
|
||||
progressMessage = msg,
|
||||
progressPercent = pct,
|
||||
)
|
||||
}
|
||||
},
|
||||
).let { result ->
|
||||
when (result) {
|
||||
is AppResult.Success -> {
|
||||
@@ -327,7 +369,14 @@ class BackupViewModel(
|
||||
}
|
||||
|
||||
is AppResult.Failure -> {
|
||||
_state.update { it.copy(statusText = "流式备份失败: ${result.errorOrNull()?.message}") }
|
||||
_state.update {
|
||||
it.copy(
|
||||
statusText = "流式备份失败: ${result.errorOrNull()?.message}",
|
||||
progressStage = "partial",
|
||||
progressMessage = "上传失败",
|
||||
progressPercent = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -354,6 +403,9 @@ class BackupViewModel(
|
||||
progress.filesDone,
|
||||
progress.totalFiles,
|
||||
),
|
||||
progressStage = "restic",
|
||||
progressMessage = "上传中: %.0f%%".format(progress.percentDone * 100),
|
||||
progressPercent = progress.percentDone.toFloat(),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -372,7 +424,14 @@ class BackupViewModel(
|
||||
}
|
||||
|
||||
is AppResult.Failure -> {
|
||||
_state.update { it.copy(statusText = "restic 快照失败: ${result.errorOrNull()?.message}") }
|
||||
_state.update {
|
||||
it.copy(
|
||||
statusText = "restic 快照失败: ${result.errorOrNull()?.message}",
|
||||
progressStage = "partial",
|
||||
progressMessage = "上传失败",
|
||||
progressPercent = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
package com.example.androidbackupgui.ui
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
/**
|
||||
* 备份/恢复通用结构化进度展示组件,三态:
|
||||
* - [isRunning] && [progressTotal] > 0:显示阶段名 + 计数 + 进度条 + 消息行
|
||||
* - [isRunning] && 无结构化进度:圆形 spinner + [statusText]
|
||||
* - !isRunning:仅显示 [statusText]
|
||||
*
|
||||
* 阶段名通过 [stageDisplayName] 映射,由调用方提供(备份/恢复各有自己的映射表,
|
||||
* 见 [backupStageDisplayName] / [restoreStageDisplayName])。
|
||||
*
|
||||
* 失败语义:当 [progressStage] 为 "partial" 时进度条与计数使用 error 色,
|
||||
* 用于让用户在多个应用部分失败时立刻察觉(备份工具的关键诉求)。
|
||||
*
|
||||
* @param progressPercent 0.0~1.0 的确定百分比,null 表示按计数计算
|
||||
*/
|
||||
@Composable
|
||||
fun ProgressBlock(
|
||||
isRunning: Boolean,
|
||||
statusText: String,
|
||||
progressCurrent: Int,
|
||||
progressTotal: Int,
|
||||
progressStage: String,
|
||||
progressPackageName: String,
|
||||
progressMessage: String,
|
||||
progressPercent: Float?,
|
||||
stageDisplayName: (String) -> String,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
if (isRunning && progressTotal > 0) {
|
||||
val isError = progressStage == "partial"
|
||||
val counterColor = if (isError) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary
|
||||
val trackColor = if (isError) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary
|
||||
val computedFraction =
|
||||
(progressPercent ?: (progressCurrent.toFloat() / progressTotal.coerceAtLeast(1)))
|
||||
.coerceIn(0f, 1f)
|
||||
|
||||
Column(modifier = modifier.padding(horizontal = 12.dp, vertical = 4.dp)) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text =
|
||||
stageDisplayName(progressStage) +
|
||||
if (progressPackageName.isNotEmpty()) " — $progressPackageName" else "",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = if (isError) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Text(
|
||||
text = "$progressCurrent/$progressTotal",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = counterColor,
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(4.dp))
|
||||
LinearProgressIndicator(
|
||||
progress = { computedFraction },
|
||||
color = trackColor,
|
||||
modifier = Modifier.fillMaxWidth().height(6.dp),
|
||||
)
|
||||
if (progressMessage.isNotEmpty()) {
|
||||
Spacer(Modifier.height(2.dp))
|
||||
Text(
|
||||
text = progressMessage,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
} else if (isRunning) {
|
||||
Row(
|
||||
modifier = modifier.padding(horizontal = 12.dp, vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
CircularProgressIndicator(modifier = Modifier.size(14.dp), strokeWidth = 2.dp)
|
||||
Text(
|
||||
text = statusText,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Text(
|
||||
text = statusText,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = modifier.padding(horizontal = 12.dp, vertical = 4.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** 备份阶段标识 → 用户友好中文名。pure function,便于单元测试。 */
|
||||
fun backupStageDisplayName(stage: String): String =
|
||||
when (stage) {
|
||||
"apk" -> "备份 APK"
|
||||
"data" -> "备份数据"
|
||||
"obb" -> "备份 OBB"
|
||||
"ssaid" -> "备份 SSAID"
|
||||
"appdone" -> "已完成"
|
||||
"restic" -> "上传至 Restic"
|
||||
"done" -> "完成"
|
||||
"partial" -> "部分完成"
|
||||
else -> stage.ifEmpty { "处理中" }
|
||||
}
|
||||
|
||||
/** 恢复阶段标识 → 用户友好中文名。pure function,便于单元测试。 */
|
||||
fun restoreStageDisplayName(stage: String): String =
|
||||
when (stage) {
|
||||
"install" -> "安装 APK"
|
||||
"data" -> "恢复数据"
|
||||
"obb" -> "恢复 OBB"
|
||||
"ssaid" -> "恢复 SSAID"
|
||||
"permissions" -> "恢复权限"
|
||||
"appdone" -> "已完成"
|
||||
"done" -> "完成"
|
||||
"partial" -> "部分完成"
|
||||
else -> stage.ifEmpty { "处理中" }
|
||||
}
|
||||
@@ -43,6 +43,13 @@ fun RestoreScreen() {
|
||||
var statusText by remember { mutableStateOf("请选择备份源") }
|
||||
var showSnapshotPicker by remember { mutableStateOf(false) }
|
||||
var availableSnapshots by remember { mutableStateOf<List<ResticWrapper.ResticSnapshot>>(emptyList()) }
|
||||
// ── 结构化进度状态 ──
|
||||
var progressCurrent by remember { mutableIntStateOf(0) }
|
||||
var progressTotal by remember { mutableIntStateOf(0) }
|
||||
var progressStage by remember { mutableStateOf("") }
|
||||
var progressPackageName by remember { mutableStateOf("") }
|
||||
var progressMessage by remember { mutableStateOf("") }
|
||||
var progressPercent by remember { mutableStateOf<Float?>(null) }
|
||||
val configFile = remember { File(context.filesDir, "backup_settings.conf") }
|
||||
|
||||
// SAF directory picker for selecting external backup dir
|
||||
@@ -216,12 +223,17 @@ fun RestoreScreen() {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Status ──
|
||||
Text(
|
||||
text = statusText,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
|
||||
// ── Progress ──
|
||||
ProgressBlock(
|
||||
isRunning = isRunning,
|
||||
statusText = statusText,
|
||||
progressCurrent = progressCurrent,
|
||||
progressTotal = progressTotal,
|
||||
progressStage = progressStage,
|
||||
progressPackageName = progressPackageName,
|
||||
progressMessage = progressMessage,
|
||||
progressPercent = progressPercent,
|
||||
stageDisplayName = ::restoreStageDisplayName,
|
||||
)
|
||||
|
||||
// ── App list ──
|
||||
@@ -284,6 +296,12 @@ fun RestoreScreen() {
|
||||
if (toRestore.isEmpty()) return@Button
|
||||
isRunning = true
|
||||
statusText = "开始恢复 ${toRestore.size} 个应用…"
|
||||
progressCurrent = 0
|
||||
progressTotal = toRestore.size
|
||||
progressStage = ""
|
||||
progressPackageName = ""
|
||||
progressMessage = ""
|
||||
progressPercent = null
|
||||
|
||||
scope.launch {
|
||||
try {
|
||||
@@ -296,6 +314,9 @@ fun RestoreScreen() {
|
||||
|
||||
try {
|
||||
statusText = "正在从 restic 快照恢复…"
|
||||
progressStage = "restic"
|
||||
progressMessage = "正在拉取快照…"
|
||||
progressPercent = null
|
||||
val restoreResult =
|
||||
withContext(Dispatchers.IO) {
|
||||
val rPw =
|
||||
@@ -314,14 +335,35 @@ fun RestoreScreen() {
|
||||
backendUser = config.resticBackendUser,
|
||||
backendPass = rBpw,
|
||||
backendShare = config.resticBackendShare,
|
||||
onProgress = { msg ->
|
||||
// restic restore emits "恢复进度: 12.3%" / "恢复完成: N 个文件"
|
||||
statusText = msg
|
||||
progressMessage = msg
|
||||
val pct =
|
||||
Regex("""(\d{1,3})(?:\.\d+)?%""")
|
||||
.find(msg)
|
||||
?.groupValues
|
||||
?.get(1)
|
||||
?.toFloatOrNull()
|
||||
?.div(100f)
|
||||
?.coerceIn(0f, 1f)
|
||||
progressPercent = pct
|
||||
},
|
||||
)
|
||||
}
|
||||
if (restoreResult.isFailure) {
|
||||
statusText = "restic 恢复失败: ${restoreResult.exceptionOrNull()?.message}"
|
||||
progressMessage = statusText
|
||||
// 清空快照选择,避免用户在半残状态上二次操作
|
||||
selectedSnapshot = null
|
||||
packages = emptyList()
|
||||
appInfos = emptyList()
|
||||
selectedPackages = emptySet()
|
||||
return@launch
|
||||
}
|
||||
val restoredDir = File(staging, backupPath.removePrefix("/"))
|
||||
statusText = "正在从恢复的备份安装应用…"
|
||||
progressPercent = null
|
||||
|
||||
val result =
|
||||
withContext(Dispatchers.IO) {
|
||||
@@ -333,16 +375,26 @@ fun RestoreScreen() {
|
||||
onProgress = { progress ->
|
||||
statusText =
|
||||
"[${progress.current}/${progress.total}] ${progress.packageName}: ${progress.message}"
|
||||
progressCurrent = progress.current
|
||||
progressTotal = progress.total
|
||||
progressStage = progress.stage
|
||||
progressPackageName = progress.packageName
|
||||
progressMessage = progress.message
|
||||
},
|
||||
)
|
||||
}
|
||||
WifiManager.restore(restoredDir)
|
||||
val failed = result.failCount
|
||||
statusText =
|
||||
buildString {
|
||||
appendLine("恢复完成!")
|
||||
appendLine("成功: ${result.successCount} 失败: ${result.failCount}")
|
||||
appendLine("恢复${if (failed > 0) "完成(部分失败)" else "完成!"}")
|
||||
appendLine("成功: ${result.successCount} 失败: $failed")
|
||||
append("耗时: ${result.elapsedMs / 1000}秒")
|
||||
}
|
||||
progressCurrent = result.successCount
|
||||
progressStage = if (failed > 0) "partial" else "done"
|
||||
progressMessage = if (failed > 0) "失败 $failed 个" else "完成"
|
||||
progressPercent = null
|
||||
} finally {
|
||||
try {
|
||||
staging.deleteRecursively()
|
||||
@@ -361,21 +413,33 @@ fun RestoreScreen() {
|
||||
onProgress = { progress ->
|
||||
statusText =
|
||||
"[${progress.current}/${progress.total}] ${progress.packageName}: ${progress.message}"
|
||||
progressCurrent = progress.current
|
||||
progressTotal = progress.total
|
||||
progressStage = progress.stage
|
||||
progressPackageName = progress.packageName
|
||||
progressMessage = progress.message
|
||||
},
|
||||
)
|
||||
}
|
||||
WifiManager.restore(dir)
|
||||
val failed = result.failCount
|
||||
statusText =
|
||||
buildString {
|
||||
appendLine("恢复完成!")
|
||||
appendLine("成功: ${result.successCount} 失败: ${result.failCount}")
|
||||
appendLine("恢复${if (failed > 0) "完成(部分失败)" else "完成!"}")
|
||||
appendLine("成功: ${result.successCount} 失败: $failed")
|
||||
append("耗时: ${result.elapsedMs / 1000}秒")
|
||||
}
|
||||
progressCurrent = result.successCount
|
||||
progressStage = if (failed > 0) "partial" else "done"
|
||||
progressMessage = if (failed > 0) "失败 $failed 个" else "完成"
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
statusText = "恢复异常: ${e.message}"
|
||||
progressMessage = e.message ?: "异常"
|
||||
progressStage = "partial"
|
||||
} finally {
|
||||
isRunning = false
|
||||
progressPercent = null
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
package com.example.androidbackupgui.ui
|
||||
|
||||
import io.kotest.core.spec.style.FunSpec
|
||||
import io.kotest.matchers.shouldBe
|
||||
import io.kotest.matchers.shouldNotBe
|
||||
import io.kotest.matchers.string.shouldBeEmpty
|
||||
import io.kotest.property.Arb
|
||||
import io.kotest.property.arbitrary.element
|
||||
import io.kotest.property.arbitrary.string
|
||||
import io.kotest.property.checkAll
|
||||
|
||||
class StageDisplayNameTest : FunSpec({
|
||||
|
||||
context("backupStageDisplayName") {
|
||||
test("maps known backup stages to Chinese labels") {
|
||||
backupStageDisplayName("apk") shouldBe "备份 APK"
|
||||
backupStageDisplayName("data") shouldBe "备份数据"
|
||||
backupStageDisplayName("obb") shouldBe "备份 OBB"
|
||||
backupStageDisplayName("ssaid") shouldBe "备份 SSAID"
|
||||
backupStageDisplayName("appdone") shouldBe "已完成"
|
||||
backupStageDisplayName("restic") shouldBe "上传至 Restic"
|
||||
backupStageDisplayName("done") shouldBe "完成"
|
||||
backupStageDisplayName("partial") shouldBe "部分完成"
|
||||
}
|
||||
|
||||
test("empty stage falls back to 处理中 (not 完成)") {
|
||||
// 回归测试:原来空字符串 per-app "done" 会让 UI 反复闪"完成",
|
||||
// 现在空串显示"处理中",per-app 完成是"已完成",避免误导用户。
|
||||
backupStageDisplayName("") shouldBe "处理中"
|
||||
}
|
||||
|
||||
test("unknown stage is returned as-is") {
|
||||
backupStageDisplayName("weird-stage") shouldBe "weird-stage"
|
||||
}
|
||||
|
||||
test("every stage produced by BackupOperation has a non-default mapping") {
|
||||
// 这些是 BackupOperation.kt 实际 emit 的所有 stage 值,
|
||||
// 任一新增未映射会导致 UI 显示原始英文 stage,需要在映射表里同步。
|
||||
val emittedStages = listOf("apk", "data", "obb", "ssaid", "appdone")
|
||||
emittedStages.forEach { stage ->
|
||||
val label = backupStageDisplayName(stage)
|
||||
label shouldNotBe stage
|
||||
label.isNotEmpty() shouldBe true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
context("restoreStageDisplayName") {
|
||||
test("maps known restore stages to Chinese labels") {
|
||||
restoreStageDisplayName("install") shouldBe "安装 APK"
|
||||
restoreStageDisplayName("data") shouldBe "恢复数据"
|
||||
restoreStageDisplayName("obb") shouldBe "恢复 OBB"
|
||||
restoreStageDisplayName("ssaid") shouldBe "恢复 SSAID"
|
||||
restoreStageDisplayName("permissions") shouldBe "恢复权限"
|
||||
restoreStageDisplayName("appdone") shouldBe "已完成"
|
||||
restoreStageDisplayName("done") shouldBe "完成"
|
||||
restoreStageDisplayName("partial") shouldBe "部分完成"
|
||||
}
|
||||
|
||||
test("empty stage falls back to 处理中") {
|
||||
restoreStageDisplayName("") shouldBe "处理中"
|
||||
}
|
||||
|
||||
test("every stage produced by RestoreOperation has a non-default mapping") {
|
||||
val emittedStages = listOf("install", "data", "obb", "ssaid", "permissions", "appdone")
|
||||
emittedStages.forEach { stage ->
|
||||
val label = restoreStageDisplayName(stage)
|
||||
label shouldNotBe stage
|
||||
label.isNotEmpty() shouldBe true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
context("partial stage is distinct from done") {
|
||||
// 备份工具关键诉求:失败状态必须可被 UI 区分(染 error 色 / 不拉满进度条)。
|
||||
// 这两个映射必须不同,否则 ProgressBlock 的 isError 分支永不触发。
|
||||
test("backup partial != done") {
|
||||
backupStageDisplayName("partial") shouldNotBe backupStageDisplayName("done")
|
||||
}
|
||||
|
||||
test("restore partial != done") {
|
||||
restoreStageDisplayName("partial") shouldNotBe restoreStageDisplayName("done")
|
||||
}
|
||||
}
|
||||
|
||||
context("property: never returns null and always non-blank for any string") {
|
||||
test("arbitrary strings yield non-blank labels") {
|
||||
val knownStages = Arb.element("apk", "data", "", "weird", "done", "partial")
|
||||
checkAll(50, knownStages) { stage ->
|
||||
backupStageDisplayName(stage).isNotEmpty() shouldBe true
|
||||
restoreStageDisplayName(stage).isNotEmpty() shouldBe true
|
||||
}
|
||||
}
|
||||
|
||||
test("property: any random non-empty stage string is returned non-blank") {
|
||||
checkAll(50, Arb.string(minSize = 1, maxSize = 20)) { s ->
|
||||
restoreStageDisplayName(s).isNotEmpty() shouldBe true
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user