From d8be40b8b033fd814071b8d83748946c2fe9bba3 Mon Sep 17 00:00:00 2001 From: hailin Date: Thu, 1 Jan 2026 04:35:00 -0800 Subject: [PATCH] feat(android): update theme to dark gray & gold, fix JoinKeygen/CoSign flows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../java/com/durian/tssparty/MainActivity.kt | 23 + .../durian/tssparty/data/remote/GrpcClient.kt | 6 +- .../tssparty/data/repository/TssRepository.kt | 577 +++++++++++++++++- .../presentation/screens/CoSignJoinScreen.kt | 139 ++++- .../presentation/screens/JoinKeygenScreen.kt | 47 +- .../screens/StartupCheckScreen.kt | 2 +- .../presentation/screens/TransferScreen.kt | 12 +- .../presentation/screens/WalletsScreen.kt | 4 +- .../presentation/viewmodel/MainViewModel.kt | 386 +++++++++++- .../com/durian/tssparty/ui/theme/Theme.kt | 59 +- 10 files changed, 1160 insertions(+), 95 deletions(-) diff --git a/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/MainActivity.kt b/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/MainActivity.kt index e7cdbf43..20717a10 100644 --- a/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/MainActivity.kt +++ b/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/MainActivity.kt @@ -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() diff --git a/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/data/remote/GrpcClient.kt b/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/data/remote/GrpcClient.kt index ec560879..ef774944 100644 --- a/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/data/remote/GrpcClient.kt +++ b/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/data/remote/GrpcClient.kt @@ -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, - val messageHash: String? + val messageHash: String?, + val sessionStatus: String? = null ) /** diff --git a/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/data/repository/TssRepository.kt b/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/data/repository/TssRepository.kt index 67f41c41..8081519c 100644 --- a/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/data/repository/TssRepository.kt +++ b/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/data/repository/TssRepository.kt @@ -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 { + ): Result { 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 { + 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 = 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 + ): Result { + 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 = 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 = 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, diff --git a/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/presentation/screens/CoSignJoinScreen.kt b/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/presentation/screens/CoSignJoinScreen.kt index 404db302..889ccf73 100644 --- a/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/presentation/screens/CoSignJoinScreen.kt +++ b/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/presentation/screens/CoSignJoinScreen.kt @@ -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(null) } var password by remember { mutableStateOf("") } var showPassword by remember { mutableStateOf(false) } var validationError by remember { mutableStateOf(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("取消") + } } } diff --git a/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/presentation/screens/JoinKeygenScreen.kt b/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/presentation/screens/JoinKeygenScreen.kt index ecdaefa2..75594631 100644 --- a/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/presentation/screens/JoinKeygenScreen.kt +++ b/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/presentation/screens/JoinKeygenScreen.kt @@ -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("取消") + } } } diff --git a/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/presentation/screens/StartupCheckScreen.kt b/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/presentation/screens/StartupCheckScreen.kt index 9b08dd45..8f1637c7 100644 --- a/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/presentation/screens/StartupCheckScreen.kt +++ b/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/presentation/screens/StartupCheckScreen.kt @@ -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) } ) diff --git a/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/presentation/screens/TransferScreen.kt b/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/presentation/screens/TransferScreen.kt index 6df900c9..88ec22d9 100644 --- a/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/presentation/screens/TransferScreen.kt +++ b/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/presentation/screens/TransferScreen.kt @@ -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 ) } } diff --git a/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/presentation/screens/WalletsScreen.kt b/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/presentation/screens/WalletsScreen.kt index 0528089f..3d8485e0 100644 --- a/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/presentation/screens/WalletsScreen.kt +++ b/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/presentation/screens/WalletsScreen.kt @@ -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 ) } } diff --git a/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/presentation/viewmodel/MainViewModel.kt b/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/presentation/viewmodel/MainViewModel.kt index 43e74c7e..02016c68 100644 --- a/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/presentation/viewmodel/MainViewModel.kt +++ b/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/presentation/viewmodel/MainViewModel.kt @@ -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 + ) { + 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(null) val joinKeygenPublicKey: StateFlow = _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(null) val coSignSignature: StateFlow = _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 +) diff --git a/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/ui/theme/Theme.kt b/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/ui/theme/Theme.kt index 6ce979c3..349614b6 100644 --- a/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/ui/theme/Theme.kt +++ b/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/ui/theme/Theme.kt @@ -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 } }