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 2f479af5..13855bc0 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 @@ -242,6 +242,7 @@ fun TssPartyApp( currentRound = currentRound, totalRounds = 9, publicKey = publicKey, + countdownSeconds = uiState.countdownSeconds, onCreateSession = { name, t, n, participantName -> viewModel.createKeygenSession(name, t, n, participantName) }, @@ -292,6 +293,7 @@ fun TssPartyApp( currentRound = joinKeygenRound, totalRounds = 9, publicKey = joinKeygenPublicKey, + countdownSeconds = uiState.countdownSeconds, onValidateInviteCode = { inviteCode -> viewModel.validateInviteCode(inviteCode) }, @@ -347,6 +349,7 @@ fun TssPartyApp( currentRound = coSignRound, totalRounds = 9, signature = coSignSignature, + countdownSeconds = uiState.countdownSeconds, onValidateInviteCode = { inviteCode -> viewModel.validateSignInviteCode(inviteCode) }, 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 faaa9ebf..924c238c 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 @@ -63,6 +63,10 @@ class TssRepository @Inject constructor( // Called when 5-minute polling timeout is reached without session starting private var keygenTimeoutCallback: ((String) -> Unit)? = null + // Countdown tick callback (set by ViewModel) + // Called every second with remaining seconds for UI countdown display + private var countdownTickCallback: ((Long) -> Unit)? = null + // Repository-level CoroutineScope for background tasks // Uses SupervisorJob so individual task failures don't cancel other tasks private val repositoryScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) @@ -281,6 +285,15 @@ class TssRepository @Inject constructor( keygenTimeoutCallback = callback } + /** + * Set countdown tick callback (called by ViewModel) + * This callback is invoked every second with the remaining seconds + * for displaying countdown in the UI + */ + fun setCountdownTickCallback(callback: (Long) -> Unit) { + countdownTickCallback = callback + } + /** * Start polling session status as fallback mechanism * This matches Electron's checkAndTriggerKeygen() polling behavior: @@ -301,14 +314,28 @@ class TssRepository @Inject constructor( android.util.Log.d("TssRepository", "[POLLING] Starting session status polling for $sessionType session: $sessionId") + // Notify UI that countdown has started (5 minutes = 300 seconds) + countdownTickCallback?.invoke(MAX_WAIT_MS / 1000) + sessionStatusPollingJob = repositoryScope.launch { val startTime = System.currentTimeMillis() var pollCount = 0 + var lastTickSecond = MAX_WAIT_MS / 1000 while (isActive && (System.currentTimeMillis() - startTime) < MAX_WAIT_MS) { pollCount++ android.util.Log.d("TssRepository", "[POLLING] Poll #$pollCount for session: $sessionId") + // Calculate and emit remaining seconds for countdown display + val elapsedMs = System.currentTimeMillis() - startTime + val remainingSeconds = (MAX_WAIT_MS - elapsedMs) / 1000 + if (remainingSeconds != lastTickSecond) { + lastTickSecond = remainingSeconds + withContext(Dispatchers.Main) { + countdownTickCallback?.invoke(remainingSeconds) + } + } + try { val statusResult = getSessionStatus(sessionId) @@ -320,6 +347,11 @@ class TssRepository @Inject constructor( if (status.status == "in_progress") { android.util.Log.d("TssRepository", "[POLLING] Session is in_progress, triggering $sessionType via callback") + // Clear countdown when session starts + withContext(Dispatchers.Main) { + countdownTickCallback?.invoke(-1L) // -1 indicates countdown stopped (success) + } + // Create synthetic session_started event to trigger keygen/sign // This matches Electron's behavior of checking status and triggering manually val eventData = SessionEventData( @@ -346,12 +378,18 @@ class TssRepository @Inject constructor( // Check if session failed or was cancelled if (status.status == "failed" || status.status == "cancelled") { android.util.Log.d("TssRepository", "[POLLING] Session ${status.status}, stopping polling") + withContext(Dispatchers.Main) { + countdownTickCallback?.invoke(-1L) // Clear countdown + } return@launch } // Check if session already completed (keygen finished) if (status.status == "completed") { android.util.Log.d("TssRepository", "[POLLING] Session completed, stopping polling") + withContext(Dispatchers.Main) { + countdownTickCallback?.invoke(-1L) // Clear countdown + } return@launch } }, @@ -374,6 +412,7 @@ class TssRepository @Inject constructor( android.util.Log.e("TssRepository", "[POLLING] Timeout after ${elapsedSeconds}s waiting for $sessionType to start") withContext(Dispatchers.Main) { + countdownTickCallback?.invoke(0L) // Countdown reached zero keygenTimeoutCallback?.invoke("等待 $sessionType 启动超时(5分钟)") } } @@ -390,6 +429,8 @@ class TssRepository @Inject constructor( fun stopSessionStatusPolling() { sessionStatusPollingJob?.cancel() sessionStatusPollingJob = null + // Clear countdown display when polling is stopped + countdownTickCallback?.invoke(-1L) android.util.Log.d("TssRepository", "[POLLING] Session status polling stopped") } 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 b4dda78a..691b0420 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 @@ -36,6 +36,16 @@ data class SignSessionInfo( val currentParticipants: Int ) +/** + * Format countdown seconds to mm:ss display + */ +private fun formatCountdown(seconds: Long): String { + if (seconds < 0) return "" + val minutes = seconds / 60 + val secs = seconds % 60 + return "%d:%02d".format(minutes, secs) +} + /** * CoSign Join screen matching service-party-app/src/renderer/src/pages/Sign.tsx * Flow: input → select_share → joining → signing → completed @@ -52,6 +62,7 @@ fun CoSignJoinScreen( currentRound: Int = 0, totalRounds: Int = 9, signature: String? = null, + countdownSeconds: Long = -1L, // 5-minute countdown: -1 = not counting, >0 = remaining seconds onValidateInviteCode: (inviteCode: String) -> Unit, onJoinSign: (inviteCode: String, shareId: Long, password: String) -> Unit, onCancel: () -> Unit, @@ -205,12 +216,16 @@ fun CoSignJoinScreen( }, onCancel = resetToInput ) - "joining" -> JoiningScreen(onCancel = resetToInput) // Reset to input state, stay on page + "joining" -> JoiningScreen( + countdownSeconds = countdownSeconds, + onCancel = resetToInput + ) // Reset to input state, stay on page "signing" -> SigningProgressScreen( sessionStatus = sessionStatus, participants = participants, currentRound = currentRound, totalRounds = totalRounds, + countdownSeconds = countdownSeconds, onCancel = resetToInput // Reset to input state, stay on page ) "completed" -> SigningCompletedScreen( @@ -699,6 +714,7 @@ private fun SelectShareScreen( @Composable private fun JoiningScreen( + countdownSeconds: Long = -1L, onCancel: () -> Unit ) { Column( @@ -729,6 +745,35 @@ private fun JoiningScreen( color = MaterialTheme.colorScheme.onSurfaceVariant ) + // Countdown timer (if counting down) + if (countdownSeconds > 0) { + Spacer(modifier = Modifier.height(24.dp)) + Card( + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.tertiaryContainer + ) + ) { + Row( + modifier = Modifier.padding(horizontal = 24.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + Icons.Default.Timer, + contentDescription = null, + tint = MaterialTheme.colorScheme.onTertiaryContainer, + modifier = Modifier.size(20.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = "等待启动: ${formatCountdown(countdownSeconds)}", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onTertiaryContainer + ) + } + } + } + Spacer(modifier = Modifier.height(32.dp)) OutlinedButton(onClick = onCancel) { @@ -745,6 +790,7 @@ private fun SigningProgressScreen( participants: List, currentRound: Int, totalRounds: Int, + countdownSeconds: Long = -1L, onCancel: () -> Unit ) { Column( @@ -804,6 +850,46 @@ private fun SigningProgressScreen( } } + // Countdown timer (if counting down - waiting for sign to start) + if (countdownSeconds > 0) { + Spacer(modifier = Modifier.height(16.dp)) + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.tertiaryContainer + ) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + Icons.Default.Timer, + contentDescription = null, + tint = MaterialTheme.colorScheme.onTertiaryContainer, + modifier = Modifier.size(24.dp) + ) + Spacer(modifier = Modifier.width(12.dp)) + Column { + Text( + text = "等待签名启动", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onTertiaryContainer + ) + Text( + text = formatCountdown(countdownSeconds), + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onTertiaryContainer + ) + } + } + } + } + Spacer(modifier = Modifier.height(16.dp)) // Participants card diff --git a/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/presentation/screens/CreateWalletScreen.kt b/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/presentation/screens/CreateWalletScreen.kt index 0241365a..46b4cd38 100644 --- a/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/presentation/screens/CreateWalletScreen.kt +++ b/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/presentation/screens/CreateWalletScreen.kt @@ -49,6 +49,7 @@ fun CreateWalletScreen( currentRound: Int = 0, totalRounds: Int = 9, publicKey: String? = null, + countdownSeconds: Long = -1L, // 5-minute countdown: -1 = not counting, >0 = remaining seconds onCreateSession: (walletName: String, thresholdT: Int, thresholdN: Int, participantName: String) -> Unit, onCopyInviteCode: () -> Unit, onEnterSession: () -> Unit, @@ -115,6 +116,7 @@ fun CreateWalletScreen( totalRounds = totalRounds, publicKey = publicKey, inviteCode = inviteCode, + countdownSeconds = countdownSeconds, onCopyInviteCode = onCopyInviteCode, onBackToHome = onBackToHome ) @@ -610,6 +612,16 @@ private fun generateQRCodeBitmap(content: String, size: Int): Bitmap? { } } +/** + * Format countdown seconds to mm:ss display + */ +private fun formatCountdown(seconds: Long): String { + if (seconds < 0) return "" + val minutes = seconds / 60 + val secs = seconds % 60 + return "%d:%02d".format(minutes, secs) +} + @Composable private fun SessionScreen( walletName: String, @@ -622,6 +634,7 @@ private fun SessionScreen( totalRounds: Int, publicKey: String?, inviteCode: String?, + countdownSeconds: Long = -1L, onCopyInviteCode: () -> Unit, onBackToHome: () -> Unit ) { @@ -763,6 +776,46 @@ private fun SessionScreen( Spacer(modifier = Modifier.height(16.dp)) } + // Countdown timer (if counting down - waiting for keygen to start after all joined) + if (countdownSeconds > 0) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.tertiaryContainer + ) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + Icons.Default.Timer, + contentDescription = null, + tint = MaterialTheme.colorScheme.onTertiaryContainer, + modifier = Modifier.size(24.dp) + ) + Spacer(modifier = Modifier.width(12.dp)) + Column { + Text( + text = "等待密钥生成启动", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onTertiaryContainer + ) + Text( + text = formatCountdown(countdownSeconds), + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onTertiaryContainer + ) + } + } + } + Spacer(modifier = Modifier.height(16.dp)) + } + // Progress Bar (if in progress) if (sessionStatus == SessionStatus.IN_PROGRESS) { Card( 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 1eeb9393..4e718857 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 @@ -32,6 +32,16 @@ data class JoinSessionInfo( val totalParticipants: Int ) +/** + * Format countdown seconds to mm:ss display + */ +private fun formatCountdown(seconds: Long): String { + if (seconds < 0) return "" + val minutes = seconds / 60 + val secs = seconds % 60 + return "%d:%02d".format(minutes, secs) +} + /** * JoinKeygen screen matching service-party-app/src/renderer/src/pages/Join.tsx * Simplified flow without password: input → confirm → joining @@ -47,6 +57,7 @@ fun JoinKeygenScreen( currentRound: Int = 0, totalRounds: Int = 9, publicKey: String? = null, + countdownSeconds: Long = -1L, // 5-minute countdown: -1 = not counting, >0 = remaining seconds onValidateInviteCode: (inviteCode: String) -> Unit, onJoinKeygen: (inviteCode: String, password: String) -> Unit, onCancel: () -> Unit, @@ -143,12 +154,16 @@ fun JoinKeygenScreen( }, onCancel = resetToInput // Reset to input state, stay on page ) - "joining" -> JoiningScreen(onCancel = resetToInput) // Reset to input state, stay on page + "joining" -> JoiningScreen( + countdownSeconds = countdownSeconds, + onCancel = resetToInput + ) // Reset to input state, stay on page "progress" -> KeygenProgressScreen( sessionStatus = sessionStatus, participants = participants, currentRound = currentRound, totalRounds = totalRounds, + countdownSeconds = countdownSeconds, onCancel = resetToInput // Reset to input state, stay on page ) "completed" -> KeygenCompletedScreen( @@ -512,7 +527,10 @@ private fun ConfirmScreen( } @Composable -private fun JoiningScreen(onCancel: () -> Unit) { +private fun JoiningScreen( + countdownSeconds: Long = -1L, + onCancel: () -> Unit +) { Column( modifier = Modifier .fillMaxSize() @@ -541,6 +559,35 @@ private fun JoiningScreen(onCancel: () -> Unit) { color = MaterialTheme.colorScheme.onSurfaceVariant ) + // Countdown timer (if counting down) + if (countdownSeconds > 0) { + Spacer(modifier = Modifier.height(24.dp)) + Card( + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.tertiaryContainer + ) + ) { + Row( + modifier = Modifier.padding(horizontal = 24.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + Icons.Default.Timer, + contentDescription = null, + tint = MaterialTheme.colorScheme.onTertiaryContainer, + modifier = Modifier.size(20.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = "等待启动: ${formatCountdown(countdownSeconds)}", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onTertiaryContainer + ) + } + } + } + Spacer(modifier = Modifier.height(32.dp)) // Cancel button @@ -558,6 +605,7 @@ private fun KeygenProgressScreen( participants: List, currentRound: Int, totalRounds: Int, + countdownSeconds: Long = -1L, onCancel: () -> Unit ) { Column( @@ -617,6 +665,46 @@ private fun KeygenProgressScreen( } } + // Countdown timer (if counting down - waiting for keygen to start) + if (countdownSeconds > 0) { + Spacer(modifier = Modifier.height(16.dp)) + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.tertiaryContainer + ) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + Icons.Default.Timer, + contentDescription = null, + tint = MaterialTheme.colorScheme.onTertiaryContainer, + modifier = Modifier.size(24.dp) + ) + Spacer(modifier = Modifier.width(12.dp)) + Column { + Text( + text = "等待密钥生成启动", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onTertiaryContainer + ) + Text( + text = formatCountdown(countdownSeconds), + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onTertiaryContainer + ) + } + } + } + } + Spacer(modifier = Modifier.height(16.dp)) // Participants card 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 6800fa59..a995bf91 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 @@ -289,7 +289,13 @@ class MainViewModel @Inject constructor( // Setup keygen timeout callback (matching Electron's 5-minute timeout in checkAndTriggerKeygen) repository.setKeygenTimeoutCallback { errorMessage -> android.util.Log.e("MainViewModel", "Keygen timeout: $errorMessage") - _uiState.update { it.copy(isLoading = false, error = errorMessage) } + _uiState.update { it.copy(isLoading = false, error = errorMessage, countdownSeconds = -1L) } + } + + // Setup countdown tick callback for UI countdown display + repository.setCountdownTickCallback { remainingSeconds -> + android.util.Log.d("MainViewModel", "Countdown tick: $remainingSeconds seconds remaining") + _uiState.update { it.copy(countdownSeconds = remainingSeconds) } } repository.setSessionEventCallback { event -> @@ -1217,7 +1223,10 @@ data class MainUiState( val error: String? = null, val successMessage: String? = null, val lastCreatedAddress: String? = null, - val lastSignature: String? = null + val lastSignature: String? = null, + // 5-minute countdown for keygen/sign startup timeout + // -1 = not counting, 0 = timeout, >0 = remaining seconds + val countdownSeconds: Long = -1L ) /**