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:
parent
f77becbdae
commit
3e29b1c23a
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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<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,导致应用崩溃
|
||||
* - 在多个关键路径中(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)
|
||||
|
|
|
|||
|
|
@ -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 ->
|
||||
|
|
|
|||
Loading…
Reference in New Issue