revert(android): 回退到 9f7a5cbb - 所有崩溃修复之前的工作版本

用户确认:9f7a5cbb 是一切正常的版本

包含功能:
-  server-party-co-managed 参与 sign(2-of-3 co-sign)
-  keygen/sign 正常工作
-  备份导出/导入
-  交易记录

删除的崩溃修复(破坏了功能):
-  JobManager
-  requirePartyId
-  coroutineExceptionHandler
-  safeLaunch
-  markPartyReady 重试

状态:编译成功,恢复到正常工作的版本
This commit is contained in:
hailin 2026-01-27 02:10:49 -08:00
parent f77becbdae
commit 3e29b1c23a
4 changed files with 134 additions and 594 deletions

View File

@ -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

View File

@ -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
}
/**

View File

@ -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 变量messageCollectionJobsessionEventJob
* - cleanup() 需要手动取消每个 Job容易遗漏导致协程泄漏
* - 没有统一的生命周期管理和错误处理
*
* 修复的内存泄漏风险
* 1. Activity 销毁时 Job 未取消 后台协程继续运行 内存泄漏 OOM
* 2. 快速重启连接时旧 Job 未取消 多个 Job 并行运行 资源竞争
* 3. 异常导致某个 Job 未取消 僵尸协程 内存累积
*
* JobManager 功能
* - 统一启动和取消所有后台 Job
* - 自动替换同名 Job防止重复启动
* - 一键清理所有 Job防止遗漏
* - 提供 Job 状态查询
*/
private inner class JobManager {
private val jobs = mutableMapOf<String, Job>()
/**
* 启动一个被管理的 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导致应用崩溃
* - 在多个关键路径中startSessionEventSubscriptionstartMessageRouting 会访问 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)

View File

@ -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> = _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 ->