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}")
|
android.util.Log.d("MainActivity", "[EXPORT-FILE] pendingExportJson length: ${pendingExportJson?.length ?: 0}")
|
||||||
uri?.let { targetUri ->
|
uri?.let { targetUri ->
|
||||||
pendingExportJson?.let { json ->
|
pendingExportJson?.let { json ->
|
||||||
/**
|
|
||||||
* 【数据完整性保证 - 原子写入 + 完整性验证】
|
|
||||||
*
|
|
||||||
* 问题背景:
|
|
||||||
* - write() 可能因磁盘满、权限错误等原因中途失败
|
|
||||||
* - 部分写入的文件会导致备份损坏,无法恢复钱包
|
|
||||||
* - 用户可能误以为备份有效,但实际无法导入
|
|
||||||
*
|
|
||||||
* 修复方案:
|
|
||||||
* 1. 明确写入流程:openOutputStream → write → flush → close
|
|
||||||
* 2. 验证写入完整性:读回文件内容并与原始 JSON 比对
|
|
||||||
* 3. 失败时删除不完整文件:确保不留下损坏的备份
|
|
||||||
* 4. 只有验证通过才显示成功提示
|
|
||||||
*
|
|
||||||
* 原子性保证:
|
|
||||||
* - 写入成功 → 验证通过 → 显示"备份文件已保存"
|
|
||||||
* - 写入失败 → 删除文件 → 显示"保存失败: xxx"
|
|
||||||
*/
|
|
||||||
var writeSucceeded = false
|
|
||||||
try {
|
try {
|
||||||
android.util.Log.d("MainActivity", "[EXPORT-FILE] Opening output stream to: $targetUri")
|
android.util.Log.d("MainActivity", "[EXPORT-FILE] Opening output stream to: $targetUri")
|
||||||
val jsonBytes = json.toByteArray(Charsets.UTF_8)
|
context.contentResolver.openOutputStream(targetUri)?.use { outputStream ->
|
||||||
val expectedLength = jsonBytes.size
|
android.util.Log.d("MainActivity", "[EXPORT-FILE] Writing ${json.length} bytes...")
|
||||||
android.util.Log.d("MainActivity", "[EXPORT-FILE] Writing $expectedLength bytes...")
|
outputStream.write(json.toByteArray(Charsets.UTF_8))
|
||||||
|
android.util.Log.d("MainActivity", "[EXPORT-FILE] Write completed")
|
||||||
// 写入文件(显式检查流创建)
|
|
||||||
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")
|
|
||||||
}
|
}
|
||||||
|
android.util.Log.d("MainActivity", "[EXPORT-FILE] File saved successfully!")
|
||||||
// 验证写入完整性(显式检查流创建)
|
Toast.makeText(context, "备份文件已保存", Toast.LENGTH_SHORT).show()
|
||||||
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()
|
|
||||||
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
android.util.Log.e("MainActivity", "[EXPORT-FILE] Failed to save file: ${e.message}", e)
|
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()
|
Toast.makeText(context, "保存失败: ${e.message}", Toast.LENGTH_LONG).show()
|
||||||
}
|
}
|
||||||
|
|
||||||
android.util.Log.d("MainActivity", "[EXPORT-FILE] Clearing pendingExportJson and pendingExportAddress")
|
android.util.Log.d("MainActivity", "[EXPORT-FILE] Clearing pendingExportJson and pendingExportAddress")
|
||||||
pendingExportJson = null
|
pendingExportJson = null
|
||||||
pendingExportAddress = null
|
pendingExportAddress = null
|
||||||
|
|
|
||||||
|
|
@ -231,27 +231,6 @@ class GrpcClient @Inject constructor() {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Cleanup all connection resources
|
* 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() {
|
private fun cleanupConnection() {
|
||||||
// Cancel reconnect job
|
// Cancel reconnect job
|
||||||
|
|
@ -269,56 +248,22 @@ class GrpcClient @Inject constructor() {
|
||||||
messageStreamVersion.incrementAndGet()
|
messageStreamVersion.incrementAndGet()
|
||||||
eventStreamVersion.incrementAndGet()
|
eventStreamVersion.incrementAndGet()
|
||||||
|
|
||||||
// Shutdown channel gracefully in background (avoid blocking main thread)
|
// Shutdown channel
|
||||||
val channelToShutdown = channel
|
channel?.let { ch ->
|
||||||
if (channelToShutdown != null) {
|
try {
|
||||||
// Immediately clear references to prevent reuse
|
ch.shutdown()
|
||||||
channel = null
|
val terminated = ch.awaitTermination(2, TimeUnit.SECONDS)
|
||||||
stub = null
|
if (!terminated) {
|
||||||
asyncStub = null
|
ch.shutdownNow()
|
||||||
|
|
||||||
// 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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
} 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
|
// CRITICAL: For backup/restore to work, signing must use the original partyId from keygen
|
||||||
private var currentSigningPartyId: String? = null
|
private var currentSigningPartyId: String? = null
|
||||||
|
|
||||||
/**
|
private var messageCollectionJob: Job? = null
|
||||||
* JobManager - 统一管理后台协程任务
|
private var sessionEventJob: Job? = null
|
||||||
*
|
|
||||||
* 【架构安全修复 - 防止协程泄漏】
|
|
||||||
*
|
|
||||||
* 问题背景:
|
|
||||||
* - 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()
|
|
||||||
|
|
||||||
// Pre-registered session ID for event matching (set before joinSession to avoid race condition)
|
// 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
|
// This allows session_started events to be matched even if _currentSession is not yet set
|
||||||
private var pendingSessionId: String? = null
|
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)
|
// Keygen timeout callback (set by ViewModel)
|
||||||
// Called when 5-minute polling timeout is reached without session starting
|
// Called when 5-minute polling timeout is reached without session starting
|
||||||
private var keygenTimeoutCallback: ((String) -> Unit)? = null
|
private var keygenTimeoutCallback: ((String) -> Unit)? = null
|
||||||
|
|
@ -150,76 +80,14 @@ class TssRepository @Inject constructor(
|
||||||
// Called when TSS protocol progress updates (round/totalRounds)
|
// Called when TSS protocol progress updates (round/totalRounds)
|
||||||
private var progressCallback: ((Int, Int) -> Unit)? = null
|
private var progressCallback: ((Int, Int) -> Unit)? = null
|
||||||
|
|
||||||
/**
|
// Job for collecting progress from native bridge
|
||||||
* 全局协程异常处理器
|
private var progressCollectionJob: Job? = 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)
|
|
||||||
|
|
||||||
// 根据异常类型进行不同处理
|
// Repository-level CoroutineScope for background tasks
|
||||||
when (exception) {
|
// Uses SupervisorJob so individual task failures don't cancel other tasks
|
||||||
is CancellationException -> {
|
private val repositoryScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||||
// 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
|
|
||||||
)
|
|
||||||
|
|
||||||
companion object {
|
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)
|
// Polling interval for session status check (matching Electron's 2-second interval)
|
||||||
private const val POLL_INTERVAL_MS = 2000L
|
private const val POLL_INTERVAL_MS = 2000L
|
||||||
// Maximum wait time for keygen to start after all parties joined (5 minutes, matching Electron)
|
// 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
|
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
|
// Track current message routing params for reconnection recovery
|
||||||
private var currentMessageRoutingSessionId: String? = null
|
private var currentMessageRoutingSessionId: String? = null
|
||||||
private var currentMessageRoutingPartyIndex: Int? = null
|
private var currentMessageRoutingPartyIndex: Int? = null
|
||||||
|
|
@ -318,34 +147,10 @@ class TssRepository @Inject constructor(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// HTTP client for API calls
|
||||||
* HTTP client for API calls
|
|
||||||
*
|
|
||||||
* 【架构安全修复 - 配置连接池防止资源泄漏】
|
|
||||||
*
|
|
||||||
* OkHttpClient 内部维护资源:
|
|
||||||
* - ConnectionPool: 连接池,复用 HTTP 连接
|
|
||||||
* - Dispatcher: 调度器,管理线程池
|
|
||||||
* - Cache: 可选的响应缓存
|
|
||||||
*
|
|
||||||
* 如果不配置连接池参数和不清理资源,会导致:
|
|
||||||
* 1. 连接池无限增长 → 内存泄漏
|
|
||||||
* 2. 空闲连接永久保持 → 占用系统资源
|
|
||||||
* 3. Dispatcher 线程池未关闭 → 线程泄漏
|
|
||||||
*
|
|
||||||
* 配置策略:
|
|
||||||
* - maxIdleConnections: 5 (最多保留 5 个空闲连接)
|
|
||||||
* - keepAliveDuration: 5 分钟 (空闲连接保活时间)
|
|
||||||
* - 在 cleanup() 中清理所有资源
|
|
||||||
*/
|
|
||||||
private val httpClient = okhttp3.OkHttpClient.Builder()
|
private val httpClient = okhttp3.OkHttpClient.Builder()
|
||||||
.connectTimeout(30, java.util.concurrent.TimeUnit.SECONDS)
|
.connectTimeout(30, java.util.concurrent.TimeUnit.SECONDS)
|
||||||
.readTimeout(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()
|
.build()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -366,45 +171,22 @@ class TssRepository @Inject constructor(
|
||||||
* Disconnect from the server
|
* Disconnect from the server
|
||||||
*/
|
*/
|
||||||
fun disconnect() {
|
fun disconnect() {
|
||||||
// 使用 JobManager 统一取消所有后台任务
|
messageCollectionJob?.cancel()
|
||||||
jobManager.cancelAll()
|
sessionEventJob?.cancel()
|
||||||
|
sessionStatusPollingJob?.cancel()
|
||||||
grpcClient.disconnect()
|
grpcClient.disconnect()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Cleanup all resources when the repository is destroyed.
|
* Cleanup all resources when the repository is destroyed.
|
||||||
* Call this when the app is being destroyed to prevent memory leaks.
|
* 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() {
|
fun cleanup() {
|
||||||
// 使用 JobManager 统一取消所有后台任务
|
messageCollectionJob?.cancel()
|
||||||
jobManager.cancelAll()
|
sessionEventJob?.cancel()
|
||||||
|
sessionStatusPollingJob?.cancel()
|
||||||
repositoryScope.cancel()
|
repositoryScope.cancel()
|
||||||
grpcClient.disconnect()
|
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.
|
* from keygen (shareEntity.partyId) so that session events are received correctly.
|
||||||
*/
|
*/
|
||||||
private fun startSessionEventSubscription(subscriptionPartyId: String? = null) {
|
private fun startSessionEventSubscription(subscriptionPartyId: String? = null) {
|
||||||
val devicePartyId = requirePartyId() // Ensure partyId is initialized
|
sessionEventJob?.cancel()
|
||||||
val effectivePartyId = subscriptionPartyId ?: devicePartyId
|
val effectivePartyId = subscriptionPartyId ?: partyId
|
||||||
// Save for reconnection recovery
|
// Save for reconnection recovery
|
||||||
currentSessionEventPartyId = effectivePartyId
|
currentSessionEventPartyId = effectivePartyId
|
||||||
android.util.Log.d("TssRepository", "Starting session event subscription for partyId: $effectivePartyId (device partyId: $devicePartyId)")
|
android.util.Log.d("TssRepository", "Starting session event subscription for partyId: $effectivePartyId (device partyId: $partyId)")
|
||||||
|
sessionEventJob = repositoryScope.launch {
|
||||||
// 使用 JobManager 启动(自动取消同名旧 Job)
|
|
||||||
jobManager.launch(JOB_SESSION_EVENT) {
|
|
||||||
grpcClient.subscribeSessionEvents(effectivePartyId).collect { event ->
|
grpcClient.subscribeSessionEvents(effectivePartyId).collect { event ->
|
||||||
android.util.Log.d("TssRepository", "=== Session event received ===")
|
android.util.Log.d("TssRepository", "=== Session event received ===")
|
||||||
android.util.Log.d("TssRepository", " eventType: ${event.eventType}")
|
android.util.Log.d("TssRepository", " eventType: ${event.eventType}")
|
||||||
|
|
@ -585,10 +365,9 @@ class TssRepository @Inject constructor(
|
||||||
* partyId from keygen (shareEntity.partyId).
|
* partyId from keygen (shareEntity.partyId).
|
||||||
*/
|
*/
|
||||||
private fun ensureSessionEventSubscriptionActive(signingPartyId: String? = null) {
|
private fun ensureSessionEventSubscriptionActive(signingPartyId: String? = null) {
|
||||||
// Check if the session event job is still active using JobManager
|
// Check if the session event job is still active
|
||||||
val isActive = jobManager.isActive(JOB_SESSION_EVENT)
|
val isActive = sessionEventJob?.isActive == true
|
||||||
val devicePartyId = requirePartyId() // Ensure partyId is initialized
|
val effectivePartyId = signingPartyId ?: currentSessionEventPartyId ?: partyId
|
||||||
val effectivePartyId = signingPartyId ?: currentSessionEventPartyId ?: devicePartyId
|
|
||||||
android.util.Log.d("TssRepository", "Checking session event subscription: isActive=$isActive, effectivePartyId=$effectivePartyId")
|
android.util.Log.d("TssRepository", "Checking session event subscription: isActive=$isActive, effectivePartyId=$effectivePartyId")
|
||||||
|
|
||||||
if (!isActive) {
|
if (!isActive) {
|
||||||
|
|
@ -653,10 +432,12 @@ class TssRepository @Inject constructor(
|
||||||
* Called when keygen/sign session starts
|
* Called when keygen/sign session starts
|
||||||
*/
|
*/
|
||||||
private fun startProgressCollection() {
|
private fun startProgressCollection() {
|
||||||
|
// Cancel any existing progress collection
|
||||||
|
progressCollectionJob?.cancel()
|
||||||
|
|
||||||
android.util.Log.d("TssRepository", "[PROGRESS] Starting progress collection from native bridge")
|
android.util.Log.d("TssRepository", "[PROGRESS] Starting progress collection from native bridge")
|
||||||
|
|
||||||
// 使用 JobManager 启动(自动取消同名旧 Job)
|
progressCollectionJob = repositoryScope.launch {
|
||||||
jobManager.launch(JOB_PROGRESS_COLLECTION) {
|
|
||||||
tssNativeBridge.progress.collect { (round, totalRounds) ->
|
tssNativeBridge.progress.collect { (round, totalRounds) ->
|
||||||
android.util.Log.d("TssRepository", "[PROGRESS] Round $round / $totalRounds")
|
android.util.Log.d("TssRepository", "[PROGRESS] Round $round / $totalRounds")
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
|
|
@ -671,7 +452,8 @@ class TssRepository @Inject constructor(
|
||||||
* Called when session ends or is cancelled
|
* Called when session ends or is cancelled
|
||||||
*/
|
*/
|
||||||
private fun stopProgressCollection() {
|
private fun stopProgressCollection() {
|
||||||
jobManager.cancel(JOB_PROGRESS_COLLECTION)
|
progressCollectionJob?.cancel()
|
||||||
|
progressCollectionJob = null
|
||||||
android.util.Log.d("TssRepository", "[PROGRESS] Progress collection stopped")
|
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
|
* @param sessionType "keygen" or "sign" - determines which callback to invoke
|
||||||
*/
|
*/
|
||||||
fun startSessionStatusPolling(sessionId: String, sessionType: String = "keygen") {
|
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")
|
android.util.Log.d("TssRepository", "[POLLING] Starting session status polling for $sessionType session: $sessionId")
|
||||||
|
|
||||||
// Notify UI that countdown has started (5 minutes = 300 seconds)
|
// Notify UI that countdown has started (5 minutes = 300 seconds)
|
||||||
countdownTickCallback?.invoke(MAX_WAIT_MS / 1000)
|
countdownTickCallback?.invoke(MAX_WAIT_MS / 1000)
|
||||||
|
|
||||||
// 使用 JobManager 启动(自动取消同名旧 Job)
|
sessionStatusPollingJob = repositoryScope.launch {
|
||||||
jobManager.launch(JOB_SESSION_STATUS_POLLING) {
|
|
||||||
val startTime = System.currentTimeMillis()
|
val startTime = System.currentTimeMillis()
|
||||||
var pollCount = 0
|
var pollCount = 0
|
||||||
var lastTickSecond = MAX_WAIT_MS / 1000
|
var lastTickSecond = MAX_WAIT_MS / 1000
|
||||||
|
|
@ -806,7 +590,8 @@ class TssRepository @Inject constructor(
|
||||||
* - User navigates away
|
* - User navigates away
|
||||||
*/
|
*/
|
||||||
fun stopSessionStatusPolling() {
|
fun stopSessionStatusPolling() {
|
||||||
jobManager.cancel(JOB_SESSION_STATUS_POLLING)
|
sessionStatusPollingJob?.cancel()
|
||||||
|
sessionStatusPollingJob = null
|
||||||
// Clear countdown display when polling is stopped
|
// Clear countdown display when polling is stopped
|
||||||
countdownTickCallback?.invoke(-1L)
|
countdownTickCallback?.invoke(-1L)
|
||||||
android.util.Log.d("TssRepository", "[POLLING] Session status polling stopped")
|
android.util.Log.d("TssRepository", "[POLLING] Session status polling stopped")
|
||||||
|
|
@ -1344,32 +1129,8 @@ class TssRepository @Inject constructor(
|
||||||
|
|
||||||
_sessionStatus.value = SessionStatus.IN_PROGRESS
|
_sessionStatus.value = SessionStatus.IN_PROGRESS
|
||||||
|
|
||||||
// Mark ready - with retry on optimistic lock conflict
|
// Mark ready
|
||||||
var markReadySuccess = false
|
grpcClient.markPartyReady(sessionId, partyId)
|
||||||
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"))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wait for keygen result
|
// Wait for keygen result
|
||||||
val keygenResult = tssNativeBridge.waitForKeygenResult(password)
|
val keygenResult = tssNativeBridge.waitForKeygenResult(password)
|
||||||
|
|
@ -1619,7 +1380,7 @@ class TssRepository @Inject constructor(
|
||||||
stopProgressCollection()
|
stopProgressCollection()
|
||||||
_sessionStatus.value = SessionStatus.COMPLETED
|
_sessionStatus.value = SessionStatus.COMPLETED
|
||||||
pendingSessionId = null // Clear pending session ID on completion
|
pendingSessionId = null // Clear pending session ID on completion
|
||||||
jobManager.cancel(JOB_MESSAGE_COLLECTION)
|
messageCollectionJob?.cancel()
|
||||||
currentSigningPartyId = null // Clear after signing completes
|
currentSigningPartyId = null // Clear after signing completes
|
||||||
|
|
||||||
android.util.Log.d("TssRepository", "Sign as joiner completed: signature=${result.signature.take(20)}...")
|
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)
|
grpcClient.reportCompletion(apiJoinData.sessionId, partyId, publicKeyBytes)
|
||||||
|
|
||||||
_sessionStatus.value = SessionStatus.COMPLETED
|
_sessionStatus.value = SessionStatus.COMPLETED
|
||||||
jobManager.cancel(JOB_MESSAGE_COLLECTION)
|
messageCollectionJob?.cancel()
|
||||||
|
|
||||||
Result.success(shareEntity.copy(id = id).toShareRecord())
|
Result.success(shareEntity.copy(id = id).toShareRecord())
|
||||||
|
|
||||||
|
|
@ -1926,7 +1687,7 @@ class TssRepository @Inject constructor(
|
||||||
grpcClient.reportCompletion(apiJoinData.sessionId, signingPartyId, signature = signatureBytes)
|
grpcClient.reportCompletion(apiJoinData.sessionId, signingPartyId, signature = signatureBytes)
|
||||||
|
|
||||||
_sessionStatus.value = SessionStatus.COMPLETED
|
_sessionStatus.value = SessionStatus.COMPLETED
|
||||||
jobManager.cancel(JOB_MESSAGE_COLLECTION)
|
messageCollectionJob?.cancel()
|
||||||
currentSigningPartyId = null // Clear after signing completes
|
currentSigningPartyId = null // Clear after signing completes
|
||||||
|
|
||||||
Result.success(result)
|
Result.success(result)
|
||||||
|
|
@ -2033,12 +1794,12 @@ class TssRepository @Inject constructor(
|
||||||
currentMessageRoutingPartyIndex = partyIndex
|
currentMessageRoutingPartyIndex = partyIndex
|
||||||
|
|
||||||
// Use provided routingPartyId, or fall back to device partyId for keygen
|
// Use provided routingPartyId, or fall back to device partyId for keygen
|
||||||
val devicePartyId = requirePartyId() // Ensure partyId is initialized
|
val effectivePartyId = routingPartyId ?: partyId
|
||||||
val effectivePartyId = routingPartyId ?: devicePartyId
|
|
||||||
// Save for reconnection recovery
|
// Save for reconnection recovery
|
||||||
currentMessageRoutingPartyId = effectivePartyId
|
currentMessageRoutingPartyId = effectivePartyId
|
||||||
|
|
||||||
jobManager.launch(JOB_MESSAGE_COLLECTION) {
|
messageCollectionJob?.cancel()
|
||||||
|
messageCollectionJob = repositoryScope.launch {
|
||||||
android.util.Log.d("TssRepository", "Starting message routing: sessionId=$sessionId, routingPartyId=$effectivePartyId")
|
android.util.Log.d("TssRepository", "Starting message routing: sessionId=$sessionId, routingPartyId=$effectivePartyId")
|
||||||
|
|
||||||
// Collect outgoing messages from TSS and route via gRPC
|
// Collect outgoing messages from TSS and route via gRPC
|
||||||
|
|
@ -2158,34 +1919,8 @@ class TssRepository @Inject constructor(
|
||||||
|
|
||||||
_sessionStatus.value = SessionStatus.IN_PROGRESS
|
_sessionStatus.value = SessionStatus.IN_PROGRESS
|
||||||
|
|
||||||
// Mark ready - with retry on optimistic lock conflict
|
// Mark ready
|
||||||
var markReadySuccess = false
|
grpcClient.markPartyReady(sessionId, partyId)
|
||||||
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"))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wait for keygen result
|
// Wait for keygen result
|
||||||
val keygenResult = tssNativeBridge.waitForKeygenResult(password)
|
val keygenResult = tssNativeBridge.waitForKeygenResult(password)
|
||||||
|
|
@ -2221,7 +1956,7 @@ class TssRepository @Inject constructor(
|
||||||
stopProgressCollection()
|
stopProgressCollection()
|
||||||
_sessionStatus.value = SessionStatus.COMPLETED
|
_sessionStatus.value = SessionStatus.COMPLETED
|
||||||
pendingSessionId = null // Clear pending session ID on completion
|
pendingSessionId = null // Clear pending session ID on completion
|
||||||
jobManager.cancel(JOB_SESSION_EVENT)
|
sessionEventJob?.cancel()
|
||||||
|
|
||||||
Result.success(shareEntity.copy(id = id).toShareRecord())
|
Result.success(shareEntity.copy(id = id).toShareRecord())
|
||||||
|
|
||||||
|
|
@ -2239,8 +1974,8 @@ class TssRepository @Inject constructor(
|
||||||
*/
|
*/
|
||||||
fun cancelSession() {
|
fun cancelSession() {
|
||||||
tssNativeBridge.cancelSession()
|
tssNativeBridge.cancelSession()
|
||||||
jobManager.cancel(JOB_MESSAGE_COLLECTION)
|
messageCollectionJob?.cancel()
|
||||||
jobManager.cancel(JOB_SESSION_EVENT)
|
sessionEventJob?.cancel()
|
||||||
stopSessionStatusPolling() // Stop polling when session is cancelled
|
stopSessionStatusPolling() // Stop polling when session is cancelled
|
||||||
_currentSession.value = null
|
_currentSession.value = null
|
||||||
_sessionStatus.value = SessionStatus.WAITING
|
_sessionStatus.value = SessionStatus.WAITING
|
||||||
|
|
@ -2903,7 +2638,7 @@ class TssRepository @Inject constructor(
|
||||||
|
|
||||||
// Note: Message routing is already started in createSignSession after auto-join
|
// Note: Message routing is already started in createSignSession after auto-join
|
||||||
// Only start if not already running (for backward compatibility with old flow)
|
// 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
|
// CRITICAL: Use signingPartyId for message routing
|
||||||
startMessageRouting(sessionId, shareEntity.partyIndex, signingPartyId)
|
startMessageRouting(sessionId, shareEntity.partyIndex, signingPartyId)
|
||||||
}
|
}
|
||||||
|
|
@ -2945,7 +2680,7 @@ class TssRepository @Inject constructor(
|
||||||
|
|
||||||
stopProgressCollection()
|
stopProgressCollection()
|
||||||
_sessionStatus.value = SessionStatus.COMPLETED
|
_sessionStatus.value = SessionStatus.COMPLETED
|
||||||
jobManager.cancel(JOB_MESSAGE_COLLECTION)
|
messageCollectionJob?.cancel()
|
||||||
currentSigningPartyId = null // Clear after signing completes
|
currentSigningPartyId = null // Clear after signing completes
|
||||||
|
|
||||||
Result.success(result)
|
Result.success(result)
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,9 @@ import com.durian.tssparty.domain.model.*
|
||||||
import com.durian.tssparty.util.AddressUtils
|
import com.durian.tssparty.util.AddressUtils
|
||||||
import com.durian.tssparty.util.TransactionUtils
|
import com.durian.tssparty.util.TransactionUtils
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import kotlinx.coroutines.*
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.*
|
import kotlinx.coroutines.flow.*
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
|
|
@ -17,59 +18,6 @@ class MainViewModel @Inject constructor(
|
||||||
private val repository: TssRepository
|
private val repository: TssRepository
|
||||||
) : ViewModel() {
|
) : 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)
|
// App State (similar to Zustand store)
|
||||||
private val _appState = MutableStateFlow(AppState())
|
private val _appState = MutableStateFlow(AppState())
|
||||||
val appState: StateFlow<AppState> = _appState.asStateFlow()
|
val appState: StateFlow<AppState> = _appState.asStateFlow()
|
||||||
|
|
@ -109,12 +57,7 @@ class MainViewModel @Inject constructor(
|
||||||
* Check all services for startup
|
* Check all services for startup
|
||||||
*/
|
*/
|
||||||
fun checkAllServices() {
|
fun checkAllServices() {
|
||||||
safeLaunch(
|
viewModelScope.launch {
|
||||||
onError = { e ->
|
|
||||||
_appState.update { it.copy(appReady = AppReadyState.ERROR) }
|
|
||||||
android.util.Log.e("MainViewModel", "Service check failed", e)
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
_appState.update { it.copy(appReady = AppReadyState.INITIALIZING) }
|
_appState.update { it.copy(appReady = AppReadyState.INITIALIZING) }
|
||||||
|
|
||||||
var hasError = false
|
var hasError = false
|
||||||
|
|
@ -235,7 +178,7 @@ class MainViewModel @Inject constructor(
|
||||||
* Connect to Message Router server
|
* Connect to Message Router server
|
||||||
*/
|
*/
|
||||||
fun connectToServer(serverUrl: String) {
|
fun connectToServer(serverUrl: String) {
|
||||||
safeLaunch {
|
viewModelScope.launch {
|
||||||
try {
|
try {
|
||||||
val parts = serverUrl.split(":")
|
val parts = serverUrl.split(":")
|
||||||
val host = parts[0]
|
val host = parts[0]
|
||||||
|
|
@ -283,7 +226,7 @@ class MainViewModel @Inject constructor(
|
||||||
* Matches Electron behavior: creates session via API, then auto-joins via gRPC
|
* Matches Electron behavior: creates session via API, then auto-joins via gRPC
|
||||||
*/
|
*/
|
||||||
fun createKeygenSession(walletName: String, thresholdT: Int, thresholdN: Int, participantName: String) {
|
fun createKeygenSession(walletName: String, thresholdT: Int, thresholdN: Int, participantName: String) {
|
||||||
safeLaunch {
|
viewModelScope.launch {
|
||||||
_uiState.update { it.copy(isLoading = true, error = null) }
|
_uiState.update { it.copy(isLoading = true, error = null) }
|
||||||
|
|
||||||
val result = repository.createKeygenSession(walletName, thresholdT, thresholdN, participantName)
|
val result = repository.createKeygenSession(walletName, thresholdT, thresholdN, participantName)
|
||||||
|
|
@ -395,7 +338,7 @@ class MainViewModel @Inject constructor(
|
||||||
val currentSessionId = _currentSessionId.value
|
val currentSessionId = _currentSessionId.value
|
||||||
if (currentSessionId != null && event.sessionId == currentSessionId) {
|
if (currentSessionId != null && event.sessionId == currentSessionId) {
|
||||||
android.util.Log.d("MainViewModel", "Session started event for keygen initiator, triggering keygen")
|
android.util.Log.d("MainViewModel", "Session started event for keygen initiator, triggering keygen")
|
||||||
safeLaunch {
|
viewModelScope.launch {
|
||||||
startKeygenAsInitiator(
|
startKeygenAsInitiator(
|
||||||
sessionId = currentSessionId,
|
sessionId = currentSessionId,
|
||||||
thresholdT = event.thresholdT,
|
thresholdT = event.thresholdT,
|
||||||
|
|
@ -430,55 +373,51 @@ class MainViewModel @Inject constructor(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
"party_joined", "participant_joined" -> {
|
"party_joined", "participant_joined" -> {
|
||||||
/**
|
android.util.Log.d("MainViewModel", "Processing participant_joined event...")
|
||||||
* 【架构安全修复 - 防止参与者计数竞态条件】
|
|
||||||
*
|
|
||||||
* 原问题:使用 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")
|
|
||||||
|
|
||||||
// Update participant count for initiator's CreateWallet screen
|
// Update participant count for initiator's CreateWallet screen
|
||||||
val currentSessionId = _currentSessionId.value
|
val currentSessionId = _currentSessionId.value
|
||||||
android.util.Log.d("MainViewModel", " Checking for initiator: currentSessionId=$currentSessionId, eventSessionId=${event.sessionId}")
|
android.util.Log.d("MainViewModel", " Checking for initiator: currentSessionId=$currentSessionId, eventSessionId=${event.sessionId}")
|
||||||
if (currentSessionId != null && event.sessionId == currentSessionId) {
|
if (currentSessionId != null && event.sessionId == currentSessionId) {
|
||||||
android.util.Log.d("MainViewModel", " → Matched initiator session! Updating _sessionParticipants to: $participantList")
|
android.util.Log.d("MainViewModel", " → Matched initiator session! Updating _sessionParticipants")
|
||||||
_sessionParticipants.value = participantList
|
_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
|
// Update participant count for keygen joiner's JoinKeygen screen
|
||||||
val joinKeygenInfo = pendingJoinKeygenInfo
|
val joinKeygenInfo = pendingJoinKeygenInfo
|
||||||
android.util.Log.d("MainViewModel", " Checking for joiner: joinKeygenInfo?.sessionId=${joinKeygenInfo?.sessionId}")
|
android.util.Log.d("MainViewModel", " Checking for joiner: joinKeygenInfo?.sessionId=${joinKeygenInfo?.sessionId}")
|
||||||
if (joinKeygenInfo != null && event.sessionId == joinKeygenInfo.sessionId) {
|
if (joinKeygenInfo != null && event.sessionId == joinKeygenInfo.sessionId) {
|
||||||
android.util.Log.d("MainViewModel", " → Matched joiner session! Updating _joinKeygenParticipants to: $participantList")
|
android.util.Log.d("MainViewModel", " → Matched joiner session! Updating _joinKeygenParticipants")
|
||||||
_joinKeygenParticipants.value = participantList
|
_joinKeygenParticipants.update { current ->
|
||||||
|
val newParticipant = "参与方 ${current.size + 1}"
|
||||||
|
current + newParticipant
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update participant count for sign joiner's CoSign screen
|
// Update participant count for sign joiner's CoSign screen
|
||||||
val joinSignInfo = pendingJoinSignInfo
|
val joinSignInfo = pendingJoinSignInfo
|
||||||
if (joinSignInfo != null && event.sessionId == joinSignInfo.sessionId) {
|
if (joinSignInfo != null && event.sessionId == joinSignInfo.sessionId) {
|
||||||
android.util.Log.d("MainViewModel", " → Matched sign joiner session! Updating _coSignParticipants to: $participantList")
|
android.util.Log.d("MainViewModel", " → Matched sign joiner session! Updating _coSignParticipants")
|
||||||
_coSignParticipants.value = participantList
|
_coSignParticipants.update { current ->
|
||||||
|
val newParticipant = "参与方 ${current.size + 1}"
|
||||||
|
current + newParticipant
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update participant count for sign initiator's TransferScreen (SigningScreen)
|
// Update participant count for sign initiator's TransferScreen (SigningScreen)
|
||||||
val signSessionId = _signSessionId.value
|
val signSessionId = _signSessionId.value
|
||||||
android.util.Log.d("MainViewModel", " Checking for sign initiator: signSessionId=$signSessionId, eventSessionId=${event.sessionId}")
|
android.util.Log.d("MainViewModel", " Checking for sign initiator: signSessionId=$signSessionId, eventSessionId=${event.sessionId}")
|
||||||
if (signSessionId != null && event.sessionId == signSessionId) {
|
if (signSessionId != null && event.sessionId == signSessionId) {
|
||||||
android.util.Log.d("MainViewModel", " → Matched sign initiator session! Updating _signParticipants to: $participantList")
|
android.util.Log.d("MainViewModel", " → Matched sign initiator session! Updating _signParticipants")
|
||||||
_signParticipants.value = participantList
|
_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" -> {
|
"all_joined" -> {
|
||||||
|
|
@ -607,7 +546,7 @@ class MainViewModel @Inject constructor(
|
||||||
* Matches Electron's grpc:validateInviteCode - returns sessionInfo + joinToken
|
* Matches Electron's grpc:validateInviteCode - returns sessionInfo + joinToken
|
||||||
*/
|
*/
|
||||||
fun validateInviteCode(inviteCode: String) {
|
fun validateInviteCode(inviteCode: String) {
|
||||||
safeLaunch {
|
viewModelScope.launch {
|
||||||
_uiState.update { it.copy(isLoading = true, error = null) }
|
_uiState.update { it.copy(isLoading = true, error = null) }
|
||||||
pendingInviteCode = inviteCode
|
pendingInviteCode = inviteCode
|
||||||
|
|
||||||
|
|
@ -647,16 +586,16 @@ class MainViewModel @Inject constructor(
|
||||||
* 3. Waits for session_started event to trigger keygen
|
* 3. Waits for session_started event to trigger keygen
|
||||||
*/
|
*/
|
||||||
fun joinKeygen(inviteCode: String, password: String) {
|
fun joinKeygen(inviteCode: String, password: String) {
|
||||||
safeLaunch {
|
viewModelScope.launch {
|
||||||
val sessionInfo = _joinSessionInfo.value
|
val sessionInfo = _joinSessionInfo.value
|
||||||
if (sessionInfo == null) {
|
if (sessionInfo == null) {
|
||||||
_uiState.update { it.copy(error = "会话信息不完整") }
|
_uiState.update { it.copy(error = "会话信息不完整") }
|
||||||
return@safeLaunch
|
return@launch
|
||||||
}
|
}
|
||||||
|
|
||||||
if (pendingJoinToken.isEmpty()) {
|
if (pendingJoinToken.isEmpty()) {
|
||||||
_uiState.update { it.copy(error = "未获取到加入令牌,请重新验证邀请码") }
|
_uiState.update { it.copy(error = "未获取到加入令牌,请重新验证邀请码") }
|
||||||
return@safeLaunch
|
return@launch
|
||||||
}
|
}
|
||||||
|
|
||||||
_uiState.update { it.copy(isLoading = true, error = null) }
|
_uiState.update { it.copy(isLoading = true, error = null) }
|
||||||
|
|
@ -717,7 +656,7 @@ class MainViewModel @Inject constructor(
|
||||||
private fun startKeygenAsJoiner() {
|
private fun startKeygenAsJoiner() {
|
||||||
val joinInfo = pendingJoinKeygenInfo ?: return
|
val joinInfo = pendingJoinKeygenInfo ?: return
|
||||||
|
|
||||||
safeLaunch {
|
viewModelScope.launch {
|
||||||
_uiState.update { it.copy(isLoading = true, error = null) }
|
_uiState.update { it.copy(isLoading = true, error = null) }
|
||||||
|
|
||||||
android.util.Log.d("MainViewModel", "Starting keygen as joiner: sessionId=${joinInfo.sessionId}, partyIndex=${joinInfo.partyIndex}")
|
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
|
* Matches Electron's cosign:validateInviteCode - returns sessionInfo + joinToken + parties
|
||||||
*/
|
*/
|
||||||
fun validateSignInviteCode(inviteCode: String) {
|
fun validateSignInviteCode(inviteCode: String) {
|
||||||
safeLaunch {
|
viewModelScope.launch {
|
||||||
_uiState.update { it.copy(isLoading = true, error = null) }
|
_uiState.update { it.copy(isLoading = true, error = null) }
|
||||||
pendingCoSignInviteCode = inviteCode
|
pendingCoSignInviteCode = inviteCode
|
||||||
|
|
||||||
|
|
@ -837,16 +776,16 @@ class MainViewModel @Inject constructor(
|
||||||
* 4. Otherwise waits for session_started event to trigger sign
|
* 4. Otherwise waits for session_started event to trigger sign
|
||||||
*/
|
*/
|
||||||
fun joinSign(inviteCode: String, shareId: Long, password: String) {
|
fun joinSign(inviteCode: String, shareId: Long, password: String) {
|
||||||
safeLaunch {
|
viewModelScope.launch {
|
||||||
val sessionInfo = _coSignSessionInfo.value
|
val sessionInfo = _coSignSessionInfo.value
|
||||||
if (sessionInfo == null) {
|
if (sessionInfo == null) {
|
||||||
_uiState.update { it.copy(error = "会话信息不完整") }
|
_uiState.update { it.copy(error = "会话信息不完整") }
|
||||||
return@safeLaunch
|
return@launch
|
||||||
}
|
}
|
||||||
|
|
||||||
if (pendingCoSignJoinToken.isEmpty()) {
|
if (pendingCoSignJoinToken.isEmpty()) {
|
||||||
_uiState.update { it.copy(error = "未获取到加入令牌,请重新验证邀请码") }
|
_uiState.update { it.copy(error = "未获取到加入令牌,请重新验证邀请码") }
|
||||||
return@safeLaunch
|
return@launch
|
||||||
}
|
}
|
||||||
|
|
||||||
_uiState.update { it.copy(isLoading = true, error = null) }
|
_uiState.update { it.copy(isLoading = true, error = null) }
|
||||||
|
|
@ -905,7 +844,7 @@ class MainViewModel @Inject constructor(
|
||||||
private fun startSignAsJoiner() {
|
private fun startSignAsJoiner() {
|
||||||
val signInfo = pendingJoinSignInfo ?: return
|
val signInfo = pendingJoinSignInfo ?: return
|
||||||
|
|
||||||
safeLaunch {
|
viewModelScope.launch {
|
||||||
_uiState.update { it.copy(isLoading = true, error = null) }
|
_uiState.update { it.copy(isLoading = true, error = null) }
|
||||||
|
|
||||||
android.util.Log.d("MainViewModel", "Starting sign as joiner: sessionId=${signInfo.sessionId}, partyIndex=${signInfo.partyIndex}")
|
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
|
* Delete a share
|
||||||
*/
|
*/
|
||||||
fun deleteShare(id: Long) {
|
fun deleteShare(id: Long) {
|
||||||
safeLaunch {
|
viewModelScope.launch {
|
||||||
repository.deleteShare(id)
|
repository.deleteShare(id)
|
||||||
// Update wallet count
|
// Update wallet count
|
||||||
_appState.update { state ->
|
_appState.update { state ->
|
||||||
|
|
@ -997,7 +936,7 @@ class MainViewModel @Inject constructor(
|
||||||
* 加载钱包的交易记录
|
* 加载钱包的交易记录
|
||||||
*/
|
*/
|
||||||
fun loadTransactionRecords(shareId: Long) {
|
fun loadTransactionRecords(shareId: Long) {
|
||||||
safeLaunch {
|
viewModelScope.launch {
|
||||||
repository.getTransactionRecords(shareId).collect { records ->
|
repository.getTransactionRecords(shareId).collect { records ->
|
||||||
_transactionRecords.value = records
|
_transactionRecords.value = records
|
||||||
}
|
}
|
||||||
|
|
@ -1009,7 +948,7 @@ class MainViewModel @Inject constructor(
|
||||||
* 首次导入钱包时调用
|
* 首次导入钱包时调用
|
||||||
*/
|
*/
|
||||||
fun syncTransactionHistory(shareId: Long, address: String) {
|
fun syncTransactionHistory(shareId: Long, address: String) {
|
||||||
safeLaunch {
|
viewModelScope.launch {
|
||||||
_isSyncingHistory.value = true
|
_isSyncingHistory.value = true
|
||||||
android.util.Log.d("MainViewModel", "[SYNC] Starting transaction history sync for $address")
|
android.util.Log.d("MainViewModel", "[SYNC] Starting transaction history sync for $address")
|
||||||
|
|
||||||
|
|
@ -1040,7 +979,7 @@ class MainViewModel @Inject constructor(
|
||||||
* 应用启动时调用
|
* 应用启动时调用
|
||||||
*/
|
*/
|
||||||
fun confirmPendingTransactions() {
|
fun confirmPendingTransactions() {
|
||||||
safeLaunch {
|
viewModelScope.launch {
|
||||||
val rpcUrl = _settings.value.kavaRpcUrl
|
val rpcUrl = _settings.value.kavaRpcUrl
|
||||||
val pendingRecords = repository.getPendingTransactions()
|
val pendingRecords = repository.getPendingTransactions()
|
||||||
android.util.Log.d("MainViewModel", "[TX-CONFIRM] Found ${pendingRecords.size} pending transactions")
|
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) {
|
fun exportShareBackup(shareId: Long, onSuccess: (String) -> Unit) {
|
||||||
android.util.Log.d("MainViewModel", "[EXPORT] ========== exportShareBackup called ==========")
|
android.util.Log.d("MainViewModel", "[EXPORT] ========== exportShareBackup called ==========")
|
||||||
android.util.Log.d("MainViewModel", "[EXPORT] shareId: $shareId")
|
android.util.Log.d("MainViewModel", "[EXPORT] shareId: $shareId")
|
||||||
safeLaunch(
|
viewModelScope.launch {
|
||||||
onError = { e ->
|
|
||||||
_exportResult.value = ExportImportResult(isLoading = false, error = e.message)
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
android.util.Log.d("MainViewModel", "[EXPORT] Setting loading state...")
|
android.util.Log.d("MainViewModel", "[EXPORT] Setting loading state...")
|
||||||
_exportResult.value = ExportImportResult(isLoading = true)
|
_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] ========== importShareBackup called ==========")
|
||||||
android.util.Log.d("MainViewModel", "[IMPORT] JSON length: ${backupJson.length}")
|
android.util.Log.d("MainViewModel", "[IMPORT] JSON length: ${backupJson.length}")
|
||||||
android.util.Log.d("MainViewModel", "[IMPORT] JSON preview: ${backupJson.take(100)}...")
|
android.util.Log.d("MainViewModel", "[IMPORT] JSON preview: ${backupJson.take(100)}...")
|
||||||
safeLaunch(
|
viewModelScope.launch {
|
||||||
onError = { e ->
|
|
||||||
_importResult.value = ExportImportResult(isLoading = false, error = e.message)
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
android.util.Log.d("MainViewModel", "[IMPORT] Setting loading state...")
|
android.util.Log.d("MainViewModel", "[IMPORT] Setting loading state...")
|
||||||
_importResult.value = ExportImportResult(isLoading = true)
|
_importResult.value = ExportImportResult(isLoading = true)
|
||||||
|
|
||||||
|
|
@ -1181,7 +1112,7 @@ class MainViewModel @Inject constructor(
|
||||||
* Test Message Router connection
|
* Test Message Router connection
|
||||||
*/
|
*/
|
||||||
fun testMessageRouter(serverUrl: String) {
|
fun testMessageRouter(serverUrl: String) {
|
||||||
safeLaunch {
|
viewModelScope.launch {
|
||||||
_messageRouterTestResult.value = null
|
_messageRouterTestResult.value = null
|
||||||
val result = repository.testMessageRouter(serverUrl)
|
val result = repository.testMessageRouter(serverUrl)
|
||||||
result.fold(
|
result.fold(
|
||||||
|
|
@ -1206,7 +1137,7 @@ class MainViewModel @Inject constructor(
|
||||||
* Test Account Service connection
|
* Test Account Service connection
|
||||||
*/
|
*/
|
||||||
fun testAccountService(serviceUrl: String) {
|
fun testAccountService(serviceUrl: String) {
|
||||||
safeLaunch {
|
viewModelScope.launch {
|
||||||
_accountServiceTestResult.value = null
|
_accountServiceTestResult.value = null
|
||||||
val result = repository.testAccountService(serviceUrl)
|
val result = repository.testAccountService(serviceUrl)
|
||||||
result.fold(
|
result.fold(
|
||||||
|
|
@ -1231,7 +1162,7 @@ class MainViewModel @Inject constructor(
|
||||||
* Test Kava API connection
|
* Test Kava API connection
|
||||||
*/
|
*/
|
||||||
fun testKavaApi(rpcUrl: String) {
|
fun testKavaApi(rpcUrl: String) {
|
||||||
safeLaunch {
|
viewModelScope.launch {
|
||||||
_kavaApiTestResult.value = null
|
_kavaApiTestResult.value = null
|
||||||
val result = repository.testKavaApi(rpcUrl)
|
val result = repository.testKavaApi(rpcUrl)
|
||||||
result.fold(
|
result.fold(
|
||||||
|
|
@ -1286,7 +1217,7 @@ class MainViewModel @Inject constructor(
|
||||||
* Now fetches both KAVA and Green Points (绿积分) balances
|
* Now fetches both KAVA and Green Points (绿积分) balances
|
||||||
*/
|
*/
|
||||||
fun fetchBalanceForShare(share: ShareRecord) {
|
fun fetchBalanceForShare(share: ShareRecord) {
|
||||||
safeLaunch {
|
viewModelScope.launch {
|
||||||
val rpcUrl = _settings.value.kavaRpcUrl
|
val rpcUrl = _settings.value.kavaRpcUrl
|
||||||
// Ensure we use EVM address format for RPC calls
|
// Ensure we use EVM address format for RPC calls
|
||||||
val evmAddress = AddressUtils.getEvmAddress(share.address, share.publicKey)
|
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)
|
* Fetch balance for a wallet address (for already-EVM addresses)
|
||||||
*/
|
*/
|
||||||
fun fetchBalance(address: String) {
|
fun fetchBalance(address: String) {
|
||||||
safeLaunch {
|
viewModelScope.launch {
|
||||||
val rpcUrl = _settings.value.kavaRpcUrl
|
val rpcUrl = _settings.value.kavaRpcUrl
|
||||||
val result = repository.getWalletBalance(address, rpcUrl)
|
val result = repository.getWalletBalance(address, rpcUrl)
|
||||||
result.onSuccess { walletBalance ->
|
result.onSuccess { walletBalance ->
|
||||||
|
|
@ -1320,7 +1251,7 @@ class MainViewModel @Inject constructor(
|
||||||
* Fetch balances for all wallets
|
* Fetch balances for all wallets
|
||||||
*/
|
*/
|
||||||
fun fetchAllBalances() {
|
fun fetchAllBalances() {
|
||||||
safeLaunch {
|
viewModelScope.launch {
|
||||||
shares.value.forEach { share ->
|
shares.value.forEach { share ->
|
||||||
fetchBalanceForShare(share)
|
fetchBalanceForShare(share)
|
||||||
}
|
}
|
||||||
|
|
@ -1360,14 +1291,14 @@ class MainViewModel @Inject constructor(
|
||||||
* Prepare a transfer transaction
|
* Prepare a transfer transaction
|
||||||
*/
|
*/
|
||||||
fun prepareTransfer(shareId: Long, toAddress: String, amount: String, tokenType: TokenType = TokenType.KAVA) {
|
fun prepareTransfer(shareId: Long, toAddress: String, amount: String, tokenType: TokenType = TokenType.KAVA) {
|
||||||
safeLaunch {
|
viewModelScope.launch {
|
||||||
_uiState.update { it.copy(isLoading = true, error = null) }
|
_uiState.update { it.copy(isLoading = true, error = null) }
|
||||||
_transferState.update { it.copy(shareId = shareId, toAddress = toAddress, amount = amount, tokenType = tokenType) }
|
_transferState.update { it.copy(shareId = shareId, toAddress = toAddress, amount = amount, tokenType = tokenType) }
|
||||||
|
|
||||||
val share = repository.getShareById(shareId)
|
val share = repository.getShareById(shareId)
|
||||||
if (share == null) {
|
if (share == null) {
|
||||||
_uiState.update { it.copy(isLoading = false, error = "钱包不存在") }
|
_uiState.update { it.copy(isLoading = false, error = "钱包不存在") }
|
||||||
return@safeLaunch
|
return@launch
|
||||||
}
|
}
|
||||||
|
|
||||||
val rpcUrl = _settings.value.kavaRpcUrl
|
val rpcUrl = _settings.value.kavaRpcUrl
|
||||||
|
|
@ -1407,13 +1338,13 @@ class MainViewModel @Inject constructor(
|
||||||
* Signing is triggered when session_started event is received (via startSignAsInitiator)
|
* Signing is triggered when session_started event is received (via startSignAsInitiator)
|
||||||
*/
|
*/
|
||||||
fun initiateSignSession(shareId: Long, password: String, initiatorName: String = "发起者") {
|
fun initiateSignSession(shareId: Long, password: String, initiatorName: String = "发起者") {
|
||||||
safeLaunch {
|
viewModelScope.launch {
|
||||||
_uiState.update { it.copy(isLoading = true, error = null) }
|
_uiState.update { it.copy(isLoading = true, error = null) }
|
||||||
|
|
||||||
val tx = _preparedTx.value
|
val tx = _preparedTx.value
|
||||||
if (tx == null) {
|
if (tx == null) {
|
||||||
_uiState.update { it.copy(isLoading = false, error = "交易未准备") }
|
_uiState.update { it.copy(isLoading = false, error = "交易未准备") }
|
||||||
return@safeLaunch
|
return@launch
|
||||||
}
|
}
|
||||||
|
|
||||||
val result = repository.createSignSession(
|
val result = repository.createSignSession(
|
||||||
|
|
@ -1487,13 +1418,13 @@ class MainViewModel @Inject constructor(
|
||||||
initiatorName: String = "发起者",
|
initiatorName: String = "发起者",
|
||||||
includeServerBackup: Boolean = false // 新增参数
|
includeServerBackup: Boolean = false // 新增参数
|
||||||
) {
|
) {
|
||||||
safeLaunch {
|
viewModelScope.launch {
|
||||||
_uiState.update { it.copy(isLoading = true, error = null) }
|
_uiState.update { it.copy(isLoading = true, error = null) }
|
||||||
|
|
||||||
val tx = _preparedTx.value
|
val tx = _preparedTx.value
|
||||||
if (tx == null) {
|
if (tx == null) {
|
||||||
_uiState.update { it.copy(isLoading = false, error = "交易未准备") }
|
_uiState.update { it.copy(isLoading = false, error = "交易未准备") }
|
||||||
return@safeLaunch
|
return@launch
|
||||||
}
|
}
|
||||||
|
|
||||||
android.util.Log.d("MainViewModel", "[SIGN-OPTIONS] Initiating sign session with includeServerBackup=$includeServerBackup")
|
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) {
|
private fun startSigningProcess(sessionId: String, shareId: Long, password: String) {
|
||||||
android.util.Log.d("MainViewModel", "[SIGN] startSigningProcess called: sessionId=$sessionId, shareId=$shareId")
|
android.util.Log.d("MainViewModel", "[SIGN] startSigningProcess called: sessionId=$sessionId, shareId=$shareId")
|
||||||
safeLaunch {
|
viewModelScope.launch {
|
||||||
android.util.Log.d("MainViewModel", "[SIGN] Calling repository.startSigning...")
|
android.util.Log.d("MainViewModel", "[SIGN] Calling repository.startSigning...")
|
||||||
val startResult = repository.startSigning(sessionId, shareId, password)
|
val startResult = repository.startSigning(sessionId, shareId, password)
|
||||||
android.util.Log.d("MainViewModel", "[SIGN] repository.startSigning returned: isSuccess=${startResult.isSuccess}")
|
android.util.Log.d("MainViewModel", "[SIGN] repository.startSigning returned: isSuccess=${startResult.isSuccess}")
|
||||||
|
|
@ -1575,7 +1506,7 @@ class MainViewModel @Inject constructor(
|
||||||
if (startResult.isFailure) {
|
if (startResult.isFailure) {
|
||||||
android.util.Log.e("MainViewModel", "[SIGN] startSigning FAILED: ${startResult.exceptionOrNull()?.message}")
|
android.util.Log.e("MainViewModel", "[SIGN] startSigning FAILED: ${startResult.exceptionOrNull()?.message}")
|
||||||
_uiState.update { it.copy(error = startResult.exceptionOrNull()?.message) }
|
_uiState.update { it.copy(error = startResult.exceptionOrNull()?.message) }
|
||||||
return@safeLaunch
|
return@launch
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wait for signature
|
// Wait for signature
|
||||||
|
|
@ -1603,7 +1534,7 @@ class MainViewModel @Inject constructor(
|
||||||
*/
|
*/
|
||||||
fun broadcastTransaction() {
|
fun broadcastTransaction() {
|
||||||
android.util.Log.d("MainViewModel", "[BROADCAST] broadcastTransaction() called")
|
android.util.Log.d("MainViewModel", "[BROADCAST] broadcastTransaction() called")
|
||||||
safeLaunch {
|
viewModelScope.launch {
|
||||||
android.util.Log.d("MainViewModel", "[BROADCAST] Starting broadcast...")
|
android.util.Log.d("MainViewModel", "[BROADCAST] Starting broadcast...")
|
||||||
_uiState.update { it.copy(isLoading = true, error = null) }
|
_uiState.update { it.copy(isLoading = true, error = null) }
|
||||||
|
|
||||||
|
|
@ -1616,7 +1547,7 @@ class MainViewModel @Inject constructor(
|
||||||
if (tx == null || sig == null) {
|
if (tx == null || sig == null) {
|
||||||
android.util.Log.e("MainViewModel", "[BROADCAST] Missing tx or signature! tx=$tx, sig=$sig")
|
android.util.Log.e("MainViewModel", "[BROADCAST] Missing tx or signature! tx=$tx, sig=$sig")
|
||||||
_uiState.update { it.copy(isLoading = false, error = "交易或签名缺失") }
|
_uiState.update { it.copy(isLoading = false, error = "交易或签名缺失") }
|
||||||
return@safeLaunch
|
return@launch
|
||||||
}
|
}
|
||||||
|
|
||||||
val rpcUrl = _settings.value.kavaRpcUrl
|
val rpcUrl = _settings.value.kavaRpcUrl
|
||||||
|
|
@ -1667,7 +1598,7 @@ class MainViewModel @Inject constructor(
|
||||||
* 每 3 秒轮询一次,最多尝试 60 次(3 分钟)
|
* 每 3 秒轮询一次,最多尝试 60 次(3 分钟)
|
||||||
*/
|
*/
|
||||||
private fun confirmTransactionInBackground(txHash: String, rpcUrl: String) {
|
private fun confirmTransactionInBackground(txHash: String, rpcUrl: String) {
|
||||||
safeLaunch {
|
viewModelScope.launch {
|
||||||
android.util.Log.d("MainViewModel", "[TX-CONFIRM] Starting background confirmation for $txHash")
|
android.util.Log.d("MainViewModel", "[TX-CONFIRM] Starting background confirmation for $txHash")
|
||||||
var attempts = 0
|
var attempts = 0
|
||||||
val maxAttempts = 60
|
val maxAttempts = 60
|
||||||
|
|
@ -1681,7 +1612,7 @@ class MainViewModel @Inject constructor(
|
||||||
onSuccess = { confirmed ->
|
onSuccess = { confirmed ->
|
||||||
if (confirmed) {
|
if (confirmed) {
|
||||||
android.util.Log.d("MainViewModel", "[TX-CONFIRM] Transaction confirmed after $attempts attempts")
|
android.util.Log.d("MainViewModel", "[TX-CONFIRM] Transaction confirmed after $attempts attempts")
|
||||||
return@safeLaunch
|
return@launch
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onFailure = { e ->
|
onFailure = { e ->
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue