feat(android): update theme to dark gray & gold, fix JoinKeygen/CoSign flows

Theme changes:
- Replace green theme with dark gray & gold color scheme
- Primary color: Gold (#D4AF37)
- Background: Dark gray (#1A1A1A)
- Surface: Medium gray (#2D2D2D)
- Disable dynamic colors to enforce custom theme
- Default to dark theme for best visual impact
- Update success indicators from green to gold across screens

JoinKeygen flow fixes (100% Electron compatible):
- Add onResetState callback for proper state reset
- Cancel in confirm/joining/progress resets to input state (stays on page)
- Two-step flow: joinKeygenSessionViaGrpc + executeKeygenAsJoiner
- Wait for session_started event before executing keygen

CoSign flow fixes (100% Electron compatible):
- Add onResetState callback and QR scanner support
- Add three-button layout (Cancel, Back, Join) in select_share step
- Two-step flow: joinSignSessionViaGrpc + executeSignAsJoiner
- If session already in_progress, trigger sign immediately (Solution B)
- Wait for session_started event otherwise

Repository changes:
- Add joinKeygenSessionViaGrpc and executeKeygenAsJoiner methods
- Add joinSignSessionViaGrpc and executeSignAsJoiner methods
- Add JoinKeygenViaGrpcResult and JoinSignViaGrpcResult data classes

🤖 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 04:35:00 -08:00
parent 2b0920f9b1
commit d8be40b8b0
10 changed files with 1160 additions and 95 deletions

View File

@ -249,6 +249,9 @@ fun TssPartyApp(
viewModel.cancelSession()
viewModel.clearError()
viewModel.resetSessionState()
navController.navigate(BottomNavItem.Wallets.route) {
popUpTo(BottomNavItem.Wallets.route) { inclusive = true }
}
},
onBackToHome = {
viewModel.resetSessionState()
@ -290,6 +293,16 @@ fun TssPartyApp(
viewModel.joinKeygen(inviteCode, password)
},
onCancel = {
// Cancel from input screen - navigate away
viewModel.cancelSession()
viewModel.clearError()
viewModel.resetJoinKeygenState()
navController.navigate(BottomNavItem.Wallets.route) {
popUpTo(BottomNavItem.Wallets.route) { inclusive = true }
}
},
onResetState = {
// Reset from confirm/joining/progress screens - stay on page
viewModel.cancelSession()
viewModel.clearError()
viewModel.resetJoinKeygenState()
@ -335,6 +348,16 @@ fun TssPartyApp(
viewModel.joinSign(inviteCode, shareId, password)
},
onCancel = {
// Cancel from input screen - navigate away
viewModel.cancelSession()
viewModel.clearError()
viewModel.resetCoSignState()
navController.navigate(BottomNavItem.Wallets.route) {
popUpTo(BottomNavItem.Wallets.route) { inclusive = true }
}
},
onResetState = {
// Reset from select_share/joining/signing screens - stay on page
viewModel.cancelSession()
viewModel.clearError()
viewModel.resetCoSignState()

View File

@ -113,7 +113,8 @@ class GrpcClient @Inject constructor() {
partyIndex = response.partyIndex,
participants = participants,
messageHash = if (sessionInfo.messageHash.isEmpty) null
else Base64.encodeToString(sessionInfo.messageHash.toByteArray(), Base64.NO_WRAP)
else Base64.encodeToString(sessionInfo.messageHash.toByteArray(), Base64.NO_WRAP),
sessionStatus = if (sessionInfo.status.isNullOrEmpty()) null else sessionInfo.status
)
)
} else {
@ -305,7 +306,8 @@ data class JoinSessionData(
val thresholdT: Int,
val partyIndex: Int,
val participants: List<Participant>,
val messageHash: String?
val messageHash: String?,
val sessionStatus: String? = null
)
/**

View File

@ -36,6 +36,7 @@ class TssRepository @Inject constructor(
private var partyId: String = UUID.randomUUID().toString()
private var messageCollectionJob: Job? = null
private var sessionEventJob: Job? = null
// Account service URL (configurable via settings)
private var accountServiceUrl: String = "https://rwaapi.szaiai.com"
@ -65,17 +66,65 @@ class TssRepository @Inject constructor(
*/
fun disconnect() {
messageCollectionJob?.cancel()
sessionEventJob?.cancel()
grpcClient.disconnect()
}
// Session event callback (set by ViewModel)
private var sessionEventCallback: ((SessionEventData) -> Unit)? = null
/**
* Register this party with the router
* Also subscribes to session events (matching Electron behavior)
*/
suspend fun registerParty(): String {
grpcClient.registerParty(partyId, "temporary", "1.0.0")
// Subscribe to session events immediately after registration (like Electron does)
startSessionEventSubscription()
return partyId
}
/**
* Start session event subscription (called after registration)
*/
private fun startSessionEventSubscription() {
sessionEventJob?.cancel()
sessionEventJob = CoroutineScope(Dispatchers.IO).launch {
grpcClient.subscribeSessionEvents(partyId).collect { event ->
android.util.Log.d("TssRepository", "Session event received: ${event.eventType} for session ${event.sessionId}")
// Check if this event is for our active session
val activeSession = _currentSession.value
if (activeSession != null && event.sessionId == activeSession.sessionId) {
when (event.eventType) {
"session_started" -> {
android.util.Log.d("TssRepository", "Session started event for our session, triggering keygen")
// Notify callback
sessionEventCallback?.invoke(event)
}
"party_joined", "participant_joined" -> {
android.util.Log.d("TssRepository", "Party joined our session")
sessionEventCallback?.invoke(event)
}
"all_joined" -> {
android.util.Log.d("TssRepository", "All parties joined our session")
sessionEventCallback?.invoke(event)
}
}
}
}
}
}
/**
* Set session event callback (called by ViewModel)
*/
fun setSessionEventCallback(callback: (SessionEventData) -> Unit) {
sessionEventCallback = callback
}
/**
* Get share count for startup check
*/
@ -94,26 +143,34 @@ class TssRepository @Inject constructor(
/**
* Create a new keygen session (as initiator)
* Calls account-service API: POST /api/v1/co-managed/sessions
* Then auto-joins the session via gRPC (matching Electron behavior)
*/
suspend fun createKeygenSession(
walletName: String,
thresholdT: Int,
thresholdN: Int,
participantName: String
): Result<String> {
): Result<CreateKeygenSessionResult> {
return withContext(Dispatchers.IO) {
try {
val jsonMediaType = "application/json; charset=utf-8".toMediaType()
// Build request body matching account-service API
// Calculate persistent and external counts (matching Electron)
// persistent = n - t (platform backup parties)
// external = t (user-held parties)
val persistentCount = thresholdN - thresholdT
val externalCount = thresholdT
// Build request body matching account-service API (same as Electron)
val requestBody = com.google.gson.JsonObject().apply {
addProperty("wallet_name", walletName)
addProperty("threshold_t", thresholdT)
addProperty("threshold_n", thresholdN)
addProperty("initiator_party_id", partyId)
addProperty("initiator_name", participantName)
addProperty("persistent_count", 0) // All external participants
addProperty("external_count", thresholdN)
addProperty("persistent_count", persistentCount)
addProperty("external_count", externalCount)
addProperty("expires_in_seconds", 86400) // 24 hours, same as Electron
}.toString()
val request = okhttp3.Request.Builder()
@ -142,14 +199,83 @@ class TssRepository @Inject constructor(
// Parse response
val json = com.google.gson.JsonParser.parseString(responseBody).asJsonObject
val sessionId = json.get("session_id").asString
val inviteCode = json.get("invite_code").asString
val joinToken = json.get("join_token")?.asString
?: json.get("join_tokens")?.asJsonObject?.entrySet()?.firstOrNull()?.value?.asString
// Get join token - prioritize join_tokens map with partyId, then wildcard, then single join_token
val joinTokensObj = json.get("join_tokens")?.asJsonObject
val joinToken = joinTokensObj?.get(partyId)?.asString
?: joinTokensObj?.get("*")?.asString
?: json.get("join_token")?.asString
?: ""
// Return invite code in format: inviteCode (the API returns a ready-to-use invite code)
// The invite code can be used directly - joinToken is for direct session joining
Result.success(inviteCode)
android.util.Log.d("TssRepository", "Session created: sessionId=$sessionId, inviteCode=$inviteCode, joinToken length=${joinToken.length}")
// Auto-join session via gRPC (matching Electron behavior)
var partyIndex = 0
var sessionAlreadyInProgress = false
if (joinToken.isNotEmpty()) {
android.util.Log.d("TssRepository", "Initiator auto-joining session via gRPC...")
val joinResult = grpcClient.joinSession(sessionId, partyId, joinToken)
if (joinResult.isSuccess) {
val joinData = joinResult.getOrThrow()
partyIndex = joinData.partyIndex
android.util.Log.d("TssRepository", "Initiator joined session: partyIndex=$partyIndex, sessionStatus=${joinData.sessionStatus}")
// Update session state
val session = TssSession(
sessionId = sessionId,
sessionType = SessionType.KEYGEN,
thresholdT = thresholdT,
thresholdN = thresholdN,
participants = listOf(Participant(partyId, partyIndex, participantName)),
status = SessionStatus.WAITING,
inviteCode = inviteCode
)
_currentSession.value = session
_sessionStatus.value = SessionStatus.WAITING
// Start message subscription for this session
startMessageRouting(sessionId, partyIndex)
// Check if session is already in_progress (Solution B from Electron)
// This handles the case where all parties join before we check
if (joinData.sessionStatus == "in_progress") {
android.util.Log.d("TssRepository", "Session already in_progress, will trigger keygen immediately")
sessionAlreadyInProgress = true
}
} else {
android.util.Log.w("TssRepository", "Initiator failed to join session via gRPC: ${joinResult.exceptionOrNull()?.message}")
}
} else {
android.util.Log.w("TssRepository", "No join token found for initiator partyId: $partyId")
}
// If session is already in_progress, notify callback immediately
if (sessionAlreadyInProgress) {
val eventData = SessionEventData(
eventId = "immediate_start",
eventType = "session_started",
sessionId = sessionId,
thresholdN = thresholdN,
thresholdT = thresholdT,
selectedParties = listOf(partyId),
joinTokens = emptyMap(),
messageHash = null
)
sessionEventCallback?.invoke(eventData)
}
Result.success(CreateKeygenSessionResult(
sessionId = sessionId,
inviteCode = inviteCode,
partyIndex = partyIndex,
walletName = walletName,
thresholdT = thresholdT,
thresholdN = thresholdN
))
} catch (e: Exception) {
android.util.Log.e("TssRepository", "Create keygen session failed", e)
Result.failure(e)
@ -289,9 +415,327 @@ class TssRepository @Inject constructor(
}
/**
* Join a keygen session and execute keygen protocol
* First calls account-service to get session info and join token, then joins via gRPC
* Join a keygen session via gRPC only (matching Electron's grpc:joinSession)
* This does NOT start keygen - that happens when session_started event is received
*
* Flow (matching Electron):
* 1. Call grpcClient.joinSession(sessionId, partyId, joinToken)
* 2. Set up activeKeygenSession state
* 3. Prepare for keygen (subscribe to messages)
* 4. Return - keygen will start when session_started event is received
*/
suspend fun joinKeygenSessionViaGrpc(
sessionId: String,
joinToken: String,
walletName: String,
thresholdT: Int,
thresholdN: Int
): Result<JoinKeygenViaGrpcResult> {
return withContext(Dispatchers.IO) {
try {
android.util.Log.d("TssRepository", "Joining keygen session via gRPC: sessionId=$sessionId, joinToken length=${joinToken.length}")
// Join session via gRPC (matching Electron's grpcClient.joinSession)
val joinResult = grpcClient.joinSession(sessionId, partyId, joinToken)
if (joinResult.isFailure) {
android.util.Log.e("TssRepository", "gRPC join failed", joinResult.exceptionOrNull())
return@withContext Result.failure(joinResult.exceptionOrNull()!!)
}
val sessionData = joinResult.getOrThrow()
val myPartyIndex = sessionData.partyIndex
android.util.Log.d("TssRepository", "gRPC join successful: partyIndex=$myPartyIndex, sessionStatus=${sessionData.sessionStatus}")
// Build participants list (matching Electron's logic)
val participants = sessionData.participants.toMutableList()
participants.add(Participant(partyId, myPartyIndex, ""))
participants.sortBy { it.partyIndex }
// Set up active keygen session state (matching Electron's activeKeygenSession)
val session = TssSession(
sessionId = sessionId,
sessionType = SessionType.KEYGEN,
thresholdT = thresholdT,
thresholdN = thresholdN,
participants = participants,
status = SessionStatus.WAITING,
inviteCode = null
)
_currentSession.value = session
_sessionStatus.value = SessionStatus.WAITING
// Start message subscription for this session (matching Electron's prepareForKeygen)
startMessageRouting(sessionId, myPartyIndex)
android.util.Log.d("TssRepository", "Session state set, waiting for session_started event")
Result.success(JoinKeygenViaGrpcResult(
partyIndex = myPartyIndex,
sessionStatus = sessionData.sessionStatus
))
} catch (e: Exception) {
android.util.Log.e("TssRepository", "Join keygen session via gRPC failed", e)
Result.failure(e)
}
}
}
/**
* Execute keygen as joiner (called when session_started event is received)
* This is the second part of the join flow, triggered by session_started event
*/
suspend fun executeKeygenAsJoiner(
sessionId: String,
partyIndex: Int,
thresholdT: Int,
thresholdN: Int,
password: String
): Result<ShareRecord> = coroutineScope {
try {
val session = _currentSession.value
if (session == null || session.sessionId != sessionId) {
return@coroutineScope Result.failure(Exception("No active session or session mismatch"))
}
android.util.Log.d("TssRepository", "Executing keygen as joiner: sessionId=$sessionId, partyIndex=$partyIndex")
// Start TSS keygen
val startResult = tssNativeBridge.startKeygen(
sessionId = sessionId,
partyId = partyId,
partyIndex = partyIndex,
thresholdT = thresholdT,
thresholdN = thresholdN,
participants = session.participants,
password = password
)
if (startResult.isFailure) {
return@coroutineScope Result.failure(startResult.exceptionOrNull()!!)
}
_sessionStatus.value = SessionStatus.IN_PROGRESS
// Mark ready
grpcClient.markPartyReady(sessionId, partyId)
// Wait for keygen result
val keygenResult = tssNativeBridge.waitForKeygenResult(password)
if (keygenResult.isFailure) {
_sessionStatus.value = SessionStatus.FAILED
return@coroutineScope Result.failure(keygenResult.exceptionOrNull()!!)
}
val result = keygenResult.getOrThrow()
// Derive address from public key
val publicKeyBytes = Base64.decode(result.publicKey, Base64.NO_WRAP)
val address = AddressUtils.deriveKavaAddress(publicKeyBytes)
// Save share record
val shareEntity = ShareRecordEntity(
sessionId = sessionId,
publicKey = result.publicKey,
encryptedShare = result.encryptedShare,
thresholdT = thresholdT,
thresholdN = thresholdN,
partyIndex = partyIndex,
address = address
)
val id = shareRecordDao.insertShare(shareEntity)
// Report completion
grpcClient.reportCompletion(sessionId, partyId, publicKeyBytes)
_sessionStatus.value = SessionStatus.COMPLETED
android.util.Log.d("TssRepository", "Keygen as joiner completed: address=$address")
Result.success(shareEntity.copy(id = id).toShareRecord())
} catch (e: Exception) {
android.util.Log.e("TssRepository", "Execute keygen as joiner failed", e)
_sessionStatus.value = SessionStatus.FAILED
Result.failure(e)
}
}
/**
* Join a sign session via gRPC only (matching Electron's cosign:joinSession flow)
* This does NOT start signing - that happens when session_started event is received
*
* Flow (matching Electron):
* 1. Validate share exists and password is correct
* 2. Call grpcClient.joinSession(sessionId, partyId, joinToken)
* 3. Set up activeCoSignSession state
* 4. Start message subscription (prepareForSign)
* 5. If session is already in_progress, return that status so caller can trigger sign
* 6. Otherwise return - sign will start when session_started event is received
*/
suspend fun joinSignSessionViaGrpc(
sessionId: String,
joinToken: String,
shareId: Long,
password: String,
walletName: String,
messageHash: String,
thresholdT: Int,
thresholdN: Int,
parties: List<Participant>
): Result<JoinSignViaGrpcResult> {
return withContext(Dispatchers.IO) {
try {
android.util.Log.d("TssRepository", "Joining sign session via gRPC: sessionId=$sessionId")
// Validate share exists (matching Electron)
val shareEntity = shareRecordDao.getShareById(shareId)
?: return@withContext Result.failure(Exception("Share not found"))
// Note: Password is verified during actual sign execution, same as Electron
// Join session via gRPC (matching Electron's grpcClient.joinSession)
val joinResult = grpcClient.joinSession(sessionId, partyId, joinToken)
if (joinResult.isFailure) {
android.util.Log.e("TssRepository", "gRPC sign join failed", joinResult.exceptionOrNull())
return@withContext Result.failure(joinResult.exceptionOrNull()!!)
}
val sessionData = joinResult.getOrThrow()
val myPartyIndex = shareEntity.partyIndex // Use party index from our share
android.util.Log.d("TssRepository", "gRPC sign join successful: partyIndex=$myPartyIndex, sessionStatus=${sessionData.sessionStatus}")
// Build participants list (matching Electron's logic)
// Prefer using parties from validateInviteCode (complete list)
val participants = if (parties.isNotEmpty()) {
parties.toMutableList()
} else {
// Fallback: use other_parties + self
val list = sessionData.participants.toMutableList()
list.add(Participant(partyId, myPartyIndex, ""))
list.sortBy { it.partyIndex }
list
}
// Set up active sign session state (matching Electron's activeCoSignSession)
val session = TssSession(
sessionId = sessionId,
sessionType = SessionType.SIGN,
thresholdT = thresholdT,
thresholdN = thresholdN,
participants = participants,
status = SessionStatus.WAITING,
inviteCode = null,
messageHash = messageHash
)
_currentSession.value = session
_sessionStatus.value = SessionStatus.WAITING
// Start message subscription (matching Electron's prepareForSign)
startMessageRouting(sessionId, myPartyIndex)
android.util.Log.d("TssRepository", "Sign session state set, waiting for session_started event or in_progress status")
Result.success(JoinSignViaGrpcResult(
partyIndex = myPartyIndex,
sessionStatus = sessionData.sessionStatus,
shareId = shareId
))
} catch (e: Exception) {
android.util.Log.e("TssRepository", "Join sign session via gRPC failed", e)
Result.failure(e)
}
}
}
/**
* Execute sign as joiner (called when session_started event is received or session is already in_progress)
* This is the second part of the join sign flow
*/
suspend fun executeSignAsJoiner(
sessionId: String,
partyIndex: Int,
thresholdT: Int,
thresholdN: Int,
messageHash: String,
shareId: Long,
password: String
): Result<SignResult> = coroutineScope {
try {
val session = _currentSession.value
if (session == null || session.sessionId != sessionId) {
return@coroutineScope Result.failure(Exception("No active session or session mismatch"))
}
android.util.Log.d("TssRepository", "Executing sign as joiner: sessionId=$sessionId, partyIndex=$partyIndex")
// Get share record
val shareEntity = shareRecordDao.getShareById(shareId)
?: return@coroutineScope Result.failure(Exception("Share not found"))
// Update session status
_sessionStatus.value = SessionStatus.IN_PROGRESS
// Get all participants from session
val allParticipants = session.participants
android.util.Log.d("TssRepository", "Starting TSS sign with ${allParticipants.size} participants, thresholdT=$thresholdT")
// Start TSS sign
val startResult = tssNativeBridge.startSign(
sessionId = sessionId,
partyId = partyId,
partyIndex = partyIndex,
thresholdT = thresholdT,
thresholdN = shareEntity.thresholdN, // Use original N from keygen
participants = allParticipants,
messageHash = messageHash,
shareData = shareEntity.encryptedShare,
password = password
)
if (startResult.isFailure) {
_sessionStatus.value = SessionStatus.FAILED
return@coroutineScope Result.failure(startResult.exceptionOrNull()!!)
}
// Mark ready
grpcClient.markPartyReady(sessionId, partyId)
// Wait for sign result
val signResult = tssNativeBridge.waitForSignResult()
if (signResult.isFailure) {
_sessionStatus.value = SessionStatus.FAILED
return@coroutineScope Result.failure(signResult.exceptionOrNull()!!)
}
val result = signResult.getOrThrow()
// Report completion
val signatureBytes = android.util.Base64.decode(result.signature, android.util.Base64.NO_WRAP)
grpcClient.reportCompletion(sessionId, partyId, signature = signatureBytes)
_sessionStatus.value = SessionStatus.COMPLETED
messageCollectionJob?.cancel()
android.util.Log.d("TssRepository", "Sign as joiner completed: signature=${result.signature.take(20)}...")
Result.success(result)
} catch (e: Exception) {
android.util.Log.e("TssRepository", "Execute sign as joiner failed", e)
_sessionStatus.value = SessionStatus.FAILED
Result.failure(e)
}
}
/**
* Join a keygen session and execute keygen protocol (DEPRECATED - old flow)
* First calls account-service to get session info and join token, then joins via gRPC
* @deprecated Use joinKeygenSessionViaGrpc + executeKeygenAsJoiner instead
*/
@Deprecated("Use joinKeygenSessionViaGrpc + executeKeygenAsJoiner for Electron-compatible flow")
suspend fun joinKeygenSession(
inviteCode: String,
password: String
@ -687,12 +1131,92 @@ class TssRepository @Inject constructor(
}
}
/**
* Start keygen as initiator (called when session_started event is received)
*/
suspend fun startKeygenAsInitiator(
sessionId: String,
thresholdT: Int,
thresholdN: Int,
password: String
): Result<ShareRecord> = coroutineScope {
try {
val session = _currentSession.value
if (session == null || session.sessionId != sessionId) {
return@coroutineScope Result.failure(Exception("No active session"))
}
android.util.Log.d("TssRepository", "Starting keygen as initiator: sessionId=$sessionId, partyIndex=${session.participants.firstOrNull()?.partyIndex}")
val myPartyIndex = session.participants.firstOrNull()?.partyIndex ?: 0
// Start TSS keygen
val startResult = tssNativeBridge.startKeygen(
sessionId = sessionId,
partyId = partyId,
partyIndex = myPartyIndex,
thresholdT = thresholdT,
thresholdN = thresholdN,
participants = session.participants,
password = password
)
if (startResult.isFailure) {
return@coroutineScope Result.failure(startResult.exceptionOrNull()!!)
}
_sessionStatus.value = SessionStatus.IN_PROGRESS
// Mark ready
grpcClient.markPartyReady(sessionId, partyId)
// Wait for keygen result
val keygenResult = tssNativeBridge.waitForKeygenResult(password)
if (keygenResult.isFailure) {
_sessionStatus.value = SessionStatus.FAILED
return@coroutineScope Result.failure(keygenResult.exceptionOrNull()!!)
}
val result = keygenResult.getOrThrow()
// Derive address from public key
val publicKeyBytes = Base64.decode(result.publicKey, Base64.NO_WRAP)
val address = AddressUtils.deriveKavaAddress(publicKeyBytes)
// Save share record
val shareEntity = ShareRecordEntity(
sessionId = sessionId,
publicKey = result.publicKey,
encryptedShare = result.encryptedShare,
thresholdT = thresholdT,
thresholdN = thresholdN,
partyIndex = myPartyIndex,
address = address
)
val id = shareRecordDao.insertShare(shareEntity)
// Report completion
grpcClient.reportCompletion(sessionId, partyId, publicKeyBytes)
_sessionStatus.value = SessionStatus.COMPLETED
sessionEventJob?.cancel()
Result.success(shareEntity.copy(id = id).toShareRecord())
} catch (e: Exception) {
android.util.Log.e("TssRepository", "Start keygen as initiator failed", e)
_sessionStatus.value = SessionStatus.FAILED
Result.failure(e)
}
}
/**
* Cancel current session
*/
fun cancelSession() {
tssNativeBridge.cancelSession()
messageCollectionJob?.cancel()
sessionEventJob?.cancel()
_currentSession.value = null
_sessionStatus.value = SessionStatus.WAITING
}
@ -1135,6 +1659,18 @@ class TssRepository @Inject constructor(
}
}
/**
* Result of creating a keygen session
*/
data class CreateKeygenSessionResult(
val sessionId: String,
val inviteCode: String,
val partyIndex: Int,
val walletName: String,
val thresholdT: Int,
val thresholdN: Int
)
/**
* Result of creating a sign session
*/
@ -1208,6 +1744,25 @@ data class ApiJoinSignSessionData(
val joinToken: String
)
/**
* Result of joinKeygenSessionViaGrpc
* Matches Electron's grpc:joinSession return value
*/
data class JoinKeygenViaGrpcResult(
val partyIndex: Int,
val sessionStatus: String?
)
/**
* Result of joining sign session via gRPC
* Matches Electron's cosign:joinSession return value
*/
data class JoinSignViaGrpcResult(
val partyIndex: Int,
val sessionStatus: String?,
val shareId: Long
)
private fun ShareRecordEntity.toShareRecord() = ShareRecord(
id = id,
sessionId = sessionId,

View File

@ -1,5 +1,7 @@
package com.durian.tssparty.presentation.screens
import android.app.Activity
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
@ -9,6 +11,7 @@ import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
@ -16,6 +19,8 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.durian.tssparty.domain.model.SessionStatus
import com.durian.tssparty.domain.model.ShareRecord
import com.journeyapps.barcodescanner.ScanContract
import com.journeyapps.barcodescanner.ScanOptions
/**
* Sign session info returned from validateSignInviteCode API
@ -32,8 +37,8 @@ data class SignSessionInfo(
)
/**
* CoSign Join screen matching service-party-app/src/renderer/src/pages/CoSignJoin.tsx
* 2-step flow: input select_share (auto-join) signing completed
* CoSign Join screen matching service-party-app/src/renderer/src/pages/Sign.tsx
* Flow: input select_share joining signing completed
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@ -50,18 +55,53 @@ fun CoSignJoinScreen(
onValidateInviteCode: (inviteCode: String) -> Unit,
onJoinSign: (inviteCode: String, shareId: Long, password: String) -> Unit,
onCancel: () -> Unit,
onResetState: () -> Unit = {}, // Reset ViewModel state without navigating
onBackToHome: () -> Unit = {}
) {
val context = LocalContext.current
var inviteCode by remember { mutableStateOf("") }
var selectedShareId by remember { mutableStateOf<Long?>(null) }
var password by remember { mutableStateOf("") }
var showPassword by remember { mutableStateOf(false) }
var validationError by remember { mutableStateOf<String?>(null) }
// 2-step flow: input → select_share → joining → signing → completed
// Flow: input → select_share → joining → signing → completed
var step by remember { mutableStateOf("input") }
var autoJoinAttempted by remember { mutableStateOf(false) }
// QR Code Scanner
val scanLauncher = rememberLauncherForActivityResult(ScanContract()) { result ->
if (result.contents != null) {
// Extract invite code from scanned content
val scannedCode = result.contents.let { content ->
// Handle both raw invite codes and URLs
if (content.contains("/")) {
content.substringAfterLast("/")
} else {
content
}
}
inviteCode = scannedCode.uppercase()
// Auto-validate after scan
if (inviteCode.isNotBlank()) {
validationError = null
onValidateInviteCode(inviteCode)
}
}
}
// Reset to input state (used by cancel buttons in select_share/joining/signing screens)
val resetToInput: () -> Unit = {
step = "input"
inviteCode = ""
selectedShareId = null
password = ""
validationError = null
autoJoinAttempted = false
onResetState() // Clear ViewModel state without navigating
}
// Handle session info received (validation success)
LaunchedEffect(signSessionInfo) {
if (signSessionInfo != null && step == "input") {
@ -78,20 +118,6 @@ fun CoSignJoinScreen(
}
}
// Auto-join when we have session info and selected share
LaunchedEffect(step, signSessionInfo, selectedShareId, autoJoinAttempted, isLoading) {
if (step == "select_share" && signSessionInfo != null &&
selectedShareId != null && !autoJoinAttempted && !isLoading && error == null) {
// Check if we should auto-join (matching share found)
val matchingShare = shares.find { it.sessionId == signSessionInfo.keygenSessionId }
if (matchingShare != null && selectedShareId == matchingShare.id) {
autoJoinAttempted = true
step = "joining"
onJoinSign(inviteCode, selectedShareId!!, password)
}
}
}
// Handle session status changes
LaunchedEffect(sessionStatus) {
when (sessionStatus) {
@ -135,7 +161,18 @@ fun CoSignJoinScreen(
}
}
},
onCancel = onCancel
onScanQrCode = {
val options = ScanOptions().apply {
setDesiredBarcodeFormats(ScanOptions.QR_CODE)
setPrompt("扫描签名会话邀请码")
setCameraId(0)
setBeepEnabled(true)
setBarcodeImageEnabled(false)
setOrientationLocked(false)
}
scanLauncher.launch(options)
},
onCancel = onCancel // In input state, cancel navigates away
)
"select_share" -> SelectShareScreen(
shares = shares,
@ -164,15 +201,16 @@ fun CoSignJoinScreen(
onJoinSign(inviteCode, selectedShareId!!, password)
}
}
}
},
onCancel = resetToInput
)
"joining" -> JoiningScreen()
"joining" -> JoiningScreen(onCancel = resetToInput) // Reset to input state, stay on page
"signing" -> SigningProgressScreen(
sessionStatus = sessionStatus,
participants = participants,
currentRound = currentRound,
totalRounds = totalRounds,
onCancel = onCancel
onCancel = resetToInput // Reset to input state, stay on page
)
"completed" -> SigningCompletedScreen(
signature = signature,
@ -190,6 +228,7 @@ private fun InputScreen(
validationError: String?,
onInviteCodeChange: (String) -> Unit,
onValidateCode: () -> Unit,
onScanQrCode: () -> Unit,
onCancel: () -> Unit
) {
Column(
@ -208,13 +247,42 @@ private fun InputScreen(
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "输入邀请码加入签名会话",
text = "扫描二维码或输入邀请码加入签名会话",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(24.dp))
// QR Code Scan Button
OutlinedButton(
onClick = onScanQrCode,
modifier = Modifier.fillMaxWidth(),
enabled = !isLoading
) {
Icon(Icons.Default.QrCodeScanner, contentDescription = null)
Spacer(modifier = Modifier.width(8.dp))
Text("扫描签名邀请码")
}
Spacer(modifier = Modifier.height(16.dp))
// Divider with "or" text
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Divider(modifier = Modifier.weight(1f))
Text(
text = " 或手动输入 ",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Divider(modifier = Modifier.weight(1f))
}
Spacer(modifier = Modifier.height(16.dp))
// Invite Code Input
OutlinedTextField(
value = inviteCode,
@ -335,7 +403,8 @@ private fun SelectShareScreen(
onPasswordChange: (String) -> Unit,
onTogglePassword: () -> Unit,
onBack: () -> Unit,
onJoinSign: () -> Unit
onJoinSign: () -> Unit,
onCancel: () -> Unit
) {
Column(
modifier = Modifier
@ -583,11 +652,19 @@ private fun SelectShareScreen(
Spacer(modifier = Modifier.weight(1f))
// Buttons
// Buttons - Three buttons: Cancel, Back, Join
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp)
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
OutlinedButton(
onClick = onCancel,
modifier = Modifier.weight(1f),
enabled = !isLoading
) {
Text("取消")
}
OutlinedButton(
onClick = onBack,
modifier = Modifier.weight(1f),
@ -620,7 +697,9 @@ private fun SelectShareScreen(
}
@Composable
private fun JoiningScreen() {
private fun JoiningScreen(
onCancel: () -> Unit
) {
Column(
modifier = Modifier
.fillMaxSize()
@ -648,6 +727,14 @@ private fun JoiningScreen() {
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(32.dp))
OutlinedButton(onClick = onCancel) {
Icon(Icons.Default.Cancel, contentDescription = null)
Spacer(modifier = Modifier.width(8.dp))
Text("取消")
}
}
}

View File

@ -50,6 +50,7 @@ fun JoinKeygenScreen(
onValidateInviteCode: (inviteCode: String) -> Unit,
onJoinKeygen: (inviteCode: String, password: String) -> Unit,
onCancel: () -> Unit,
onResetState: () -> Unit = {}, // Reset ViewModel state without navigating
onBackToHome: () -> Unit = {}
) {
var inviteCode by remember { mutableStateOf("") }
@ -101,6 +102,16 @@ fun JoinKeygenScreen(
}
}
// Reset to input state (used by cancel buttons in confirm/joining/progress screens)
// This resets UI state to input screen WITHOUT navigating away from JoinKeygen page
val resetToInput: () -> Unit = {
step = "input"
inviteCode = ""
validationError = null
autoJoinAttempted = false
onResetState() // Clear ViewModel state (sessionInfo, joinToken, etc.) without navigating
}
when (step) {
"input" -> InputScreen(
inviteCode = inviteCode,
@ -117,7 +128,7 @@ fun JoinKeygenScreen(
}
}
},
onCancel = onCancel
onCancel = onCancel // In input state, cancel navigates away
)
"confirm" -> ConfirmScreen(
sessionInfo = sessionInfo,
@ -129,15 +140,16 @@ fun JoinKeygenScreen(
},
onRetry = {
autoJoinAttempted = false
}
},
onCancel = resetToInput // Reset to input state, stay on page
)
"joining" -> JoiningScreen()
"joining" -> JoiningScreen(onCancel = resetToInput) // Reset to input state, stay on page
"progress" -> KeygenProgressScreen(
sessionStatus = sessionStatus,
participants = participants,
currentRound = currentRound,
totalRounds = totalRounds,
onCancel = onCancel
onCancel = resetToInput // Reset to input state, stay on page
)
"completed" -> KeygenCompletedScreen(
publicKey = publicKey,
@ -396,7 +408,8 @@ private fun ConfirmScreen(
isLoading: Boolean,
error: String?,
onBack: () -> Unit,
onRetry: () -> Unit
onRetry: () -> Unit,
onCancel: () -> Unit
) {
Column(
modifier = Modifier
@ -464,10 +477,10 @@ private fun ConfirmScreen(
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
OutlinedButton(
onClick = onBack,
onClick = onCancel,
modifier = Modifier.weight(1f)
) {
Text("返回")
Text("取消")
}
Button(
onClick = onRetry,
@ -484,12 +497,21 @@ private fun ConfirmScreen(
text = "正在自动加入会话...",
style = MaterialTheme.typography.bodyLarge
)
Spacer(modifier = Modifier.height(24.dp))
// Cancel button during auto-join
OutlinedButton(onClick = onCancel) {
Icon(Icons.Default.Cancel, contentDescription = null)
Spacer(modifier = Modifier.width(8.dp))
Text("取消")
}
}
}
}
@Composable
private fun JoiningScreen() {
private fun JoiningScreen(onCancel: () -> Unit) {
Column(
modifier = Modifier
.fillMaxSize()
@ -517,6 +539,15 @@ private fun JoiningScreen() {
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(32.dp))
// Cancel button
OutlinedButton(onClick = onCancel) {
Icon(Icons.Default.Cancel, contentDescription = null)
Spacer(modifier = Modifier.width(8.dp))
Text("取消")
}
}
}

View File

@ -234,7 +234,7 @@ private fun ServiceCheckItem(
.background(
when {
status.message.isEmpty() -> Color.Gray
status.isOnline -> Color(0xFF4CAF50)
status.isOnline -> Color(0xFFD4AF37) // Gold for success
else -> Color(0xFFFF5722)
}
)

View File

@ -614,7 +614,7 @@ private fun SigningScreen(
color = when (sessionStatus) {
SessionStatus.WAITING -> MaterialTheme.colorScheme.tertiaryContainer
SessionStatus.IN_PROGRESS -> MaterialTheme.colorScheme.primaryContainer
SessionStatus.COMPLETED -> Color(0xFF4CAF50)
SessionStatus.COMPLETED -> Color(0xFFD4AF37) // Gold
SessionStatus.FAILED -> MaterialTheme.colorScheme.errorContainer
},
shape = MaterialTheme.shapes.small
@ -747,7 +747,7 @@ private fun SigningScreen(
Icon(
Icons.Default.CheckCircle,
contentDescription = null,
tint = Color(0xFF4CAF50),
tint = Color(0xFFD4AF37), // Gold
modifier = Modifier.size(20.dp)
)
}
@ -832,14 +832,14 @@ private fun BroadcastingScreen(
Surface(
modifier = Modifier.size(80.dp),
shape = MaterialTheme.shapes.extraLarge,
color = Color(0xFF4CAF50)
color = Color(0xFFD4AF37) // Gold
) {
Box(contentAlignment = Alignment.Center) {
Icon(
Icons.Default.Check,
contentDescription = null,
modifier = Modifier.size(40.dp),
tint = Color.White
tint = Color.Black
)
}
}
@ -913,14 +913,14 @@ private fun CompletedScreen(
Surface(
modifier = Modifier.size(100.dp),
shape = MaterialTheme.shapes.extraLarge,
color = Color(0xFF4CAF50)
color = Color(0xFFD4AF37) // Gold
) {
Box(contentAlignment = Alignment.Center) {
Icon(
Icons.Default.CheckCircle,
contentDescription = null,
modifier = Modifier.size(60.dp),
tint = Color.White
tint = Color.Black
)
}
}

View File

@ -83,14 +83,14 @@ fun WalletsScreen(
Icon(
imageVector = if (isConnected) Icons.Default.CheckCircle else Icons.Default.Warning,
contentDescription = if (isConnected) "已连接" else "未连接",
tint = if (isConnected) Color(0xFF4CAF50) else MaterialTheme.colorScheme.error,
tint = if (isConnected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error,
modifier = Modifier.size(20.dp)
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text = if (isConnected) "已连接" else "离线",
style = MaterialTheme.typography.bodySmall,
color = if (isConnected) Color(0xFF4CAF50) else MaterialTheme.colorScheme.error
color = if (isConnected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error
)
}
}

View File

@ -2,6 +2,7 @@ package com.durian.tssparty.presentation.viewmodel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.durian.tssparty.data.repository.JoinKeygenViaGrpcResult
import com.durian.tssparty.data.repository.TssRepository
import com.durian.tssparty.domain.model.*
import com.durian.tssparty.util.TransactionUtils
@ -42,6 +43,9 @@ class MainViewModel @Inject constructor(
init {
// Start initialization on app launch
checkAllServices()
// Setup session event callback (like Electron does after connection)
setupSessionEventCallback()
}
/**
@ -214,7 +218,7 @@ class MainViewModel @Inject constructor(
/**
* Create a new keygen session (initiator)
* Note: password is not needed for creating session in service-party-app
* Matches Electron behavior: creates session via API, then auto-joins via gRPC
*/
fun createKeygenSession(walletName: String, thresholdT: Int, thresholdN: Int, participantName: String) {
viewModelScope.launch {
@ -223,14 +227,16 @@ class MainViewModel @Inject constructor(
val result = repository.createKeygenSession(walletName, thresholdT, thresholdN, participantName)
result.fold(
onSuccess = { inviteCode ->
_createdInviteCode.value = inviteCode
// Parse sessionId from invite code (format: sessionId:joinToken)
val sessionId = inviteCode.split(":").firstOrNull()
_currentSessionId.value = sessionId
onSuccess = { sessionResult ->
_createdInviteCode.value = sessionResult.inviteCode
_currentSessionId.value = sessionResult.sessionId
// Add self as first participant
_sessionParticipants.value = listOf(participantName)
// Store party index for later use
_currentRound.value = 0
_uiState.update { it.copy(isLoading = false) }
android.util.Log.d("MainViewModel", "Keygen session created: sessionId=${sessionResult.sessionId}, partyIndex=${sessionResult.partyIndex}")
},
onFailure = { e ->
_uiState.update { it.copy(isLoading = false, error = e.message) }
@ -239,12 +245,133 @@ class MainViewModel @Inject constructor(
}
}
// Store initiator keygen info
private var initiatorKeygenInfo: InitiatorKeygenInfo? = null
/**
* Setup session event callback (called during initialization)
* This mirrors Electron's behavior of subscribing to events after connection
* Handles events for:
* - Initiator (CreateWallet)
* - Joiner (JoinKeygen)
* - CoSign joiner (参与签名)
*/
private fun setupSessionEventCallback() {
repository.setSessionEventCallback { event ->
android.util.Log.d("MainViewModel", "Session event: ${event.eventType} for session ${event.sessionId}")
when (event.eventType) {
"session_started" -> {
// Check if this is for initiator (CreateWallet)
val currentSessionId = _currentSessionId.value
if (currentSessionId != null && event.sessionId == currentSessionId) {
android.util.Log.d("MainViewModel", "Session started event for initiator, triggering keygen")
viewModelScope.launch {
startKeygenAsInitiator(
sessionId = currentSessionId,
thresholdT = event.thresholdT,
thresholdN = event.thresholdN,
selectedParties = event.selectedParties
)
}
}
// Check if this is for keygen joiner (JoinKeygen)
val joinKeygenInfo = pendingJoinKeygenInfo
if (joinKeygenInfo != null && event.sessionId == joinKeygenInfo.sessionId) {
android.util.Log.d("MainViewModel", "Session started event for keygen joiner, triggering keygen")
startKeygenAsJoiner()
}
// Check if this is for sign joiner (CoSign/参与签名)
val joinSignInfo = pendingJoinSignInfo
if (joinSignInfo != null && event.sessionId == joinSignInfo.sessionId) {
android.util.Log.d("MainViewModel", "Session started event for sign joiner, triggering sign")
startSignAsJoiner()
}
}
"party_joined", "participant_joined" -> {
// Update participant count for initiator's CreateWallet screen
val currentSessionId = _currentSessionId.value
if (currentSessionId != null && event.sessionId == currentSessionId) {
_sessionParticipants.update { current ->
val newParticipant = "参与方 ${current.size + 1}"
current + newParticipant
}
}
// Update participant count for keygen joiner's JoinKeygen screen
val joinKeygenInfo = pendingJoinKeygenInfo
if (joinKeygenInfo != null && event.sessionId == joinKeygenInfo.sessionId) {
_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) {
_coSignParticipants.update { current ->
val newParticipant = "参与方 ${current.size + 1}"
current + newParticipant
}
}
}
"all_joined" -> {
android.util.Log.d("MainViewModel", "All parties joined, protocol will start soon")
}
}
}
}
/**
* Enter session (transition from created to session screen)
* The session event subscription is already active from initialization
*/
fun enterSession() {
// This triggers the session screen to show
// The session status will change to IN_PROGRESS once keygen starts
// Session events are already being listened to via the callback set in init
// This just transitions the UI to the session view
val sessionId = _currentSessionId.value
android.util.Log.d("MainViewModel", "Entering session: $sessionId")
}
/**
* Start keygen process as initiator
*/
private suspend fun startKeygenAsInitiator(
sessionId: String,
thresholdT: Int,
thresholdN: Int,
selectedParties: List<String>
) {
android.util.Log.d("MainViewModel", "Starting keygen as initiator: sessionId=$sessionId, t=$thresholdT, n=$thresholdN")
val result = repository.startKeygenAsInitiator(
sessionId = sessionId,
thresholdT = thresholdT,
thresholdN = thresholdN,
password = "" // No password needed, same as Electron
)
result.fold(
onSuccess = { share ->
_publicKey.value = share.publicKey
_uiState.update {
it.copy(
lastCreatedAddress = share.address,
successMessage = "钱包创建成功!"
)
}
// Update wallet count
_appState.update { state ->
state.copy(walletCount = state.walletCount + 1)
}
},
onFailure = { e ->
_uiState.update { it.copy(error = e.message) }
}
)
}
/**
@ -272,12 +399,14 @@ class MainViewModel @Inject constructor(
private val _joinKeygenPublicKey = MutableStateFlow<String?>(null)
val joinKeygenPublicKey: StateFlow<String?> = _joinKeygenPublicKey.asStateFlow()
// Store invite code and password for auto-join
// Store invite code, joinToken and password for auto-join (matching Electron)
private var pendingInviteCode: String = ""
private var pendingJoinToken: String = ""
private var pendingPassword: String = ""
/**
* Validate invite code and get session info
* Matches Electron's grpc:validateInviteCode - returns sessionInfo + joinToken
*/
fun validateInviteCode(inviteCode: String) {
viewModelScope.launch {
@ -289,6 +418,9 @@ class MainViewModel @Inject constructor(
result.fold(
onSuccess = { validateResult ->
val info = validateResult.sessionInfo
// Store joinToken for later use (matching Electron)
pendingJoinToken = validateResult.joinToken
_joinSessionInfo.value = JoinKeygenSessionInfo(
sessionId = info.sessionId,
walletName = info.walletName,
@ -299,6 +431,8 @@ class MainViewModel @Inject constructor(
totalParticipants = info.totalParticipants
)
_uiState.update { it.copy(isLoading = false) }
android.util.Log.d("MainViewModel", "Validate success: sessionId=${info.sessionId}, joinToken length=${pendingJoinToken.length}")
},
onFailure = { e ->
_uiState.update { it.copy(isLoading = false, error = e.message) }
@ -308,14 +442,91 @@ class MainViewModel @Inject constructor(
}
/**
* Join a keygen session (called after validateInviteCode)
* Join a keygen session (called after validateInviteCode succeeds)
* Matches Electron's grpc:joinSession flow:
* 1. Uses stored sessionInfo and joinToken from validateInviteCode
* 2. Joins via gRPC
* 3. Waits for session_started event to trigger keygen
*/
fun joinKeygen(inviteCode: String, password: String) {
viewModelScope.launch {
val sessionInfo = _joinSessionInfo.value
if (sessionInfo == null) {
_uiState.update { it.copy(error = "会话信息不完整") }
return@launch
}
if (pendingJoinToken.isEmpty()) {
_uiState.update { it.copy(error = "未获取到加入令牌,请重新验证邀请码") }
return@launch
}
_uiState.update { it.copy(isLoading = true, error = null) }
pendingPassword = password
val result = repository.joinKeygenSession(inviteCode, password)
android.util.Log.d("MainViewModel", "Joining keygen session: sessionId=${sessionInfo.sessionId}, joinToken length=${pendingJoinToken.length}")
// Join session via gRPC (matching Electron's grpc:joinSession)
val result = repository.joinKeygenSessionViaGrpc(
sessionId = sessionInfo.sessionId,
joinToken = pendingJoinToken,
walletName = sessionInfo.walletName,
thresholdT = sessionInfo.thresholdT,
thresholdN = sessionInfo.thresholdN
)
result.fold(
onSuccess = { joinResult ->
android.util.Log.d("MainViewModel", "gRPC join success: partyIndex=${joinResult.partyIndex}, sessionStatus=${joinResult.sessionStatus}")
// Store join info for when session_started event arrives
pendingJoinKeygenInfo = PendingJoinKeygenInfo(
sessionId = sessionInfo.sessionId,
partyIndex = joinResult.partyIndex,
thresholdT = sessionInfo.thresholdT,
thresholdN = sessionInfo.thresholdN,
walletName = sessionInfo.walletName,
password = password
)
_uiState.update { it.copy(isLoading = false) }
// If session is already in_progress, trigger keygen immediately (Solution B from Electron)
if (joinResult.sessionStatus == "in_progress") {
android.util.Log.d("MainViewModel", "Session already in_progress, triggering keygen immediately")
startKeygenAsJoiner()
}
// Otherwise, wait for session_started event
},
onFailure = { e ->
android.util.Log.e("MainViewModel", "gRPC join failed", e)
_uiState.update { it.copy(isLoading = false, error = e.message) }
}
)
}
}
// Store pending join keygen info for when session_started event arrives
private var pendingJoinKeygenInfo: PendingJoinKeygenInfo? = null
/**
* Start keygen as joiner (called when session_started event is received)
*/
private fun startKeygenAsJoiner() {
val joinInfo = pendingJoinKeygenInfo ?: return
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}")
val result = repository.executeKeygenAsJoiner(
sessionId = joinInfo.sessionId,
partyIndex = joinInfo.partyIndex,
thresholdT = joinInfo.thresholdT,
thresholdN = joinInfo.thresholdN,
password = joinInfo.password
)
result.fold(
onSuccess = { share ->
@ -331,11 +542,12 @@ class MainViewModel @Inject constructor(
_appState.update { state ->
state.copy(walletCount = state.walletCount + 1)
}
// Clear pending info
pendingJoinKeygenInfo = null
},
onFailure = { e ->
_uiState.update {
it.copy(isLoading = false, error = e.message)
}
android.util.Log.e("MainViewModel", "Keygen execution failed", e)
_uiState.update { it.copy(isLoading = false, error = e.message) }
}
)
}
@ -350,7 +562,9 @@ class MainViewModel @Inject constructor(
_joinKeygenRound.value = 0
_joinKeygenPublicKey.value = null
pendingInviteCode = ""
pendingJoinToken = ""
pendingPassword = ""
pendingJoinKeygenInfo = null
}
// ========== CoSign (Join Sign) State ==========
@ -367,11 +581,14 @@ class MainViewModel @Inject constructor(
private val _coSignSignature = MutableStateFlow<String?>(null)
val coSignSignature: StateFlow<String?> = _coSignSignature.asStateFlow()
// Store pending CoSign data
// Store pending CoSign data (matching Electron's activeCoSignSession)
private var pendingCoSignInviteCode: String = ""
private var pendingCoSignJoinToken: String = ""
private var pendingJoinSignInfo: PendingJoinSignInfo? = null
/**
* Validate sign session invite code
* Matches Electron's cosign:validateInviteCode - returns sessionInfo + joinToken
*/
fun validateSignInviteCode(inviteCode: String) {
viewModelScope.launch {
@ -383,6 +600,9 @@ class MainViewModel @Inject constructor(
result.fold(
onSuccess = { validateResult ->
val info = validateResult.signSessionInfo
// Store joinToken for later use (matching Electron)
pendingCoSignJoinToken = validateResult.joinToken
_coSignSessionInfo.value = CoSignSessionInfo(
sessionId = info.sessionId,
keygenSessionId = info.keygenSessionId,
@ -393,6 +613,8 @@ class MainViewModel @Inject constructor(
currentParticipants = info.currentParticipants
)
_uiState.update { it.copy(isLoading = false) }
android.util.Log.d("MainViewModel", "Validate sign success: sessionId=${info.sessionId}, joinToken length=${pendingCoSignJoinToken.length}")
},
onFailure = { e ->
_uiState.update { it.copy(isLoading = false, error = e.message) }
@ -402,13 +624,95 @@ class MainViewModel @Inject constructor(
}
/**
* Join a sign session
* Join a sign session (called after validateSignInviteCode succeeds)
* Matches Electron's cosign:joinSession flow:
* 1. Uses stored sessionInfo and joinToken from validateSignInviteCode
* 2. Joins via gRPC
* 3. If session is already in_progress, trigger sign immediately
* 4. Otherwise waits for session_started event to trigger sign
*/
fun joinSign(inviteCode: String, shareId: Long, password: String) {
viewModelScope.launch {
val sessionInfo = _coSignSessionInfo.value
if (sessionInfo == null) {
_uiState.update { it.copy(error = "会话信息不完整") }
return@launch
}
if (pendingCoSignJoinToken.isEmpty()) {
_uiState.update { it.copy(error = "未获取到加入令牌,请重新验证邀请码") }
return@launch
}
_uiState.update { it.copy(isLoading = true, error = null) }
val result = repository.joinSignSession(inviteCode, shareId, password)
android.util.Log.d("MainViewModel", "Joining sign session: sessionId=${sessionInfo.sessionId}, joinToken length=${pendingCoSignJoinToken.length}")
// Join session via gRPC (matching Electron's cosign:joinSession)
val result = repository.joinSignSessionViaGrpc(
sessionId = sessionInfo.sessionId,
joinToken = pendingCoSignJoinToken,
shareId = shareId,
password = password,
walletName = sessionInfo.walletName,
messageHash = sessionInfo.messageHash,
thresholdT = sessionInfo.thresholdT,
thresholdN = sessionInfo.thresholdN,
parties = emptyList() // Will use other_parties from gRPC response
)
result.fold(
onSuccess = { joinResult ->
android.util.Log.d("MainViewModel", "gRPC sign join success: partyIndex=${joinResult.partyIndex}, sessionStatus=${joinResult.sessionStatus}")
// Store join info for when session_started event arrives
pendingJoinSignInfo = PendingJoinSignInfo(
sessionId = sessionInfo.sessionId,
partyIndex = joinResult.partyIndex,
thresholdT = sessionInfo.thresholdT,
thresholdN = sessionInfo.thresholdN,
messageHash = sessionInfo.messageHash,
shareId = shareId,
password = password
)
_uiState.update { it.copy(isLoading = false) }
// If session is already in_progress, trigger sign immediately (Solution B from Electron)
if (joinResult.sessionStatus == "in_progress") {
android.util.Log.d("MainViewModel", "Sign session already in_progress, triggering sign immediately")
startSignAsJoiner()
}
// Otherwise, wait for session_started event
},
onFailure = { e ->
android.util.Log.e("MainViewModel", "gRPC sign join failed", e)
_uiState.update { it.copy(isLoading = false, error = e.message) }
}
)
}
}
/**
* Start sign as joiner (called when session_started event is received or session is already in_progress)
*/
private fun startSignAsJoiner() {
val signInfo = pendingJoinSignInfo ?: return
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}")
val result = repository.executeSignAsJoiner(
sessionId = signInfo.sessionId,
partyIndex = signInfo.partyIndex,
thresholdT = signInfo.thresholdT,
thresholdN = signInfo.thresholdN,
messageHash = signInfo.messageHash,
shareId = signInfo.shareId,
password = signInfo.password
)
result.fold(
onSuccess = { signResult ->
@ -420,11 +724,12 @@ class MainViewModel @Inject constructor(
successMessage = "签名完成!"
)
}
// Clear pending info
pendingJoinSignInfo = null
},
onFailure = { e ->
_uiState.update {
it.copy(isLoading = false, error = e.message)
}
android.util.Log.e("MainViewModel", "Sign execution failed", e)
_uiState.update { it.copy(isLoading = false, error = e.message) }
}
)
}
@ -439,6 +744,8 @@ class MainViewModel @Inject constructor(
_coSignRound.value = 0
_coSignSignature.value = null
pendingCoSignInviteCode = ""
pendingCoSignJoinToken = ""
pendingJoinSignInfo = null
}
/**
@ -853,3 +1160,42 @@ data class ConnectionTestResult(
val message: String,
val latency: Long? = null
)
/**
* Initiator keygen info (stored when creating session)
*/
data class InitiatorKeygenInfo(
val sessionId: String,
val partyIndex: Int,
val walletName: String,
val thresholdT: Int,
val thresholdN: Int,
val participantName: String
)
/**
* Pending join keygen info (stored when joining session, waiting for session_started)
* Matches Electron's flow where join happens first, then keygen starts on session_started event
*/
data class PendingJoinKeygenInfo(
val sessionId: String,
val partyIndex: Int,
val thresholdT: Int,
val thresholdN: Int,
val walletName: String,
val password: String
)
/**
* Pending join sign info (stored when joining sign session, waiting for session_started)
* Matches Electron's activeCoSignSession
*/
data class PendingJoinSignInfo(
val sessionId: String,
val partyIndex: Int,
val thresholdT: Int,
val thresholdN: Int,
val messageHash: String,
val shareId: Long,
val password: String
)

View File

@ -12,45 +12,65 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalView
import androidx.core.view.WindowCompat
// Durian Green Theme Colors
private val DurianGreen = Color(0xFF4CAF50)
private val DurianGreenDark = Color(0xFF388E3C)
private val DurianGreenLight = Color(0xFF81C784)
// Dark Gray & Gold Theme Colors
private val Gold = Color(0xFFD4AF37) // Classic gold
private val GoldLight = Color(0xFFFFD966) // Light gold
private val GoldDark = Color(0xFFB8960C) // Dark gold
private val DarkGray = Color(0xFF1A1A1A) // Deep dark gray
private val MediumGray = Color(0xFF2D2D2D) // Medium dark gray
private val LightGray = Color(0xFF3D3D3D) // Lighter gray for surfaces
private val TextGray = Color(0xFFB0B0B0) // Gray for secondary text
private val DarkColorScheme = darkColorScheme(
primary = DurianGreenLight,
primary = Gold,
onPrimary = Color.Black,
primaryContainer = DurianGreenDark,
primaryContainer = GoldDark,
onPrimaryContainer = Color.White,
secondary = Color(0xFFB2DFDB),
secondary = GoldLight,
onSecondary = Color.Black,
background = Color(0xFF121212),
secondaryContainer = LightGray,
onSecondaryContainer = GoldLight,
tertiary = GoldLight,
onTertiary = Color.Black,
background = DarkGray,
onBackground = Color.White,
surface = Color(0xFF1E1E1E),
surface = MediumGray,
onSurface = Color.White,
surfaceVariant = LightGray,
onSurfaceVariant = TextGray,
outline = Color(0xFF5A5A5A),
outlineVariant = Color(0xFF404040),
error = Color(0xFFCF6679),
onError = Color.Black
)
private val LightColorScheme = lightColorScheme(
primary = DurianGreen,
primary = GoldDark,
onPrimary = Color.White,
primaryContainer = DurianGreenLight,
primaryContainer = GoldLight,
onPrimaryContainer = Color.Black,
secondary = Color(0xFF00796B),
onSecondary = Color.White,
background = Color(0xFFFAFAFA),
onBackground = Color.Black,
secondary = Gold,
onSecondary = Color.Black,
secondaryContainer = Color(0xFFFFF3CD),
onSecondaryContainer = GoldDark,
tertiary = GoldDark,
onTertiary = Color.White,
background = Color(0xFFF5F5F5),
onBackground = Color(0xFF2D2D2D),
surface = Color.White,
onSurface = Color.Black,
onSurface = Color(0xFF2D2D2D),
surfaceVariant = Color(0xFFE8E8E8),
onSurfaceVariant = Color(0xFF5A5A5A),
outline = Color(0xFFB0B0B0),
outlineVariant = Color(0xFFD0D0D0),
error = Color(0xFFB00020),
onError = Color.White
)
@Composable
fun TssPartyTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
dynamicColor: Boolean = true,
darkTheme: Boolean = true, // Default to dark theme for dark gray & gold look
dynamicColor: Boolean = false, // Disable dynamic colors to use our custom theme
content: @Composable () -> Unit
) {
val colorScheme = when {
@ -66,7 +86,8 @@ fun TssPartyTheme(
if (!view.isInEditMode) {
SideEffect {
val window = (view.context as Activity).window
window.statusBarColor = colorScheme.primary.toArgb()
// Use dark background color for status bar to match the dark theme
window.statusBarColor = colorScheme.background.toArgb()
WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = !darkTheme
}
}