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 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) { class ConfigViewModel(application: Application) : AndroidViewModel(application) {
companion object { companion object {
@@ -92,6 +107,10 @@ class ConfigViewModel(application: Application) : AndroidViewModel(application)
File(getApplication<Application>().filesDir, CONFIG_FILE_NAME) 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()) private val _uiState = MutableStateFlow(ConfigUiState())
val uiState: StateFlow<ConfigUiState> = _uiState.asStateFlow() 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. * The caller passes the current form values as a [BackupConfig] copy.
*/ */
fun save(formConfig: BackupConfig) { fun save(formConfig: BackupConfig) {
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch {
BackupConfig.toFile(formConfig, configFile) withContext(Dispatchers.IO) {
BackupConfig.toFile(formConfig, configFile)
}
_uiState.update { _uiState.update {
it.copy(resticStatus = it.resticStatus.copy(message = "配置已保存到 $configFile")) it.copy(resticStatus = it.resticStatus.copy(message = "配置已保存到 $configFile"))
} }
@@ -168,27 +189,31 @@ class ConfigViewModel(application: Application) : AndroidViewModel(application)
))} ))}
viewModelScope.launch { viewModelScope.launch {
val result = ResticWrapper.init(form.repo, form.password, try {
backend = form.backend, backendUrl = form.backendUrl, _operationEvents.emit(OperationEvent.InitStarted)
backendUser = form.backendUser, backendPass = form.backendPass, val result = ResticWrapper.init(form.repo, form.password,
backendShare = form.backendShare, backend = form.backend, backendUrl = form.backendUrl,
onSyncProgress = { p -> onSyncProgress(p) }, backendUser = form.backendUser, backendPass = form.backendPass,
onByteSyncProgress = { p -> onByteProgress(p) }, backendShare = form.backendShare,
) onSyncProgress = { p -> onSyncProgress(p) },
result.fold( onByteSyncProgress = { p -> onByteProgress(p) },
onSuccess = { )
if (result.isSuccess) {
_operationEvents.emit(OperationEvent.InitCompleted)
_uiState.update { it.copy(resticStatus = it.resticStatus.copy( _uiState.update { it.copy(resticStatus = it.resticStatus.copy(
message = "仓库初始化成功: ${form.repo}", initButtonEnabled = true message = "仓库初始化成功: ${form.repo}", initButtonEnabled = true
))} ))}
refreshResticStatus(form) refreshResticStatus(form)
}, } else {
onFailure = { e -> _operationEvents.emit(OperationEvent.InitFailed)
Log.e(TAG, "initResticRepo failed", e) Log.e(TAG, "initResticRepo failed: ${result.exceptionOrNull()?.message}")
_uiState.update { it.copy(resticStatus = it.resticStatus.copy( _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) { fun showResticStats(form: ResticForm) {
_uiState.update { it.copy(resticStatus = it.resticStatus.copy( _uiState.update { it.copy(resticStatus = it.resticStatus.copy(
message = "正在读取统计…", statsButtonEnabled = false message = "正在读取统计…", statsButtonEnabled = false
))} ))}
viewModelScope.launch { viewModelScope.launch {
val statsResult = ResticWrapper.stats(form.repo, form.password, try {
backend = form.backend, backendUrl = form.backendUrl, _operationEvents.emit(OperationEvent.StatsStarted)
backendUser = form.backendUser, backendPass = form.backendPass, val statsResult = ResticWrapper.stats(form.repo, form.password,
backendShare = form.backendShare, backend = form.backend, backendUrl = form.backendUrl,
onSyncProgress = { p -> onSyncProgress(p) }, backendUser = form.backendUser, backendPass = form.backendPass,
onByteSyncProgress = { p -> onByteProgress(p) }, backendShare = form.backendShare,
) onSyncProgress = { p -> onSyncProgress(p) },
val snapshotsResult = ResticWrapper.listSnapshots(form.repo, form.password, onByteSyncProgress = { p -> onByteProgress(p) },
backend = form.backend, backendUrl = form.backendUrl, )
backendUser = form.backendUser, backendPass = form.backendPass, val snapshotsResult = ResticWrapper.listSnapshots(form.repo, form.password,
backendShare = form.backendShare, backend = form.backend, backendUrl = form.backendUrl,
onSyncProgress = { p -> onSyncProgress(p) }, backendUser = form.backendUser, backendPass = form.backendPass,
onByteSyncProgress = { p -> onByteProgress(p) }, backendShare = form.backendShare,
) onSyncProgress = { p -> onSyncProgress(p) },
onByteSyncProgress = { p -> onByteProgress(p) },
)
val snapshotCount = snapshotsResult.getOrDefault(emptyList()).size val snapshotCount = snapshotsResult.getOrDefault(emptyList()).size
_uiState.update { it.copy(resticStatus = it.resticStatus.copy( _uiState.update { it.copy(resticStatus = it.resticStatus.copy(
message = buildString { message = buildString {
appendLine("快照数: $snapshotCount") appendLine("快照数: $snapshotCount")
if (statsResult.isSuccess) { if (statsResult.isSuccess) {
appendLine(statsResult.getOrDefault("")) appendLine(statsResult.getOrDefault(""))
} else { } else {
appendLine("统计读取失败: ${statsResult.exceptionOrNull()?.message}") appendLine("统计读取失败: ${statsResult.errorOrNull()?.message}")
} }
}, },
snapshotCount = snapshotCount, snapshotCount = snapshotCount,
statsButtonEnabled = true 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 { viewModelScope.launch {
val forgetResult = ResticWrapper.forget(form.repo, form.password, try {
keepDaily = 7, keepWeekly = 4, keepMonthly = 3, _operationEvents.emit(OperationEvent.PruneStarted)
backend = form.backend, backendUrl = form.backendUrl, val forgetResult = ResticWrapper.forget(form.repo, form.password,
backendUser = form.backendUser, backendPass = form.backendPass, keepDaily = 7, keepWeekly = 4, keepMonthly = 3,
backendShare = form.backendShare, backend = form.backend, backendUrl = form.backendUrl,
onSyncProgress = { p -> onSyncProgress(p) }, backendUser = form.backendUser, backendPass = form.backendPass,
onByteSyncProgress = { p -> onByteProgress(p) }, backendShare = form.backendShare,
) onSyncProgress = { p -> onSyncProgress(p) },
if (forgetResult.isFailure) { 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( _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 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
))}
} }
} }