diff --git a/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/presentation/viewmodel/MainViewModel.kt b/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/presentation/viewmodel/MainViewModel.kt index c1abe192..0afbf2d8 100644 --- a/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/presentation/viewmodel/MainViewModel.kt +++ b/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/presentation/viewmodel/MainViewModel.kt @@ -8,9 +8,8 @@ import com.durian.tssparty.domain.model.* import com.durian.tssparty.util.AddressUtils import com.durian.tssparty.util.TransactionUtils import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.delay +import kotlinx.coroutines.* import kotlinx.coroutines.flow.* -import kotlinx.coroutines.launch import javax.inject.Inject @HiltViewModel @@ -18,6 +17,59 @@ class MainViewModel @Inject constructor( private val repository: TssRepository ) : ViewModel() { + /** + * 安全启动协程 - 自动捕获异常防止应用崩溃 + * + * 【架构安全修复 - ViewModel 层异常处理】 + * + * 问题背景: + * - viewModelScope.launch 没有配置 CoroutineExceptionHandler + * - 未捕获的异常会导致应用崩溃 + * - 用户操作触发的异常体验最差 + * + * 解决方案: + * - 提供 safeLaunch 扩展函数,自动添加 try-catch + * - 捕获所有异常并更新 UI 错误状态 + * - 记录详细日志用于调试 + * + * 使用方式: + * safeLaunch { + * // 业务逻辑,异常会被自动捕获 + * } + */ + private fun safeLaunch( + onError: ((Exception) -> Unit)? = null, + block: suspend CoroutineScope.() -> Unit + ) = viewModelScope.launch { + try { + block() + } catch (e: CancellationException) { + // 协程取消是正常行为,重新抛出 + throw e + } catch (e: Exception) { + // 捕获所有其他异常 + android.util.Log.e("MainViewModel", "Caught exception in safeLaunch", e) + + // 根据异常类型进行分类处理 + val errorMessage = when (e) { + is java.net.SocketTimeoutException -> "网络超时,请检查网络连接" + is java.net.UnknownHostException -> "无法连接到服务器,请检查网络设置" + is java.io.IOException -> "网络错误: ${e.message}" + is IllegalStateException -> "状态错误: ${e.message}" + is IllegalArgumentException -> "参数错误: ${e.message}" + else -> "操作失败: ${e.message ?: e.javaClass.simpleName}" + } + + // 调用自定义错误处理器(如果提供) + if (onError != null) { + onError(e) + } else { + // 默认更新 UI 错误状态 + _uiState.update { it.copy(isLoading = false, error = errorMessage) } + } + } + } + // App State (similar to Zustand store) private val _appState = MutableStateFlow(AppState()) val appState: StateFlow = _appState.asStateFlow() @@ -57,7 +109,12 @@ class MainViewModel @Inject constructor( * Check all services for startup */ fun checkAllServices() { - viewModelScope.launch { + safeLaunch( + onError = { e -> + _appState.update { it.copy(appReady = AppReadyState.ERROR) } + android.util.Log.e("MainViewModel", "Service check failed", e) + } + ) { _appState.update { it.copy(appReady = AppReadyState.INITIALIZING) } var hasError = false @@ -178,7 +235,7 @@ class MainViewModel @Inject constructor( * Connect to Message Router server */ fun connectToServer(serverUrl: String) { - viewModelScope.launch { + safeLaunch { try { val parts = serverUrl.split(":") val host = parts[0] @@ -226,7 +283,7 @@ class MainViewModel @Inject constructor( * Matches Electron behavior: creates session via API, then auto-joins via gRPC */ fun createKeygenSession(walletName: String, thresholdT: Int, thresholdN: Int, participantName: String) { - viewModelScope.launch { + safeLaunch { _uiState.update { it.copy(isLoading = true, error = null) } val result = repository.createKeygenSession(walletName, thresholdT, thresholdN, participantName) @@ -550,7 +607,7 @@ class MainViewModel @Inject constructor( * Matches Electron's grpc:validateInviteCode - returns sessionInfo + joinToken */ fun validateInviteCode(inviteCode: String) { - viewModelScope.launch { + safeLaunch { _uiState.update { it.copy(isLoading = true, error = null) } pendingInviteCode = inviteCode @@ -590,16 +647,16 @@ class MainViewModel @Inject constructor( * 3. Waits for session_started event to trigger keygen */ fun joinKeygen(inviteCode: String, password: String) { - viewModelScope.launch { + safeLaunch { val sessionInfo = _joinSessionInfo.value if (sessionInfo == null) { _uiState.update { it.copy(error = "会话信息不完整") } - return@launch + return@safeLaunch } if (pendingJoinToken.isEmpty()) { _uiState.update { it.copy(error = "未获取到加入令牌,请重新验证邀请码") } - return@launch + return@safeLaunch } _uiState.update { it.copy(isLoading = true, error = null) } @@ -780,16 +837,16 @@ class MainViewModel @Inject constructor( * 4. Otherwise waits for session_started event to trigger sign */ fun joinSign(inviteCode: String, shareId: Long, password: String) { - viewModelScope.launch { + safeLaunch { val sessionInfo = _coSignSessionInfo.value if (sessionInfo == null) { _uiState.update { it.copy(error = "会话信息不完整") } - return@launch + return@safeLaunch } if (pendingCoSignJoinToken.isEmpty()) { _uiState.update { it.copy(error = "未获取到加入令牌,请重新验证邀请码") } - return@launch + return@safeLaunch } _uiState.update { it.copy(isLoading = true, error = null) } @@ -1010,7 +1067,11 @@ class MainViewModel @Inject constructor( fun exportShareBackup(shareId: Long, onSuccess: (String) -> Unit) { android.util.Log.d("MainViewModel", "[EXPORT] ========== exportShareBackup called ==========") android.util.Log.d("MainViewModel", "[EXPORT] shareId: $shareId") - viewModelScope.launch { + safeLaunch( + onError = { e -> + _exportResult.value = ExportImportResult(isLoading = false, error = e.message) + } + ) { android.util.Log.d("MainViewModel", "[EXPORT] Setting loading state...") _exportResult.value = ExportImportResult(isLoading = true) @@ -1043,7 +1104,11 @@ class MainViewModel @Inject constructor( android.util.Log.d("MainViewModel", "[IMPORT] ========== importShareBackup called ==========") android.util.Log.d("MainViewModel", "[IMPORT] JSON length: ${backupJson.length}") android.util.Log.d("MainViewModel", "[IMPORT] JSON preview: ${backupJson.take(100)}...") - viewModelScope.launch { + safeLaunch( + onError = { e -> + _importResult.value = ExportImportResult(isLoading = false, error = e.message) + } + ) { android.util.Log.d("MainViewModel", "[IMPORT] Setting loading state...") _importResult.value = ExportImportResult(isLoading = true) @@ -1295,14 +1360,14 @@ class MainViewModel @Inject constructor( * Prepare a transfer transaction */ fun prepareTransfer(shareId: Long, toAddress: String, amount: String, tokenType: TokenType = TokenType.KAVA) { - viewModelScope.launch { + safeLaunch { _uiState.update { it.copy(isLoading = true, error = null) } _transferState.update { it.copy(shareId = shareId, toAddress = toAddress, amount = amount, tokenType = tokenType) } val share = repository.getShareById(shareId) if (share == null) { _uiState.update { it.copy(isLoading = false, error = "钱包不存在") } - return@launch + return@safeLaunch } val rpcUrl = _settings.value.kavaRpcUrl @@ -1342,13 +1407,13 @@ class MainViewModel @Inject constructor( * Signing is triggered when session_started event is received (via startSignAsInitiator) */ fun initiateSignSession(shareId: Long, password: String, initiatorName: String = "发起者") { - viewModelScope.launch { + safeLaunch { _uiState.update { it.copy(isLoading = true, error = null) } val tx = _preparedTx.value if (tx == null) { _uiState.update { it.copy(isLoading = false, error = "交易未准备") } - return@launch + return@safeLaunch } val result = repository.createSignSession( @@ -1422,13 +1487,13 @@ class MainViewModel @Inject constructor( initiatorName: String = "发起者", includeServerBackup: Boolean = false // 新增参数 ) { - viewModelScope.launch { + safeLaunch { _uiState.update { it.copy(isLoading = true, error = null) } val tx = _preparedTx.value if (tx == null) { _uiState.update { it.copy(isLoading = false, error = "交易未准备") } - return@launch + return@safeLaunch } android.util.Log.d("MainViewModel", "[SIGN-OPTIONS] Initiating sign session with includeServerBackup=$includeServerBackup") @@ -1502,7 +1567,7 @@ class MainViewModel @Inject constructor( */ private fun startSigningProcess(sessionId: String, shareId: Long, password: String) { android.util.Log.d("MainViewModel", "[SIGN] startSigningProcess called: sessionId=$sessionId, shareId=$shareId") - viewModelScope.launch { + safeLaunch { android.util.Log.d("MainViewModel", "[SIGN] Calling repository.startSigning...") val startResult = repository.startSigning(sessionId, shareId, password) android.util.Log.d("MainViewModel", "[SIGN] repository.startSigning returned: isSuccess=${startResult.isSuccess}") @@ -1510,7 +1575,7 @@ class MainViewModel @Inject constructor( if (startResult.isFailure) { android.util.Log.e("MainViewModel", "[SIGN] startSigning FAILED: ${startResult.exceptionOrNull()?.message}") _uiState.update { it.copy(error = startResult.exceptionOrNull()?.message) } - return@launch + return@safeLaunch } // Wait for signature @@ -1538,7 +1603,7 @@ class MainViewModel @Inject constructor( */ fun broadcastTransaction() { android.util.Log.d("MainViewModel", "[BROADCAST] broadcastTransaction() called") - viewModelScope.launch { + safeLaunch { android.util.Log.d("MainViewModel", "[BROADCAST] Starting broadcast...") _uiState.update { it.copy(isLoading = true, error = null) } @@ -1551,7 +1616,7 @@ class MainViewModel @Inject constructor( if (tx == null || sig == null) { android.util.Log.e("MainViewModel", "[BROADCAST] Missing tx or signature! tx=$tx, sig=$sig") _uiState.update { it.copy(isLoading = false, error = "交易或签名缺失") } - return@launch + return@safeLaunch } val rpcUrl = _settings.value.kavaRpcUrl @@ -1602,7 +1667,7 @@ class MainViewModel @Inject constructor( * 每 3 秒轮询一次,最多尝试 60 次(3 分钟) */ private fun confirmTransactionInBackground(txHash: String, rpcUrl: String) { - viewModelScope.launch { + safeLaunch { android.util.Log.d("MainViewModel", "[TX-CONFIRM] Starting background confirmation for $txHash") var attempts = 0 val maxAttempts = 60 @@ -1616,7 +1681,7 @@ class MainViewModel @Inject constructor( onSuccess = { confirmed -> if (confirmed) { android.util.Log.d("MainViewModel", "[TX-CONFIRM] Transaction confirmed after $attempts attempts") - return@launch + return@safeLaunch } }, onFailure = { e ->