diff --git a/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/MainActivity.kt b/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/MainActivity.kt index 69a27267..2418939d 100644 --- a/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/MainActivity.kt +++ b/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/MainActivity.kt @@ -134,90 +134,19 @@ fun TssPartyApp( android.util.Log.d("MainActivity", "[EXPORT-FILE] pendingExportJson length: ${pendingExportJson?.length ?: 0}") uri?.let { targetUri -> pendingExportJson?.let { json -> - /** - * 【数据完整性保证 - 原子写入 + 完整性验证】 - * - * 问题背景: - * - write() 可能因磁盘满、权限错误等原因中途失败 - * - 部分写入的文件会导致备份损坏,无法恢复钱包 - * - 用户可能误以为备份有效,但实际无法导入 - * - * 修复方案: - * 1. 明确写入流程:openOutputStream → write → flush → close - * 2. 验证写入完整性:读回文件内容并与原始 JSON 比对 - * 3. 失败时删除不完整文件:确保不留下损坏的备份 - * 4. 只有验证通过才显示成功提示 - * - * 原子性保证: - * - 写入成功 → 验证通过 → 显示"备份文件已保存" - * - 写入失败 → 删除文件 → 显示"保存失败: xxx" - */ - var writeSucceeded = false try { android.util.Log.d("MainActivity", "[EXPORT-FILE] Opening output stream to: $targetUri") - val jsonBytes = json.toByteArray(Charsets.UTF_8) - val expectedLength = jsonBytes.size - android.util.Log.d("MainActivity", "[EXPORT-FILE] Writing $expectedLength bytes...") - - // 写入文件(显式检查流创建) - val outputStream = context.contentResolver.openOutputStream(targetUri) - ?: throw Exception("无法创建输出流 - 可能是权限问题或存储已满") - - outputStream.use { - it.write(jsonBytes) - it.flush() // 确保数据真正写入存储 - android.util.Log.d("MainActivity", "[EXPORT-FILE] Write and flush completed") + context.contentResolver.openOutputStream(targetUri)?.use { outputStream -> + android.util.Log.d("MainActivity", "[EXPORT-FILE] Writing ${json.length} bytes...") + outputStream.write(json.toByteArray(Charsets.UTF_8)) + android.util.Log.d("MainActivity", "[EXPORT-FILE] Write completed") } - - // 验证写入完整性(显式检查流创建) - android.util.Log.d("MainActivity", "[EXPORT-FILE] Verifying file integrity...") - val inputStream = context.contentResolver.openInputStream(targetUri) - ?: throw Exception("无法读取已写入的文件 - 文件可能未创建") - - inputStream.use { - val writtenContent = it.bufferedReader().readText() - android.util.Log.d("MainActivity", "[EXPORT-FILE] Read back ${writtenContent.length} bytes") - - // 首先检查文件是否为空(0字节) - if (writtenContent.isEmpty()) { - throw Exception("文件为空 (0 字节) - 写入失败") - } - - // 检查长度是否匹配 - if (writtenContent.length != json.length) { - throw Exception("文件长度不匹配: 期望 ${json.length}, 实际 ${writtenContent.length}") - } - - // 检查内容是否完全一致 - if (writtenContent != json) { - throw Exception("文件内容校验失败 - 数据损坏") - } - - android.util.Log.d("MainActivity", "[EXPORT-FILE] Integrity verification passed: ${writtenContent.length} bytes, content matches") - } - - // 验证通过 - writeSucceeded = true - android.util.Log.d("MainActivity", "[EXPORT-FILE] File saved and verified successfully!") - Toast.makeText(context, "备份文件已保存并验证成功", Toast.LENGTH_SHORT).show() - + android.util.Log.d("MainActivity", "[EXPORT-FILE] File saved successfully!") + Toast.makeText(context, "备份文件已保存", Toast.LENGTH_SHORT).show() } catch (e: Exception) { android.util.Log.e("MainActivity", "[EXPORT-FILE] Failed to save file: ${e.message}", e) - - // 写入失败,尝试删除不完整的文件 - if (!writeSucceeded) { - try { - android.util.Log.w("MainActivity", "[EXPORT-FILE] Deleting incomplete file...") - context.contentResolver.delete(targetUri, null, null) - android.util.Log.w("MainActivity", "[EXPORT-FILE] Incomplete file deleted") - } catch (deleteError: Exception) { - android.util.Log.e("MainActivity", "[EXPORT-FILE] Failed to delete incomplete file", deleteError) - } - } - Toast.makeText(context, "保存失败: ${e.message}", Toast.LENGTH_LONG).show() } - android.util.Log.d("MainActivity", "[EXPORT-FILE] Clearing pendingExportJson and pendingExportAddress") pendingExportJson = null pendingExportAddress = null diff --git a/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/data/remote/GrpcClient.kt b/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/data/remote/GrpcClient.kt index dcbab22b..bb2427c5 100644 --- a/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/data/remote/GrpcClient.kt +++ b/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/data/remote/GrpcClient.kt @@ -231,27 +231,6 @@ class GrpcClient @Inject constructor() { /** * Cleanup all connection resources - * - * 【架构安全修复 - 防止内存泄漏和主线程阻塞】 - * - * 原问题: - * 1. channel.awaitTermination() 是阻塞操作,在主线程调用会导致 ANR (Application Not Responding) - * 2. 如果异常发生,channel 可能未完全关闭,导致: - * - gRPC 连接池泄漏 - * - 后续连接失败(端口/资源占用) - * - 内存持续增长 - * 3. 没有等待 shutdownNow() 完成,强制关闭可能不生效 - * - * 修复方案: - * 1. 立即清空 channel/stub/asyncStub 引用,防止复用已关闭的连接 - * 2. 在后台 IO 线程异步执行 channel 关闭,避免阻塞主线程 - * 3. 优雅关闭(3秒)→ 强制关闭(1秒)→ 完整的异常处理 - * 4. 所有异常路径都确保 shutdownNow() 被调用 - * - * 防止的崩溃场景: - * - Activity.onDestroy() 调用 cleanup() → 主线程阻塞 → ANR - * - 网络切换时快速 disconnect/reconnect → channel 泄漏 → 内存溢出 - * - 异常中断导致 channel 未关闭 → 后续连接失败 → 应用无法使用 */ private fun cleanupConnection() { // Cancel reconnect job @@ -269,56 +248,22 @@ class GrpcClient @Inject constructor() { messageStreamVersion.incrementAndGet() eventStreamVersion.incrementAndGet() - // Shutdown channel gracefully in background (avoid blocking main thread) - val channelToShutdown = channel - if (channelToShutdown != null) { - // Immediately clear references to prevent reuse - channel = null - stub = null - asyncStub = null - - // Perform shutdown asynchronously on IO thread - scope.launch(Dispatchers.IO) { - try { - // Initiate graceful shutdown - channelToShutdown.shutdown() - Log.d(TAG, "Channel shutdown initiated, waiting for termination...") - - // Wait up to 3 seconds for graceful shutdown - val gracefullyTerminated = channelToShutdown.awaitTermination(3, TimeUnit.SECONDS) - - if (!gracefullyTerminated) { - Log.w(TAG, "Channel did not terminate gracefully, forcing shutdown...") - // Force shutdown if graceful shutdown times out - channelToShutdown.shutdownNow() - - // Wait up to 1 second for forced shutdown - val forcedTerminated = channelToShutdown.awaitTermination(1, TimeUnit.SECONDS) - - if (!forcedTerminated) { - Log.e(TAG, "Channel failed to terminate after forced shutdown") - } else { - Log.d(TAG, "Channel terminated after forced shutdown") - } - } else { - Log.d(TAG, "Channel terminated gracefully") - } - } catch (e: InterruptedException) { - Log.e(TAG, "Interrupted while shutting down channel", e) - // Force shutdown on interruption - channelToShutdown.shutdownNow() - // Note: Don't restore interrupt status here as we're in a coroutine - } catch (e: Exception) { - Log.e(TAG, "Unexpected error during channel shutdown", e) - // Attempt force shutdown - try { - channelToShutdown.shutdownNow() - } catch (shutdownError: Exception) { - Log.e(TAG, "Failed to force shutdown channel", shutdownError) - } + // Shutdown channel + channel?.let { ch -> + try { + ch.shutdown() + val terminated = ch.awaitTermination(2, TimeUnit.SECONDS) + if (!terminated) { + ch.shutdownNow() } + } catch (e: Exception) { + Log.e(TAG, "Error shutting down channel: ${e.message}") } + Unit } + channel = null + stub = null + asyncStub = null } /** diff --git a/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/data/repository/TssRepository.kt b/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/data/repository/TssRepository.kt index 54fdad75..35c26a47 100644 --- a/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/data/repository/TssRepository.kt +++ b/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/data/repository/TssRepository.kt @@ -57,87 +57,17 @@ class TssRepository @Inject constructor( // CRITICAL: For backup/restore to work, signing must use the original partyId from keygen private var currentSigningPartyId: String? = null - /** - * JobManager - 统一管理后台协程任务 - * - * 【架构安全修复 - 防止协程泄漏】 - * - * 问题背景: - * - TssRepository 中有 4 个独立的 Job 变量(messageCollectionJob、sessionEventJob 等) - * - cleanup() 需要手动取消每个 Job,容易遗漏导致协程泄漏 - * - 没有统一的生命周期管理和错误处理 - * - * 修复的内存泄漏风险: - * 1. Activity 销毁时 Job 未取消 → 后台协程继续运行 → 内存泄漏 → OOM - * 2. 快速重启连接时旧 Job 未取消 → 多个 Job 并行运行 → 资源竞争 - * 3. 异常导致某个 Job 未取消 → 僵尸协程 → 内存累积 - * - * JobManager 功能: - * - 统一启动和取消所有后台 Job - * - 自动替换同名 Job(防止重复启动) - * - 一键清理所有 Job(防止遗漏) - * - 提供 Job 状态查询 - */ - private inner class JobManager { - private val jobs = mutableMapOf() - - /** - * 启动一个被管理的 Job - * 如果同名 Job 已存在,自动取消旧 Job - */ - fun launch(name: String, block: suspend CoroutineScope.() -> Unit): Job { - // 取消同名的旧 Job - jobs[name]?.cancel() - - // 启动新 Job - val job = repositoryScope.launch(block = block) - jobs[name] = job - - android.util.Log.d("TssRepository", "[JobManager] Launched job: $name (active jobs: ${jobs.size})") - return job - } - - /** - * 取消指定的 Job - */ - fun cancel(name: String) { - jobs[name]?.let { job -> - job.cancel() - jobs.remove(name) - android.util.Log.d("TssRepository", "[JobManager] Cancelled job: $name (remaining jobs: ${jobs.size})") - } - } - - /** - * 检查 Job 是否活跃 - */ - fun isActive(name: String): Boolean { - return jobs[name]?.isActive == true - } - - /** - * 取消所有 Job - */ - fun cancelAll() { - android.util.Log.d("TssRepository", "[JobManager] Cancelling all jobs (total: ${jobs.size})") - jobs.values.forEach { it.cancel() } - jobs.clear() - } - - /** - * 获取活跃的 Job 数量 - */ - fun getActiveJobCount(): Int { - return jobs.values.count { it.isActive } - } - } - - private val jobManager = JobManager() + private var messageCollectionJob: Job? = null + private var sessionEventJob: Job? = null // Pre-registered session ID for event matching (set before joinSession to avoid race condition) // This allows session_started events to be matched even if _currentSession is not yet set private var pendingSessionId: String? = null + // Fallback polling job for session status (handles gRPC stream disconnection on Android) + // Android gRPC streams can disconnect when app goes to background, so we poll as backup + private var sessionStatusPollingJob: Job? = null + // Keygen timeout callback (set by ViewModel) // Called when 5-minute polling timeout is reached without session starting private var keygenTimeoutCallback: ((String) -> Unit)? = null @@ -150,76 +80,14 @@ class TssRepository @Inject constructor( // Called when TSS protocol progress updates (round/totalRounds) private var progressCallback: ((Int, Int) -> Unit)? = null - /** - * 全局协程异常处理器 - * - * 【架构安全修复 - 防止未捕获异常导致应用崩溃】 - * - * 问题背景: - * - 协程中的未捕获异常会传播到父协程 - * - SupervisorJob 虽然防止子协程失败取消其他子协程,但不捕获异常 - * - 未处理的异常最终会导致应用崩溃 - * - * 修复方案: - * - 添加 CoroutineExceptionHandler 捕获所有未处理的异常 - * - 记录详细的异常信息(协程上下文、堆栈) - * - 防止应用崩溃,保持功能可用 - * - * 适用场景: - * 1. 后台消息收集失败 - 不应导致整个应用崩溃 - * 2. 事件订阅异常 - 记录错误但继续运行 - * 3. RPC 调用失败 - 优雅降级而非崩溃 - */ - private val coroutineExceptionHandler = CoroutineExceptionHandler { context, exception -> - android.util.Log.e("TssRepository", "Uncaught coroutine exception in context: $context", exception) + // Job for collecting progress from native bridge + private var progressCollectionJob: Job? = null - // 根据异常类型进行不同处理 - when (exception) { - is CancellationException -> { - // CancellationException 是正常的协程取消,不需要特殊处理 - android.util.Log.d("TssRepository", "Coroutine cancelled: ${exception.message}") - } - is java.net.SocketTimeoutException, - is java.net.UnknownHostException, - is java.io.IOException -> { - // 网络异常 - 可能需要重连 - android.util.Log.w("TssRepository", "Network error in coroutine: ${exception.message}") - // 可以触发重连逻辑或通知 UI - } - is IllegalStateException, - is IllegalArgumentException -> { - // 状态异常 - 可能是编程错误 - android.util.Log.e("TssRepository", "State error in coroutine: ${exception.message}", exception) - // 可以重置状态或通知 UI - } - else -> { - // 其他未知异常 - android.util.Log.e("TssRepository", "Unknown error in coroutine: ${exception.javaClass.simpleName}", exception) - } - } - } - - /** - * Repository-level CoroutineScope for background tasks - * - * 配置: - * - SupervisorJob: 子协程失败不影响其他子协程 - * - Dispatchers.IO: 使用 IO 线程池(适合网络/数据库操作) - * - CoroutineExceptionHandler: 捕获所有未处理的异常,防止崩溃 - */ - private val repositoryScope = CoroutineScope( - SupervisorJob() + - Dispatchers.IO + - coroutineExceptionHandler - ) + // Repository-level CoroutineScope for background tasks + // Uses SupervisorJob so individual task failures don't cancel other tasks + private val repositoryScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) companion object { - // Job 名称常量 - private const val JOB_MESSAGE_COLLECTION = "message_collection" - private const val JOB_SESSION_EVENT = "session_event" - private const val JOB_SESSION_STATUS_POLLING = "session_status_polling" - private const val JOB_PROGRESS_COLLECTION = "progress_collection" - // Polling interval for session status check (matching Electron's 2-second interval) private const val POLL_INTERVAL_MS = 2000L // Maximum wait time for keygen to start after all parties joined (5 minutes, matching Electron) @@ -237,45 +105,6 @@ class TssRepository @Inject constructor( return partyId } - /** - * Safe getter for partyId with fallback - * Used internally to prevent crashes in edge cases - */ - private fun getPartyIdOrNull(): String? { - return if (::partyId.isInitialized) partyId else null - } - - /** - * Ensure partyId is initialized, throw descriptive error if not - * - * 【架构安全修复 - 防止 lateinit 未初始化崩溃】 - * - * 问题背景: - * - partyId 是 lateinit var,必须在 registerParty() 中初始化后才能使用 - * - 直接访问未初始化的 lateinit var 会抛出 UninitializedPropertyAccessException,导致应用崩溃 - * - 在多个关键路径中(startSessionEventSubscription、startMessageRouting 等)会访问 partyId - * - * 修复的崩溃场景: - * 1. 网络重连时,如果 registerParty() 未完成就触发订阅 → 崩溃 - * 2. Activity 快速销毁重建时,初始化顺序错乱 → 崩溃 - * 3. 后台恢复时,Repository 状态不一致 → 崩溃 - * - * 解决方案: - * - 在所有访问 partyId 的地方使用 requirePartyId() 进行强制检查 - * - 提供清晰的错误日志,帮助定位问题 - * - 比直接访问 partyId 多一层保护,确保 100% 不会因未初始化而崩溃 - * - * @return partyId if initialized - * @throws IllegalStateException if partyId not initialized (with clear error message) - */ - private fun requirePartyId(): String { - if (!::partyId.isInitialized) { - android.util.Log.e("TssRepository", "partyId not initialized - registerParty() was not called") - throw IllegalStateException("partyId not initialized. Call registerParty() first.") - } - return partyId - } - // Track current message routing params for reconnection recovery private var currentMessageRoutingSessionId: String? = null private var currentMessageRoutingPartyIndex: Int? = null @@ -318,34 +147,10 @@ class TssRepository @Inject constructor( } } - /** - * HTTP client for API calls - * - * 【架构安全修复 - 配置连接池防止资源泄漏】 - * - * OkHttpClient 内部维护资源: - * - ConnectionPool: 连接池,复用 HTTP 连接 - * - Dispatcher: 调度器,管理线程池 - * - Cache: 可选的响应缓存 - * - * 如果不配置连接池参数和不清理资源,会导致: - * 1. 连接池无限增长 → 内存泄漏 - * 2. 空闲连接永久保持 → 占用系统资源 - * 3. Dispatcher 线程池未关闭 → 线程泄漏 - * - * 配置策略: - * - maxIdleConnections: 5 (最多保留 5 个空闲连接) - * - keepAliveDuration: 5 分钟 (空闲连接保活时间) - * - 在 cleanup() 中清理所有资源 - */ + // HTTP client for API calls private val httpClient = okhttp3.OkHttpClient.Builder() .connectTimeout(30, java.util.concurrent.TimeUnit.SECONDS) .readTimeout(30, java.util.concurrent.TimeUnit.SECONDS) - .connectionPool(okhttp3.ConnectionPool( - maxIdleConnections = 5, - keepAliveDuration = 5, - timeUnit = java.util.concurrent.TimeUnit.MINUTES - )) .build() /** @@ -366,45 +171,22 @@ class TssRepository @Inject constructor( * Disconnect from the server */ fun disconnect() { - // 使用 JobManager 统一取消所有后台任务 - jobManager.cancelAll() + messageCollectionJob?.cancel() + sessionEventJob?.cancel() + sessionStatusPollingJob?.cancel() grpcClient.disconnect() } /** * Cleanup all resources when the repository is destroyed. * Call this when the app is being destroyed to prevent memory leaks. - * - * 【架构安全修复 - 使用 JobManager 统一清理】 - * 替换手动取消每个 Job 的方式,防止遗漏导致内存泄漏 - * - * 【架构安全修复 - 清理 OkHttpClient 资源】 - * OkHttpClient 维护连接池和线程池,必须显式清理: - * 1. evictAll() - 关闭并移除所有空闲连接 - * 2. executorService().shutdown() - 关闭调度器线程池 - * 3. connectionPool().evictAll() - 清空连接池 */ fun cleanup() { - // 使用 JobManager 统一取消所有后台任务 - jobManager.cancelAll() + messageCollectionJob?.cancel() + sessionEventJob?.cancel() + sessionStatusPollingJob?.cancel() repositoryScope.cancel() grpcClient.disconnect() - - // 清理 OkHttpClient 资源 - try { - // 1. 关闭所有空闲连接 - httpClient.connectionPool.evictAll() - - // 2. 关闭调度器线程池 - httpClient.dispatcher.executorService.shutdown() - - // 3. 如果配置了缓存,清理缓存 - httpClient.cache?.close() - - android.util.Log.d("TssRepository", "OkHttpClient resources cleaned up successfully") - } catch (e: Exception) { - android.util.Log.e("TssRepository", "Failed to cleanup OkHttpClient resources", e) - } } /** @@ -477,14 +259,12 @@ class TssRepository @Inject constructor( * from keygen (shareEntity.partyId) so that session events are received correctly. */ private fun startSessionEventSubscription(subscriptionPartyId: String? = null) { - val devicePartyId = requirePartyId() // Ensure partyId is initialized - val effectivePartyId = subscriptionPartyId ?: devicePartyId + sessionEventJob?.cancel() + val effectivePartyId = subscriptionPartyId ?: partyId // Save for reconnection recovery currentSessionEventPartyId = effectivePartyId - android.util.Log.d("TssRepository", "Starting session event subscription for partyId: $effectivePartyId (device partyId: $devicePartyId)") - - // 使用 JobManager 启动(自动取消同名旧 Job) - jobManager.launch(JOB_SESSION_EVENT) { + android.util.Log.d("TssRepository", "Starting session event subscription for partyId: $effectivePartyId (device partyId: $partyId)") + sessionEventJob = repositoryScope.launch { grpcClient.subscribeSessionEvents(effectivePartyId).collect { event -> android.util.Log.d("TssRepository", "=== Session event received ===") android.util.Log.d("TssRepository", " eventType: ${event.eventType}") @@ -585,10 +365,9 @@ class TssRepository @Inject constructor( * partyId from keygen (shareEntity.partyId). */ private fun ensureSessionEventSubscriptionActive(signingPartyId: String? = null) { - // Check if the session event job is still active using JobManager - val isActive = jobManager.isActive(JOB_SESSION_EVENT) - val devicePartyId = requirePartyId() // Ensure partyId is initialized - val effectivePartyId = signingPartyId ?: currentSessionEventPartyId ?: devicePartyId + // Check if the session event job is still active + val isActive = sessionEventJob?.isActive == true + val effectivePartyId = signingPartyId ?: currentSessionEventPartyId ?: partyId android.util.Log.d("TssRepository", "Checking session event subscription: isActive=$isActive, effectivePartyId=$effectivePartyId") if (!isActive) { @@ -653,10 +432,12 @@ class TssRepository @Inject constructor( * Called when keygen/sign session starts */ private fun startProgressCollection() { + // Cancel any existing progress collection + progressCollectionJob?.cancel() + android.util.Log.d("TssRepository", "[PROGRESS] Starting progress collection from native bridge") - // 使用 JobManager 启动(自动取消同名旧 Job) - jobManager.launch(JOB_PROGRESS_COLLECTION) { + progressCollectionJob = repositoryScope.launch { tssNativeBridge.progress.collect { (round, totalRounds) -> android.util.Log.d("TssRepository", "[PROGRESS] Round $round / $totalRounds") withContext(Dispatchers.Main) { @@ -671,7 +452,8 @@ class TssRepository @Inject constructor( * Called when session ends or is cancelled */ private fun stopProgressCollection() { - jobManager.cancel(JOB_PROGRESS_COLLECTION) + progressCollectionJob?.cancel() + progressCollectionJob = null android.util.Log.d("TssRepository", "[PROGRESS] Progress collection stopped") } @@ -690,13 +472,15 @@ class TssRepository @Inject constructor( * @param sessionType "keygen" or "sign" - determines which callback to invoke */ fun startSessionStatusPolling(sessionId: String, sessionType: String = "keygen") { + // Cancel any existing polling job + sessionStatusPollingJob?.cancel() + android.util.Log.d("TssRepository", "[POLLING] Starting session status polling for $sessionType session: $sessionId") // Notify UI that countdown has started (5 minutes = 300 seconds) countdownTickCallback?.invoke(MAX_WAIT_MS / 1000) - // 使用 JobManager 启动(自动取消同名旧 Job) - jobManager.launch(JOB_SESSION_STATUS_POLLING) { + sessionStatusPollingJob = repositoryScope.launch { val startTime = System.currentTimeMillis() var pollCount = 0 var lastTickSecond = MAX_WAIT_MS / 1000 @@ -806,7 +590,8 @@ class TssRepository @Inject constructor( * - User navigates away */ fun stopSessionStatusPolling() { - jobManager.cancel(JOB_SESSION_STATUS_POLLING) + sessionStatusPollingJob?.cancel() + sessionStatusPollingJob = null // Clear countdown display when polling is stopped countdownTickCallback?.invoke(-1L) android.util.Log.d("TssRepository", "[POLLING] Session status polling stopped") @@ -1344,32 +1129,8 @@ class TssRepository @Inject constructor( _sessionStatus.value = SessionStatus.IN_PROGRESS - // Mark ready - with retry on optimistic lock conflict - var markReadySuccess = false - repeat(5) { attempt -> - if (markReadySuccess) return@repeat // Already succeeded, skip remaining attempts - - val markReadyResult = grpcClient.markPartyReady(sessionId, partyId) - if (markReadyResult.isSuccess) { - android.util.Log.d("TssRepository", "markPartyReady successful on attempt ${attempt + 1}") - markReadySuccess = true - return@repeat - } - val error = markReadyResult.exceptionOrNull() - android.util.Log.w("TssRepository", "markPartyReady attempt ${attempt + 1} failed: ${error?.message}") - if (error?.message?.contains("optimistic lock conflict") == true && attempt < 4) { - android.util.Log.d("TssRepository", "Retrying after ${(attempt + 1) * 500}ms...") - delay((attempt + 1) * 500L) - } - } - - // Check if any attempt succeeded - if (!markReadySuccess) { - android.util.Log.e("TssRepository", "All markPartyReady attempts failed") - stopProgressCollection() - _sessionStatus.value = SessionStatus.FAILED - return@coroutineScope Result.failure(Exception("Failed to mark party ready after 5 attempts")) - } + // Mark ready + grpcClient.markPartyReady(sessionId, partyId) // Wait for keygen result val keygenResult = tssNativeBridge.waitForKeygenResult(password) @@ -1619,7 +1380,7 @@ class TssRepository @Inject constructor( stopProgressCollection() _sessionStatus.value = SessionStatus.COMPLETED pendingSessionId = null // Clear pending session ID on completion - jobManager.cancel(JOB_MESSAGE_COLLECTION) + messageCollectionJob?.cancel() currentSigningPartyId = null // Clear after signing completes android.util.Log.d("TssRepository", "Sign as joiner completed: signature=${result.signature.take(20)}...") @@ -1741,7 +1502,7 @@ class TssRepository @Inject constructor( grpcClient.reportCompletion(apiJoinData.sessionId, partyId, publicKeyBytes) _sessionStatus.value = SessionStatus.COMPLETED - jobManager.cancel(JOB_MESSAGE_COLLECTION) + messageCollectionJob?.cancel() Result.success(shareEntity.copy(id = id).toShareRecord()) @@ -1926,7 +1687,7 @@ class TssRepository @Inject constructor( grpcClient.reportCompletion(apiJoinData.sessionId, signingPartyId, signature = signatureBytes) _sessionStatus.value = SessionStatus.COMPLETED - jobManager.cancel(JOB_MESSAGE_COLLECTION) + messageCollectionJob?.cancel() currentSigningPartyId = null // Clear after signing completes Result.success(result) @@ -2033,12 +1794,12 @@ class TssRepository @Inject constructor( currentMessageRoutingPartyIndex = partyIndex // Use provided routingPartyId, or fall back to device partyId for keygen - val devicePartyId = requirePartyId() // Ensure partyId is initialized - val effectivePartyId = routingPartyId ?: devicePartyId + val effectivePartyId = routingPartyId ?: partyId // Save for reconnection recovery currentMessageRoutingPartyId = effectivePartyId - jobManager.launch(JOB_MESSAGE_COLLECTION) { + messageCollectionJob?.cancel() + messageCollectionJob = repositoryScope.launch { android.util.Log.d("TssRepository", "Starting message routing: sessionId=$sessionId, routingPartyId=$effectivePartyId") // Collect outgoing messages from TSS and route via gRPC @@ -2158,34 +1919,8 @@ class TssRepository @Inject constructor( _sessionStatus.value = SessionStatus.IN_PROGRESS - // Mark ready - with retry on optimistic lock conflict - var markReadySuccess = false - repeat(5) { attempt -> - if (markReadySuccess) return@repeat // Already succeeded, skip remaining attempts - - val markReadyResult = grpcClient.markPartyReady(sessionId, partyId) - if (markReadyResult.isSuccess) { - android.util.Log.d("TssRepository", "Successfully marked party ready on attempt ${attempt + 1}") - markReadySuccess = true - return@repeat - } else { - val error = markReadyResult.exceptionOrNull() - android.util.Log.w("TssRepository", "markPartyReady attempt ${attempt + 1} failed: ${error?.message}") - - // If it's optimistic lock conflict, retry after a short delay - if (error?.message?.contains("optimistic lock conflict") == true && attempt < 4) { - android.util.Log.d("TssRepository", "Optimistic lock conflict detected, retrying after ${(attempt + 1) * 500}ms...") - delay((attempt + 1) * 500L) // 500ms, 1s, 1.5s, 2s - } - } - } - - if (!markReadySuccess) { - android.util.Log.e("TssRepository", "All markPartyReady attempts failed") - stopProgressCollection() - _sessionStatus.value = SessionStatus.FAILED - return@coroutineScope Result.failure(Exception("Failed to mark party ready after 5 attempts")) - } + // Mark ready + grpcClient.markPartyReady(sessionId, partyId) // Wait for keygen result val keygenResult = tssNativeBridge.waitForKeygenResult(password) @@ -2221,7 +1956,7 @@ class TssRepository @Inject constructor( stopProgressCollection() _sessionStatus.value = SessionStatus.COMPLETED pendingSessionId = null // Clear pending session ID on completion - jobManager.cancel(JOB_SESSION_EVENT) + sessionEventJob?.cancel() Result.success(shareEntity.copy(id = id).toShareRecord()) @@ -2239,8 +1974,8 @@ class TssRepository @Inject constructor( */ fun cancelSession() { tssNativeBridge.cancelSession() - jobManager.cancel(JOB_MESSAGE_COLLECTION) - jobManager.cancel(JOB_SESSION_EVENT) + messageCollectionJob?.cancel() + sessionEventJob?.cancel() stopSessionStatusPolling() // Stop polling when session is cancelled _currentSession.value = null _sessionStatus.value = SessionStatus.WAITING @@ -2903,7 +2638,7 @@ class TssRepository @Inject constructor( // Note: Message routing is already started in createSignSession after auto-join // Only start if not already running (for backward compatibility with old flow) - if (!jobManager.isActive(JOB_MESSAGE_COLLECTION)) { + if (messageCollectionJob == null || messageCollectionJob?.isActive != true) { // CRITICAL: Use signingPartyId for message routing startMessageRouting(sessionId, shareEntity.partyIndex, signingPartyId) } @@ -2945,7 +2680,7 @@ class TssRepository @Inject constructor( stopProgressCollection() _sessionStatus.value = SessionStatus.COMPLETED - jobManager.cancel(JOB_MESSAGE_COLLECTION) + messageCollectionJob?.cancel() currentSigningPartyId = null // Clear after signing completes Result.success(result) 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 ff96c47f..cdd7fbf3 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,8 +8,9 @@ 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.* +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch import javax.inject.Inject @HiltViewModel @@ -17,59 +18,6 @@ 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() @@ -109,12 +57,7 @@ class MainViewModel @Inject constructor( * Check all services for startup */ fun checkAllServices() { - safeLaunch( - onError = { e -> - _appState.update { it.copy(appReady = AppReadyState.ERROR) } - android.util.Log.e("MainViewModel", "Service check failed", e) - } - ) { + viewModelScope.launch { _appState.update { it.copy(appReady = AppReadyState.INITIALIZING) } var hasError = false @@ -235,7 +178,7 @@ class MainViewModel @Inject constructor( * Connect to Message Router server */ fun connectToServer(serverUrl: String) { - safeLaunch { + viewModelScope.launch { try { val parts = serverUrl.split(":") val host = parts[0] @@ -283,7 +226,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) { - safeLaunch { + viewModelScope.launch { _uiState.update { it.copy(isLoading = true, error = null) } val result = repository.createKeygenSession(walletName, thresholdT, thresholdN, participantName) @@ -395,7 +338,7 @@ class MainViewModel @Inject constructor( val currentSessionId = _currentSessionId.value if (currentSessionId != null && event.sessionId == currentSessionId) { android.util.Log.d("MainViewModel", "Session started event for keygen initiator, triggering keygen") - safeLaunch { + viewModelScope.launch { startKeygenAsInitiator( sessionId = currentSessionId, thresholdT = event.thresholdT, @@ -430,55 +373,51 @@ class MainViewModel @Inject constructor( } } "party_joined", "participant_joined" -> { - /** - * 【架构安全修复 - 防止参与者计数竞态条件】 - * - * 原问题:使用 current.size + 1 递增计数器存在多个风险 - * 1. 事件重放:重连后事件可能重复发送,导致参与者重复添加 - * 2. 事件乱序:网络延迟可能导致事件乱序到达,参与者编号错乱 - * 3. 状态不一致:本地计数与服务端真实参与者列表不同步 - * - * 修复方案:使用事件的 selectedParties 字段构建权威的参与者列表 - * - selectedParties 来自服务端,是参与者的唯一真实来源 - * - 根据 selectedParties.size 构建参与者列表,确保与服务端一致 - * - 防止重复添加和计数错乱 - */ - android.util.Log.d("MainViewModel", "Processing participant_joined event (selectedParties=${event.selectedParties.size})...") - - // Build participant list from authoritative server data - val participantCount = event.selectedParties.size - val participantList = List(participantCount) { index -> "参与方 ${index + 1}" } - android.util.Log.d("MainViewModel", " → Building participant list from server data: $participantList") + android.util.Log.d("MainViewModel", "Processing participant_joined event...") // Update participant count for initiator's CreateWallet screen val currentSessionId = _currentSessionId.value android.util.Log.d("MainViewModel", " Checking for initiator: currentSessionId=$currentSessionId, eventSessionId=${event.sessionId}") if (currentSessionId != null && event.sessionId == currentSessionId) { - android.util.Log.d("MainViewModel", " → Matched initiator session! Updating _sessionParticipants to: $participantList") - _sessionParticipants.value = participantList + android.util.Log.d("MainViewModel", " → Matched initiator session! Updating _sessionParticipants") + _sessionParticipants.update { current -> + val newParticipant = "参与方 ${current.size + 1}" + android.util.Log.d("MainViewModel", " → Adding participant: $newParticipant, total now: ${current.size + 1}") + current + newParticipant + } } // Update participant count for keygen joiner's JoinKeygen screen val joinKeygenInfo = pendingJoinKeygenInfo android.util.Log.d("MainViewModel", " Checking for joiner: joinKeygenInfo?.sessionId=${joinKeygenInfo?.sessionId}") if (joinKeygenInfo != null && event.sessionId == joinKeygenInfo.sessionId) { - android.util.Log.d("MainViewModel", " → Matched joiner session! Updating _joinKeygenParticipants to: $participantList") - _joinKeygenParticipants.value = participantList + android.util.Log.d("MainViewModel", " → Matched joiner session! Updating _joinKeygenParticipants") + _joinKeygenParticipants.update { current -> + val newParticipant = "参与方 ${current.size + 1}" + current + newParticipant + } } // Update participant count for sign joiner's CoSign screen val joinSignInfo = pendingJoinSignInfo if (joinSignInfo != null && event.sessionId == joinSignInfo.sessionId) { - android.util.Log.d("MainViewModel", " → Matched sign joiner session! Updating _coSignParticipants to: $participantList") - _coSignParticipants.value = participantList + android.util.Log.d("MainViewModel", " → Matched sign joiner session! Updating _coSignParticipants") + _coSignParticipants.update { current -> + val newParticipant = "参与方 ${current.size + 1}" + current + newParticipant + } } // Update participant count for sign initiator's TransferScreen (SigningScreen) val signSessionId = _signSessionId.value android.util.Log.d("MainViewModel", " Checking for sign initiator: signSessionId=$signSessionId, eventSessionId=${event.sessionId}") if (signSessionId != null && event.sessionId == signSessionId) { - android.util.Log.d("MainViewModel", " → Matched sign initiator session! Updating _signParticipants to: $participantList") - _signParticipants.value = participantList + android.util.Log.d("MainViewModel", " → Matched sign initiator session! Updating _signParticipants") + _signParticipants.update { current -> + val newParticipant = "参与方 ${current.size + 1}" + android.util.Log.d("MainViewModel", " → Adding participant: $newParticipant, total now: ${current.size + 1}") + current + newParticipant + } } } "all_joined" -> { @@ -607,7 +546,7 @@ class MainViewModel @Inject constructor( * Matches Electron's grpc:validateInviteCode - returns sessionInfo + joinToken */ fun validateInviteCode(inviteCode: String) { - safeLaunch { + viewModelScope.launch { _uiState.update { it.copy(isLoading = true, error = null) } pendingInviteCode = inviteCode @@ -647,16 +586,16 @@ class MainViewModel @Inject constructor( * 3. Waits for session_started event to trigger keygen */ fun joinKeygen(inviteCode: String, password: String) { - safeLaunch { + viewModelScope.launch { val sessionInfo = _joinSessionInfo.value if (sessionInfo == null) { _uiState.update { it.copy(error = "会话信息不完整") } - return@safeLaunch + return@launch } if (pendingJoinToken.isEmpty()) { _uiState.update { it.copy(error = "未获取到加入令牌,请重新验证邀请码") } - return@safeLaunch + return@launch } _uiState.update { it.copy(isLoading = true, error = null) } @@ -717,7 +656,7 @@ class MainViewModel @Inject constructor( private fun startKeygenAsJoiner() { val joinInfo = pendingJoinKeygenInfo ?: return - safeLaunch { + viewModelScope.launch { _uiState.update { it.copy(isLoading = true, error = null) } android.util.Log.d("MainViewModel", "Starting keygen as joiner: sessionId=${joinInfo.sessionId}, partyIndex=${joinInfo.partyIndex}") @@ -795,7 +734,7 @@ class MainViewModel @Inject constructor( * Matches Electron's cosign:validateInviteCode - returns sessionInfo + joinToken + parties */ fun validateSignInviteCode(inviteCode: String) { - safeLaunch { + viewModelScope.launch { _uiState.update { it.copy(isLoading = true, error = null) } pendingCoSignInviteCode = inviteCode @@ -837,16 +776,16 @@ class MainViewModel @Inject constructor( * 4. Otherwise waits for session_started event to trigger sign */ fun joinSign(inviteCode: String, shareId: Long, password: String) { - safeLaunch { + viewModelScope.launch { val sessionInfo = _coSignSessionInfo.value if (sessionInfo == null) { _uiState.update { it.copy(error = "会话信息不完整") } - return@safeLaunch + return@launch } if (pendingCoSignJoinToken.isEmpty()) { _uiState.update { it.copy(error = "未获取到加入令牌,请重新验证邀请码") } - return@safeLaunch + return@launch } _uiState.update { it.copy(isLoading = true, error = null) } @@ -905,7 +844,7 @@ class MainViewModel @Inject constructor( private fun startSignAsJoiner() { val signInfo = pendingJoinSignInfo ?: return - safeLaunch { + viewModelScope.launch { _uiState.update { it.copy(isLoading = true, error = null) } android.util.Log.d("MainViewModel", "Starting sign as joiner: sessionId=${signInfo.sessionId}, partyIndex=${signInfo.partyIndex}") @@ -969,7 +908,7 @@ class MainViewModel @Inject constructor( * Delete a share */ fun deleteShare(id: Long) { - safeLaunch { + viewModelScope.launch { repository.deleteShare(id) // Update wallet count _appState.update { state -> @@ -997,7 +936,7 @@ class MainViewModel @Inject constructor( * 加载钱包的交易记录 */ fun loadTransactionRecords(shareId: Long) { - safeLaunch { + viewModelScope.launch { repository.getTransactionRecords(shareId).collect { records -> _transactionRecords.value = records } @@ -1009,7 +948,7 @@ class MainViewModel @Inject constructor( * 首次导入钱包时调用 */ fun syncTransactionHistory(shareId: Long, address: String) { - safeLaunch { + viewModelScope.launch { _isSyncingHistory.value = true android.util.Log.d("MainViewModel", "[SYNC] Starting transaction history sync for $address") @@ -1040,7 +979,7 @@ class MainViewModel @Inject constructor( * 应用启动时调用 */ fun confirmPendingTransactions() { - safeLaunch { + viewModelScope.launch { val rpcUrl = _settings.value.kavaRpcUrl val pendingRecords = repository.getPendingTransactions() android.util.Log.d("MainViewModel", "[TX-CONFIRM] Found ${pendingRecords.size} pending transactions") @@ -1067,11 +1006,7 @@ 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") - safeLaunch( - onError = { e -> - _exportResult.value = ExportImportResult(isLoading = false, error = e.message) - } - ) { + viewModelScope.launch { android.util.Log.d("MainViewModel", "[EXPORT] Setting loading state...") _exportResult.value = ExportImportResult(isLoading = true) @@ -1104,11 +1039,7 @@ 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)}...") - safeLaunch( - onError = { e -> - _importResult.value = ExportImportResult(isLoading = false, error = e.message) - } - ) { + viewModelScope.launch { android.util.Log.d("MainViewModel", "[IMPORT] Setting loading state...") _importResult.value = ExportImportResult(isLoading = true) @@ -1181,7 +1112,7 @@ class MainViewModel @Inject constructor( * Test Message Router connection */ fun testMessageRouter(serverUrl: String) { - safeLaunch { + viewModelScope.launch { _messageRouterTestResult.value = null val result = repository.testMessageRouter(serverUrl) result.fold( @@ -1206,7 +1137,7 @@ class MainViewModel @Inject constructor( * Test Account Service connection */ fun testAccountService(serviceUrl: String) { - safeLaunch { + viewModelScope.launch { _accountServiceTestResult.value = null val result = repository.testAccountService(serviceUrl) result.fold( @@ -1231,7 +1162,7 @@ class MainViewModel @Inject constructor( * Test Kava API connection */ fun testKavaApi(rpcUrl: String) { - safeLaunch { + viewModelScope.launch { _kavaApiTestResult.value = null val result = repository.testKavaApi(rpcUrl) result.fold( @@ -1286,7 +1217,7 @@ class MainViewModel @Inject constructor( * Now fetches both KAVA and Green Points (绿积分) balances */ fun fetchBalanceForShare(share: ShareRecord) { - safeLaunch { + viewModelScope.launch { val rpcUrl = _settings.value.kavaRpcUrl // Ensure we use EVM address format for RPC calls val evmAddress = AddressUtils.getEvmAddress(share.address, share.publicKey) @@ -1306,7 +1237,7 @@ class MainViewModel @Inject constructor( * Fetch balance for a wallet address (for already-EVM addresses) */ fun fetchBalance(address: String) { - safeLaunch { + viewModelScope.launch { val rpcUrl = _settings.value.kavaRpcUrl val result = repository.getWalletBalance(address, rpcUrl) result.onSuccess { walletBalance -> @@ -1320,7 +1251,7 @@ class MainViewModel @Inject constructor( * Fetch balances for all wallets */ fun fetchAllBalances() { - safeLaunch { + viewModelScope.launch { shares.value.forEach { share -> fetchBalanceForShare(share) } @@ -1360,14 +1291,14 @@ class MainViewModel @Inject constructor( * Prepare a transfer transaction */ fun prepareTransfer(shareId: Long, toAddress: String, amount: String, tokenType: TokenType = TokenType.KAVA) { - safeLaunch { + viewModelScope.launch { _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@safeLaunch + return@launch } val rpcUrl = _settings.value.kavaRpcUrl @@ -1407,13 +1338,13 @@ class MainViewModel @Inject constructor( * Signing is triggered when session_started event is received (via startSignAsInitiator) */ fun initiateSignSession(shareId: Long, password: String, initiatorName: String = "发起者") { - safeLaunch { + viewModelScope.launch { _uiState.update { it.copy(isLoading = true, error = null) } val tx = _preparedTx.value if (tx == null) { _uiState.update { it.copy(isLoading = false, error = "交易未准备") } - return@safeLaunch + return@launch } val result = repository.createSignSession( @@ -1487,13 +1418,13 @@ class MainViewModel @Inject constructor( initiatorName: String = "发起者", includeServerBackup: Boolean = false // 新增参数 ) { - safeLaunch { + viewModelScope.launch { _uiState.update { it.copy(isLoading = true, error = null) } val tx = _preparedTx.value if (tx == null) { _uiState.update { it.copy(isLoading = false, error = "交易未准备") } - return@safeLaunch + return@launch } android.util.Log.d("MainViewModel", "[SIGN-OPTIONS] Initiating sign session with includeServerBackup=$includeServerBackup") @@ -1567,7 +1498,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") - safeLaunch { + viewModelScope.launch { 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}") @@ -1575,7 +1506,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@safeLaunch + return@launch } // Wait for signature @@ -1603,7 +1534,7 @@ class MainViewModel @Inject constructor( */ fun broadcastTransaction() { android.util.Log.d("MainViewModel", "[BROADCAST] broadcastTransaction() called") - safeLaunch { + viewModelScope.launch { android.util.Log.d("MainViewModel", "[BROADCAST] Starting broadcast...") _uiState.update { it.copy(isLoading = true, error = null) } @@ -1616,7 +1547,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@safeLaunch + return@launch } val rpcUrl = _settings.value.kavaRpcUrl @@ -1667,7 +1598,7 @@ class MainViewModel @Inject constructor( * 每 3 秒轮询一次,最多尝试 60 次(3 分钟) */ private fun confirmTransactionInBackground(txHash: String, rpcUrl: String) { - safeLaunch { + viewModelScope.launch { android.util.Log.d("MainViewModel", "[TX-CONFIRM] Starting background confirmation for $txHash") var attempts = 0 val maxAttempts = 60 @@ -1681,7 +1612,7 @@ class MainViewModel @Inject constructor( onSuccess = { confirmed -> if (confirmed) { android.util.Log.d("MainViewModel", "[TX-CONFIRM] Transaction confirmed after $attempts attempts") - return@safeLaunch + return@launch } }, onFailure = { e ->