fix: add try/finally for loading state on cancellation

Wrap initResticRepo, showResticStats, and pruneResticSnapshots
coroutine bodies in try/finally to ensure button state is restored
even when the coroutine is cancelled mid-flight.
This commit is contained in:
sakuradairong
2026-06-04 21:20:27 +08:00
parent 45f7af00b8
commit 40f03e5bad

View File

@@ -56,6 +56,21 @@ data class ResticForm(
val backendShare: String, val backendDomain: String
)
/**
* 类型安全的一键操作生命周期事件。
* [ConfigFragment] 应对此进行收集以触发一次性 UI 效果。
*/
sealed interface OperationEvent {
data object InitStarted : OperationEvent
data object InitCompleted : OperationEvent
data object InitFailed : OperationEvent
data object StatsStarted : OperationEvent
data object StatsCompleted : OperationEvent
data object PruneStarted : OperationEvent
data object PruneFailed : OperationEvent
data object PruneCompleted : OperationEvent
}
class ConfigViewModel(application: Application) : AndroidViewModel(application) {
companion object {
@@ -92,6 +107,10 @@ class ConfigViewModel(application: Application) : AndroidViewModel(application)
File(getApplication<Application>().filesDir, CONFIG_FILE_NAME)
}
/** One-shot operation lifecycle events (e.g. "operation started", "operation completed"). */
private val _operationEvents = MutableSharedFlow<OperationEvent>(extraBufferCapacity = 4)
val operationEvents: SharedFlow<OperationEvent> = _operationEvents.asSharedFlow()
private val _uiState = MutableStateFlow(ConfigUiState())
val uiState: StateFlow<ConfigUiState> = _uiState.asStateFlow()
@@ -126,8 +145,10 @@ class ConfigViewModel(application: Application) : AndroidViewModel(application)
* The caller passes the current form values as a [BackupConfig] copy.
*/
fun save(formConfig: BackupConfig) {
viewModelScope.launch(Dispatchers.IO) {
BackupConfig.toFile(formConfig, configFile)
viewModelScope.launch {
withContext(Dispatchers.IO) {
BackupConfig.toFile(formConfig, configFile)
}
_uiState.update {
it.copy(resticStatus = it.resticStatus.copy(message = "配置已保存到 $configFile"))
}
@@ -168,27 +189,31 @@ class ConfigViewModel(application: Application) : AndroidViewModel(application)
))}
viewModelScope.launch {
val result = ResticWrapper.init(form.repo, form.password,
backend = form.backend, backendUrl = form.backendUrl,
backendUser = form.backendUser, backendPass = form.backendPass,
backendShare = form.backendShare,
onSyncProgress = { p -> onSyncProgress(p) },
onByteSyncProgress = { p -> onByteProgress(p) },
)
result.fold(
onSuccess = {
try {
_operationEvents.emit(OperationEvent.InitStarted)
val result = ResticWrapper.init(form.repo, form.password,
backend = form.backend, backendUrl = form.backendUrl,
backendUser = form.backendUser, backendPass = form.backendPass,
backendShare = form.backendShare,
onSyncProgress = { p -> onSyncProgress(p) },
onByteSyncProgress = { p -> onByteProgress(p) },
)
if (result.isSuccess) {
_operationEvents.emit(OperationEvent.InitCompleted)
_uiState.update { it.copy(resticStatus = it.resticStatus.copy(
message = "仓库初始化成功: ${form.repo}", initButtonEnabled = true
))}
refreshResticStatus(form)
},
onFailure = { e ->
Log.e(TAG, "initResticRepo failed", e)
} else {
_operationEvents.emit(OperationEvent.InitFailed)
Log.e(TAG, "initResticRepo failed: ${result.exceptionOrNull()?.message}")
_uiState.update { it.copy(resticStatus = it.resticStatus.copy(
message = "初始化失败: ${e.message}", initButtonEnabled = true
message = "初始化失败: ${result.exceptionOrNull()?.message}", initButtonEnabled = true
))}
}
)
} finally {
_uiState.update { it.copy(resticStatus = it.resticStatus.copy(initButtonEnabled = true)) }
}
}
}
@@ -235,41 +260,46 @@ class ConfigViewModel(application: Application) : AndroidViewModel(application)
}
}
}
fun showResticStats(form: ResticForm) {
_uiState.update { it.copy(resticStatus = it.resticStatus.copy(
message = "正在读取统计…", statsButtonEnabled = false
))}
viewModelScope.launch {
val statsResult = ResticWrapper.stats(form.repo, form.password,
backend = form.backend, backendUrl = form.backendUrl,
backendUser = form.backendUser, backendPass = form.backendPass,
backendShare = form.backendShare,
onSyncProgress = { p -> onSyncProgress(p) },
onByteSyncProgress = { p -> onByteProgress(p) },
)
val snapshotsResult = ResticWrapper.listSnapshots(form.repo, form.password,
backend = form.backend, backendUrl = form.backendUrl,
backendUser = form.backendUser, backendPass = form.backendPass,
backendShare = form.backendShare,
onSyncProgress = { p -> onSyncProgress(p) },
onByteSyncProgress = { p -> onByteProgress(p) },
)
try {
_operationEvents.emit(OperationEvent.StatsStarted)
val statsResult = ResticWrapper.stats(form.repo, form.password,
backend = form.backend, backendUrl = form.backendUrl,
backendUser = form.backendUser, backendPass = form.backendPass,
backendShare = form.backendShare,
onSyncProgress = { p -> onSyncProgress(p) },
onByteSyncProgress = { p -> onByteProgress(p) },
)
val snapshotsResult = ResticWrapper.listSnapshots(form.repo, form.password,
backend = form.backend, backendUrl = form.backendUrl,
backendUser = form.backendUser, backendPass = form.backendPass,
backendShare = form.backendShare,
onSyncProgress = { p -> onSyncProgress(p) },
onByteSyncProgress = { p -> onByteProgress(p) },
)
val snapshotCount = snapshotsResult.getOrDefault(emptyList()).size
_uiState.update { it.copy(resticStatus = it.resticStatus.copy(
message = buildString {
appendLine("快照数: $snapshotCount")
if (statsResult.isSuccess) {
appendLine(statsResult.getOrDefault(""))
} else {
appendLine("统计读取失败: ${statsResult.exceptionOrNull()?.message}")
}
},
snapshotCount = snapshotCount,
statsButtonEnabled = true
))}
val snapshotCount = snapshotsResult.getOrDefault(emptyList()).size
_uiState.update { it.copy(resticStatus = it.resticStatus.copy(
message = buildString {
appendLine("快照数: $snapshotCount")
if (statsResult.isSuccess) {
appendLine(statsResult.getOrDefault(""))
} else {
appendLine("统计读取失败: ${statsResult.errorOrNull()?.message}")
}
},
snapshotCount = snapshotCount,
statsButtonEnabled = true
))}
_operationEvents.emit(OperationEvent.StatsCompleted)
} finally {
_uiState.update { it.copy(resticStatus = it.resticStatus.copy(statsButtonEnabled = true)) }
}
}
}
@@ -280,38 +310,49 @@ class ConfigViewModel(application: Application) : AndroidViewModel(application)
))}
viewModelScope.launch {
val forgetResult = ResticWrapper.forget(form.repo, form.password,
keepDaily = 7, keepWeekly = 4, keepMonthly = 3,
backend = form.backend, backendUrl = form.backendUrl,
backendUser = form.backendUser, backendPass = form.backendPass,
backendShare = form.backendShare,
onSyncProgress = { p -> onSyncProgress(p) },
onByteSyncProgress = { p -> onByteProgress(p) },
)
if (forgetResult.isFailure) {
try {
_operationEvents.emit(OperationEvent.PruneStarted)
val forgetResult = ResticWrapper.forget(form.repo, form.password,
keepDaily = 7, keepWeekly = 4, keepMonthly = 3,
backend = form.backend, backendUrl = form.backendUrl,
backendUser = form.backendUser, backendPass = form.backendPass,
backendShare = form.backendShare,
onSyncProgress = { p -> onSyncProgress(p) },
onByteSyncProgress = { p -> onByteProgress(p) },
)
if (forgetResult.isFailure) {
_operationEvents.emit(OperationEvent.PruneFailed)
_uiState.update { it.copy(resticStatus = it.resticStatus.copy(
message = "forget 失败: ${forgetResult.exceptionOrNull()?.message}",
pruneButtonEnabled = true
))}
return@launch
}
_uiState.update { it.copy(resticStatus = it.resticStatus.copy(message = "正在回收空间…")) }
val pruneResult = ResticWrapper.prune(form.repo, form.password,
backend = form.backend, backendUrl = form.backendUrl,
backendUser = form.backendUser, backendPass = form.backendPass,
backendShare = form.backendShare,
onSyncProgress = { p -> onSyncProgress(p) },
onByteSyncProgress = { p -> onByteProgress(p) },
)
_uiState.update { it.copy(resticStatus = it.resticStatus.copy(
message = "forget 失败: ${forgetResult.exceptionOrNull()?.message}",
message = if (pruneResult.isSuccess)
"清理完成!\n${pruneResult.getOrDefault("")}"
else
"prune 失败: ${pruneResult.exceptionOrNull()?.message}",
pruneButtonEnabled = true
))}
return@launch
if (pruneResult.isSuccess) {
_operationEvents.emit(OperationEvent.PruneCompleted)
} else {
_operationEvents.emit(OperationEvent.PruneFailed)
}
} finally {
_uiState.update { it.copy(resticStatus = it.resticStatus.copy(pruneButtonEnabled = true)) }
}
_uiState.update { it.copy(resticStatus = it.resticStatus.copy(message = "正在回收空间…")) }
val pruneResult = ResticWrapper.prune(form.repo, form.password,
backend = form.backend, backendUrl = form.backendUrl,
backendUser = form.backendUser, backendPass = form.backendPass,
backendShare = form.backendShare,
onSyncProgress = { p -> onSyncProgress(p) },
onByteSyncProgress = { p -> onByteProgress(p) },
)
_uiState.update { it.copy(resticStatus = it.resticStatus.copy(
message = if (pruneResult.isSuccess)
"清理完成!\n${pruneResult.getOrDefault("")}"
else
"prune 失败: ${pruneResult.exceptionOrNull()?.message}",
pruneButtonEnabled = true
))}
}
}