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:
@@ -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
|
|
||||||
))}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user