fix(android): co-sign flow consistency with Electron + state reset

Changes:
- Fix Android state not resetting after successful keygen/join
  - Add resetSessionStatus() method in TssRepository
  - Call reset on success navigation in MainActivity

- Make Android co-sign flow 100% consistent with Electron:
  - Get keygen session status for participants list
  - Filter out co-managed-party-* (server backup parties)
  - Auto-join via gRPC after creating sign session
  - Start message routing BEFORE signing (prepareForSign)
  - Use gRPC response partyIndex instead of local share
  - Use original keygen thresholdN instead of signingParties.size
  - Pass parties list in join sign flow

- Update SignSessionInfoResponse to include parties array
- Update validateSignInviteCode to parse parties from API response

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
hailin 2026-01-01 10:52:13 -08:00
parent 7346b3518a
commit 04eeadf7a7
4 changed files with 199 additions and 75 deletions

View File

@ -108,6 +108,11 @@ fun TssPartyApp(
navController.navigate(BottomNavItem.Wallets.route) {
popUpTo(BottomNavItem.Wallets.route) { inclusive = true }
}
// Reset all session states so next time user enters a fresh state
viewModel.resetSessionState()
viewModel.resetJoinKeygenState()
viewModel.resetCoSignState()
viewModel.resetTransferState()
viewModel.clearSuccess()
viewModel.clearCreatedInviteCode()
}
@ -201,8 +206,11 @@ fun TssPartyApp(
onPrepareTransaction = { toAddress, amount ->
viewModel.prepareTransfer(shareId, toAddress, amount)
},
onConfirmTransaction = { password ->
viewModel.initiateSignSession(shareId, password)
onConfirmTransaction = {
viewModel.initiateSignSession(shareId, "")
},
onScanQrCode = {
// TODO: Launch QR scanner for recipient address
},
onCopyInviteCode = {
signInviteCode?.let { onCopyToClipboard(it) }

View File

@ -589,6 +589,20 @@ class TssRepository @Inject constructor(
val joinedCount = json.get("joined_count")?.asInt ?: 0
val joinToken = json.get("join_token")?.asString ?: ""
// Parse parties array (matching Electron's sessionInfo.parties)
val partiesArray = json.get("parties")?.asJsonArray
val parties = mutableListOf<Participant>()
partiesArray?.forEach { elem ->
val p = elem.asJsonObject
parties.add(Participant(
partyId = p.get("party_id")?.asString ?: "",
partyIndex = p.get("party_index")?.asInt ?: 0,
name = "参与方 ${(p.get("party_index")?.asInt ?: 0) + 1}"
))
}
android.util.Log.d("TssRepository", "[CO-SIGN] validateSignInviteCode: found ${parties.size} parties")
val signSessionInfo = SignSessionInfoResponse(
sessionId = sessionId,
keygenSessionId = keygenSessionId,
@ -596,7 +610,8 @@ class TssRepository @Inject constructor(
messageHash = messageHash,
thresholdT = thresholdT,
thresholdN = thresholdN,
currentParticipants = joinedCount
currentParticipants = joinedCount,
parties = parties
)
Result.success(ValidateSignInviteCodeResult(
@ -851,9 +866,11 @@ class TssRepository @Inject constructor(
}
val sessionData = joinResult.getOrThrow()
val myPartyIndex = shareEntity.partyIndex // Use party index from our share
// Use party_index from gRPC response (matching Electron's result.party_index)
// This is more authoritative than local share's partyIndex
val myPartyIndex = sessionData.partyIndex
android.util.Log.d("TssRepository", "gRPC sign join successful: partyIndex=$myPartyIndex, sessionStatus=${sessionData.sessionStatus}")
android.util.Log.d("TssRepository", "gRPC sign join successful: partyIndex=$myPartyIndex (from gRPC), localPartyIndex=${shareEntity.partyIndex}, sessionStatus=${sessionData.sessionStatus}")
// Build participants list (matching Electron's logic)
// Prefer using parties from validateInviteCode (complete list)
@ -1530,6 +1547,14 @@ class TssRepository @Inject constructor(
currentMessageRoutingPartyIndex = null
}
/**
* Reset session status to WAITING
* Called when navigating away from session screens to ensure fresh state
*/
fun resetSessionStatus() {
_sessionStatus.value = SessionStatus.WAITING
}
/**
* Delete a share record
*/
@ -1605,7 +1630,13 @@ class TssRepository @Inject constructor(
/**
* Create a sign session for a transaction (as initiator)
* Calls account-service API: POST /api/v1/co-managed/sign
* Matches Electron's cosign:createSession flow exactly:
* 1. Get keygen session status to get all participants with party_index
* 2. Filter out co-managed-party-* (server backup parties)
* 3. Create sign session with filtered signing parties
* 4. Auto-join the session via gRPC
* 5. Subscribe to message stream (prepareForSign)
* 6. Check if session already in_progress and trigger sign immediately
*/
suspend fun createSignSession(
shareId: Long,
@ -1619,20 +1650,54 @@ class TssRepository @Inject constructor(
val shareEntity = shareRecordDao.getShareById(shareId)
?: return@withContext Result.failure(Exception("Share not found"))
android.util.Log.d("TssRepository", "[CO-SIGN] Creating sign session for share: ${shareEntity.sessionId}")
// Step 1: Get keygen session status to get all participants with party_index
// This matches Electron's: accountClient.getSessionStatus(share.session_id)
val keygenStatusResult = getSessionStatus(shareEntity.sessionId)
if (keygenStatusResult.isFailure) {
return@withContext Result.failure(Exception("无法获取 keygen 会话的参与者信息: ${keygenStatusResult.exceptionOrNull()?.message}"))
}
val keygenStatus = keygenStatusResult.getOrThrow()
if (keygenStatus.participants.isEmpty()) {
return@withContext Result.failure(Exception("无法获取 keygen 会话的参与者信息"))
}
// Step 2: Filter out co-managed-party-* (server backup parties)
// Only user-held signing parties should participate in signing
// This matches Electron's: filter(p => !p.party_id.startsWith('co-managed-party-'))
val signingParties = keygenStatus.participants
.filter { !it.partyId.startsWith("co-managed-party-") }
.map { Pair(it.partyId, it.partyIndex) }
android.util.Log.d("TssRepository", "[CO-SIGN] Signing parties (excluding co-managed-party): ${signingParties.size} of ${keygenStatus.participants.size}")
signingParties.forEach { (id, index) ->
android.util.Log.d("TssRepository", "[CO-SIGN] party_id=${id.take(16)}, party_index=$index")
}
if (signingParties.size < shareEntity.thresholdT) {
return@withContext Result.failure(Exception(
"签名参与方不足: 需要 ${shareEntity.thresholdT} 个,但只有 ${signingParties.size} 个用户方"
))
}
val jsonMediaType = "application/json; charset=utf-8".toMediaType()
// Build parties array - include initiator party info
// Build parties array with all signing parties (not just initiator)
val partiesArray = com.google.gson.JsonArray().apply {
add(com.google.gson.JsonObject().apply {
addProperty("party_id", partyId)
addProperty("party_index", shareEntity.partyIndex)
})
signingParties.forEach { (partyIdStr, partyIndex) ->
add(com.google.gson.JsonObject().apply {
addProperty("party_id", partyIdStr)
addProperty("party_index", partyIndex)
})
}
}
// Build request body matching account-service API
val requestBody = com.google.gson.JsonObject().apply {
addProperty("keygen_session_id", shareEntity.sessionId)
addProperty("wallet_name", "Wallet") // Use a default name or could be passed as parameter
addProperty("wallet_name", shareEntity.walletName)
addProperty("message_hash", messageHash)
add("parties", partiesArray)
addProperty("threshold_t", shareEntity.thresholdT)
@ -1645,13 +1710,13 @@ class TssRepository @Inject constructor(
.header("Content-Type", "application/json")
.build()
android.util.Log.d("TssRepository", "Creating sign session: $requestBody")
android.util.Log.d("TssRepository", "[CO-SIGN] Creating sign session: $requestBody")
val response = httpClient.newCall(request).execute()
val responseBody = response.body?.string()
?: return@withContext Result.failure(Exception("空响应"))
android.util.Log.d("TssRepository", "Create sign session response: $responseBody")
android.util.Log.d("TssRepository", "[CO-SIGN] Create sign session response: $responseBody")
if (!response.isSuccessful) {
val errorJson = try {
@ -1669,13 +1734,31 @@ class TssRepository @Inject constructor(
val inviteCode = json.get("invite_code").asString
val thresholdT = json.get("threshold_t")?.asInt ?: shareEntity.thresholdT
// Update session state
// Get join token - support both new format (join_tokens map) and old format (join_token string)
val joinToken = if (json.has("join_tokens") && json.get("join_tokens").isJsonObject) {
val joinTokens = json.getAsJsonObject("join_tokens")
joinTokens.get(partyId)?.asString ?: joinTokens.get("*")?.asString
} else {
json.get("join_token")?.asString
}
// Build participants list from signingParties
val participants = signingParties.map { (pId, pIndex) ->
Participant(
partyId = pId,
partyIndex = pIndex,
name = if (pId == partyId) initiatorName else "参与方 ${pIndex + 1}"
)
}
// Update session state with complete participant list
// Use original keygen thresholdN (matching Electron's share.threshold_n)
val session = TssSession(
sessionId = sessionId,
sessionType = SessionType.SIGN,
thresholdT = thresholdT,
thresholdN = shareEntity.thresholdN,
participants = listOf(Participant(partyId, shareEntity.partyIndex, initiatorName)),
thresholdN = shareEntity.thresholdN, // Use original keygen N (matching Electron)
participants = participants,
status = SessionStatus.WAITING,
inviteCode = inviteCode,
messageHash = messageHash
@ -1683,12 +1766,54 @@ class TssRepository @Inject constructor(
_currentSession.value = session
_sessionStatus.value = SessionStatus.WAITING
// Step 4: Initiator auto-join via gRPC (matching Electron behavior)
var sessionAlreadyInProgress = false
if (joinToken != null) {
android.util.Log.d("TssRepository", "[CO-SIGN] Initiator auto-joining session...")
val myPartyIndex = signingParties.find { it.first == partyId }?.second ?: shareEntity.partyIndex
val joinResult = grpcClient.joinSession(sessionId, partyId, joinToken)
if (joinResult.isSuccess) {
val joinData = joinResult.getOrThrow()
android.util.Log.d("TssRepository", "[CO-SIGN] Initiator joined: partyIndex=${joinData.partyIndex}, status=${joinData.sessionStatus}")
// Step 5: Start message routing (prepareForSign) BEFORE sign starts
startMessageRouting(sessionId, myPartyIndex)
// Step 6: Check if session already in_progress
if (joinData.sessionStatus == "in_progress") {
android.util.Log.d("TssRepository", "[CO-SIGN] Session already in_progress, will trigger sign immediately")
sessionAlreadyInProgress = true
}
} else {
android.util.Log.w("TssRepository", "[CO-SIGN] Initiator failed to join: ${joinResult.exceptionOrNull()?.message}")
}
} else {
android.util.Log.w("TssRepository", "[CO-SIGN] No join token found for initiator")
}
// If session is already in_progress, notify callback immediately
if (sessionAlreadyInProgress) {
val selectedParties = signingParties.map { it.first }
val eventData = SessionEventData(
eventId = "immediate_sign_start",
eventType = "session_started",
sessionId = sessionId,
thresholdN = shareEntity.thresholdN, // Use original keygen N (matching Electron)
thresholdT = thresholdT,
selectedParties = selectedParties,
joinTokens = emptyMap(),
messageHash = messageHash
)
sessionEventCallback?.invoke(eventData)
}
Result.success(SignSessionResult(
sessionId = sessionId,
inviteCode = inviteCode
))
} catch (e: Exception) {
android.util.Log.e("TssRepository", "Create sign session failed", e)
android.util.Log.e("TssRepository", "[CO-SIGN] Create sign session failed", e)
Result.failure(e)
}
}
@ -1696,6 +1821,7 @@ class TssRepository @Inject constructor(
/**
* Start the signing process after all parties have joined
* Note: Message routing should already be started in createSignSession (after auto-join)
*/
suspend fun startSigning(
sessionId: String,
@ -1710,6 +1836,8 @@ class TssRepository @Inject constructor(
val shareEntity = shareRecordDao.getShareById(shareId)
?: return@withContext Result.failure(Exception("Share not found"))
android.util.Log.d("TssRepository", "[CO-SIGN] startSigning: participants=${session.participants.size}")
// Start TSS sign
val startResult = tssNativeBridge.startSign(
sessionId = sessionId,
@ -1729,8 +1857,11 @@ class TssRepository @Inject constructor(
_sessionStatus.value = SessionStatus.IN_PROGRESS
// Start message routing
startMessageRouting(sessionId, shareEntity.partyIndex)
// Note: Message routing is already started in createSignSession after auto-join
// Only start if not already running (for backward compatibility with old flow)
if (messageCollectionJob == null || messageCollectionJob?.isActive != true) {
startMessageRouting(sessionId, shareEntity.partyIndex)
}
// Mark ready
grpcClient.markPartyReady(sessionId, partyId)
@ -2027,7 +2158,8 @@ data class SignSessionInfoResponse(
val messageHash: String,
val thresholdT: Int,
val thresholdN: Int,
val currentParticipants: Int
val currentParticipants: Int,
val parties: List<Participant> = emptyList() // Complete parties list from API
)
/**

View File

@ -1,7 +1,10 @@
package com.durian.tssparty.presentation.screens
import android.app.Activity
import android.content.Intent
import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
@ -17,8 +20,6 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
@ -60,16 +61,15 @@ fun TransferScreen(
networkType: NetworkType = NetworkType.MAINNET,
rpcUrl: String = "https://evm.kava.io",
onPrepareTransaction: (toAddress: String, amount: String) -> Unit,
onConfirmTransaction: (password: String) -> Unit,
onConfirmTransaction: () -> Unit,
onCopyInviteCode: () -> Unit,
onBroadcastTransaction: () -> Unit,
onCancel: () -> Unit,
onBackToWallets: () -> Unit
onBackToWallets: () -> Unit,
onScanQrCode: () -> Unit = {}
) {
var toAddress by remember { mutableStateOf("") }
var amount by remember { mutableStateOf("") }
var password by remember { mutableStateOf("") }
var showPassword by remember { mutableStateOf(false) }
var validationError by remember { mutableStateOf<String?>(null) }
// Determine current step
@ -131,7 +131,8 @@ fun TransferScreen(
}
}
},
onCancel = onCancel
onCancel = onCancel,
onScanQrCode = onScanQrCode
)
"preparing" -> PreparingScreen()
@ -141,18 +142,10 @@ fun TransferScreen(
preparedTx = preparedTx!!,
toAddress = toAddress,
amount = amount,
password = password,
onPasswordChange = { password = it },
showPassword = showPassword,
onTogglePassword = { showPassword = !showPassword },
error = error,
onConfirm = {
if (password.isBlank()) {
validationError = "请输入密码"
} else {
validationError = null
onConfirmTransaction(password)
}
validationError = null
onConfirmTransaction()
},
onBack = onCancel
)
@ -201,7 +194,8 @@ private fun TransferInputScreen(
error: String?,
rpcUrl: String,
onSubmit: () -> Unit,
onCancel: () -> Unit
onCancel: () -> Unit,
onScanQrCode: () -> Unit
) {
val scope = rememberCoroutineScope()
var isCalculatingMax by remember { mutableStateOf(false) }
@ -251,16 +245,25 @@ private fun TransferInputScreen(
Spacer(modifier = Modifier.height(24.dp))
// Recipient address
// Recipient address with QR scan button
OutlinedTextField(
value = toAddress,
onValueChange = onToAddressChange,
label = { Text("收款地址") },
placeholder = { Text("0x...") },
placeholder = { Text("0x... 或点击扫码") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
leadingIcon = {
Icon(Icons.Default.Person, contentDescription = null)
},
trailingIcon = {
IconButton(onClick = onScanQrCode) {
Icon(
Icons.Default.QrCodeScanner,
contentDescription = "扫描二维码",
tint = MaterialTheme.colorScheme.primary
)
}
}
)
@ -414,10 +417,6 @@ private fun TransferConfirmScreen(
preparedTx: TransactionUtils.PreparedTransaction,
toAddress: String,
amount: String,
password: String,
onPasswordChange: (String) -> Unit,
showPassword: Boolean,
onTogglePassword: () -> Unit,
error: String?,
onConfirm: () -> Unit,
onBack: () -> Unit
@ -501,30 +500,6 @@ private fun TransferConfirmScreen(
}
}
Spacer(modifier = Modifier.height(24.dp))
// Password input
OutlinedTextField(
value = password,
onValueChange = onPasswordChange,
label = { Text("钱包密码") },
placeholder = { Text("输入密码解锁密钥份额") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
visualTransformation = if (showPassword) VisualTransformation.None else PasswordVisualTransformation(),
leadingIcon = {
Icon(Icons.Default.Lock, contentDescription = null)
},
trailingIcon = {
IconButton(onClick = onTogglePassword) {
Icon(
if (showPassword) Icons.Default.VisibilityOff else Icons.Default.Visibility,
contentDescription = null
)
}
}
)
// Error display
error?.let {
Spacer(modifier = Modifier.height(16.dp))

View File

@ -426,6 +426,8 @@ class MainViewModel @Inject constructor(
_publicKey.value = null
_createdInviteCode.value = null
_hasEnteredSession.value = false
// Reset session status to WAITING for fresh start
repository.resetSessionStatus()
}
// ========== Join Keygen State ==========
@ -612,6 +614,8 @@ class MainViewModel @Inject constructor(
pendingJoinToken = ""
pendingPassword = ""
pendingJoinKeygenInfo = null
// Reset session status to WAITING for fresh start
repository.resetSessionStatus()
}
// ========== CoSign (Join Sign) State ==========
@ -635,7 +639,7 @@ class MainViewModel @Inject constructor(
/**
* Validate sign session invite code
* Matches Electron's cosign:validateInviteCode - returns sessionInfo + joinToken
* Matches Electron's cosign:validateInviteCode - returns sessionInfo + joinToken + parties
*/
fun validateSignInviteCode(inviteCode: String) {
viewModelScope.launch {
@ -657,11 +661,12 @@ class MainViewModel @Inject constructor(
messageHash = info.messageHash,
thresholdT = info.thresholdT,
thresholdN = info.thresholdN,
currentParticipants = info.currentParticipants
currentParticipants = info.currentParticipants,
parties = info.parties // Include complete parties list
)
_uiState.update { it.copy(isLoading = false) }
android.util.Log.d("MainViewModel", "Validate sign success: sessionId=${info.sessionId}, joinToken length=${pendingCoSignJoinToken.length}")
android.util.Log.d("MainViewModel", "Validate sign success: sessionId=${info.sessionId}, parties=${info.parties.size}, joinToken length=${pendingCoSignJoinToken.length}")
},
onFailure = { e ->
_uiState.update { it.copy(isLoading = false, error = e.message) }
@ -693,9 +698,10 @@ class MainViewModel @Inject constructor(
_uiState.update { it.copy(isLoading = true, error = null) }
android.util.Log.d("MainViewModel", "Joining sign session: sessionId=${sessionInfo.sessionId}, joinToken length=${pendingCoSignJoinToken.length}")
android.util.Log.d("MainViewModel", "Joining sign session: sessionId=${sessionInfo.sessionId}, parties=${sessionInfo.parties.size}, joinToken length=${pendingCoSignJoinToken.length}")
// Join session via gRPC (matching Electron's cosign:joinSession)
// Use complete parties list from validateInviteCode (matching Electron's behavior)
val result = repository.joinSignSessionViaGrpc(
sessionId = sessionInfo.sessionId,
joinToken = pendingCoSignJoinToken,
@ -705,7 +711,7 @@ class MainViewModel @Inject constructor(
messageHash = sessionInfo.messageHash,
thresholdT = sessionInfo.thresholdT,
thresholdN = sessionInfo.thresholdN,
parties = emptyList() // Will use other_parties from gRPC response
parties = sessionInfo.parties // Use complete parties list from validateInviteCode
)
result.fold(
@ -793,6 +799,8 @@ class MainViewModel @Inject constructor(
pendingCoSignInviteCode = ""
pendingCoSignJoinToken = ""
pendingJoinSignInfo = null
// Reset session status to WAITING for fresh start
repository.resetSessionStatus()
}
/**
@ -1212,7 +1220,8 @@ data class CoSignSessionInfo(
val messageHash: String,
val thresholdT: Int,
val thresholdN: Int,
val currentParticipants: Int
val currentParticipants: Int,
val parties: List<com.durian.tssparty.data.repository.Participant> = emptyList() // Complete parties list from API
)
/**