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:
parent
2b0920f9b1
commit
d8be40b8b0
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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("取消")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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("取消")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue