From fc86af918fa34b897be992409c8ad08f560f67ed Mon Sep 17 00:00:00 2001 From: hailin Date: Thu, 1 Jan 2026 18:51:02 -0800 Subject: [PATCH] feat(android): add 5-minute polling timeout mechanism for keygen/sign MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements Electron's checkAndTriggerKeygen() polling fallback: - Adds polling every 2 seconds with 5-minute timeout - Triggers keygen/sign via synthetic session_started event on in_progress status - Handles gRPC stream disconnection when app goes to background - Shows timeout error in UI via existing error mechanism 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../tssparty/data/repository/TssRepository.kt | 134 ++++++++++++++++++ .../presentation/viewmodel/MainViewModel.kt | 34 ++++- 2 files changed, 167 insertions(+), 1 deletion(-) 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 efa1b742..c44fc64a 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 @@ -59,6 +59,17 @@ class TssRepository @Inject constructor( // Android gRPC streams can disconnect when app goes to background, so we poll as backup private var sessionStatusPollingJob: Job? = null + // Keygen timeout callback (set by ViewModel) + // Called when 5-minute polling timeout is reached without session starting + private var keygenTimeoutCallback: ((String) -> Unit)? = null + + companion object { + // Polling interval for session status check (matching Electron's 2-second interval) + private const val POLL_INTERVAL_MS = 2000L + // Maximum wait time for keygen to start after all parties joined (5 minutes, matching Electron) + private const val MAX_WAIT_MS = 5 * 60 * 1000L + } + /** * Get the current party ID * Note: This will throw UninitializedPropertyAccessException if called before registerParty() @@ -244,6 +255,127 @@ class TssRepository @Inject constructor( sessionEventCallback = callback } + /** + * Set keygen timeout callback (called by ViewModel) + * This callback is invoked when the 5-minute polling timeout is reached + * without the keygen session starting (matching Electron's checkAndTriggerKeygen timeout) + */ + fun setKeygenTimeoutCallback(callback: (String) -> Unit) { + keygenTimeoutCallback = callback + } + + /** + * Start polling session status as fallback mechanism + * This matches Electron's checkAndTriggerKeygen() polling behavior: + * - Polls every 2 seconds + * - Times out after 5 minutes + * - Triggers keygen when session status is "in_progress" + * - Calls timeout callback if keygen doesn't start within 5 minutes + * + * Android needs this because gRPC streams can disconnect when app goes to background, + * causing session_started events to be missed. + * + * @param sessionId The session to poll + * @param sessionType "keygen" or "sign" - determines which callback to invoke + */ + fun startSessionStatusPolling(sessionId: String, sessionType: String = "keygen") { + // Cancel any existing polling job + sessionStatusPollingJob?.cancel() + + android.util.Log.d("TssRepository", "[POLLING] Starting session status polling for $sessionType session: $sessionId") + + sessionStatusPollingJob = CoroutineScope(Dispatchers.IO).launch { + val startTime = System.currentTimeMillis() + var pollCount = 0 + + while (isActive && (System.currentTimeMillis() - startTime) < MAX_WAIT_MS) { + pollCount++ + android.util.Log.d("TssRepository", "[POLLING] Poll #$pollCount for session: $sessionId") + + try { + val statusResult = getSessionStatus(sessionId) + + statusResult.fold( + onSuccess = { status -> + android.util.Log.d("TssRepository", "[POLLING] Session status: ${status.status}, completed: ${status.completedParties}/${status.totalParties}") + + // Check if session is in_progress (all parties joined and ready) + if (status.status == "in_progress") { + android.util.Log.d("TssRepository", "[POLLING] Session is in_progress, triggering $sessionType via callback") + + // Create synthetic session_started event to trigger keygen/sign + // This matches Electron's behavior of checking status and triggering manually + val eventData = SessionEventData( + eventId = "polling_trigger_${System.currentTimeMillis()}", + eventType = "session_started", + sessionId = sessionId, + thresholdN = status.thresholdN, + thresholdT = status.thresholdT, + selectedParties = status.participants.map { it.partyId }, + joinTokens = emptyMap(), + messageHash = null + ) + + // Invoke the session event callback on main thread + withContext(Dispatchers.Main) { + sessionEventCallback?.invoke(eventData) + } + + // Stop polling after triggering + android.util.Log.d("TssRepository", "[POLLING] Triggered $sessionType, stopping polling") + return@launch + } + + // 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") + return@launch + } + + // Check if session already completed (keygen finished) + if (status.status == "completed") { + android.util.Log.d("TssRepository", "[POLLING] Session completed, stopping polling") + return@launch + } + }, + onFailure = { e -> + android.util.Log.w("TssRepository", "[POLLING] Failed to get session status: ${e.message}") + // Continue polling on failure (network hiccup, etc.) + } + ) + } catch (e: Exception) { + android.util.Log.w("TssRepository", "[POLLING] Exception during polling: ${e.message}") + } + + // Wait before next poll + delay(POLL_INTERVAL_MS) + } + + // Timeout reached - keygen didn't start within 5 minutes + if (isActive) { + val elapsedSeconds = (System.currentTimeMillis() - startTime) / 1000 + android.util.Log.e("TssRepository", "[POLLING] Timeout after ${elapsedSeconds}s waiting for $sessionType to start") + + withContext(Dispatchers.Main) { + keygenTimeoutCallback?.invoke("等待 $sessionType 启动超时(5分钟)") + } + } + } + } + + /** + * Stop session status polling + * Called when: + * - Session starts successfully (keygen/sign triggered) + * - Session is cancelled + * - User navigates away + */ + fun stopSessionStatusPolling() { + sessionStatusPollingJob?.cancel() + sessionStatusPollingJob = null + android.util.Log.d("TssRepository", "[POLLING] Session status polling stopped") + } + /** * Get share count for startup check */ @@ -1536,6 +1668,7 @@ class TssRepository @Inject constructor( tssNativeBridge.cancelSession() messageCollectionJob?.cancel() sessionEventJob?.cancel() + stopSessionStatusPolling() // Stop polling when session is cancelled _currentSession.value = null _sessionStatus.value = SessionStatus.WAITING @@ -1553,6 +1686,7 @@ class TssRepository @Inject constructor( */ fun resetSessionStatus() { _sessionStatus.value = SessionStatus.WAITING + stopSessionStatusPolling() // Stop polling when resetting session state } /** 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 398b4855..65c96121 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 @@ -285,6 +285,13 @@ class MainViewModel @Inject constructor( */ private fun setupSessionEventCallback() { android.util.Log.d("MainViewModel", "Setting up session event callback") + + // 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) } + } + repository.setSessionEventCallback { event -> android.util.Log.d("MainViewModel", "=== MainViewModel received session event ===") android.util.Log.d("MainViewModel", " eventType: ${event.eventType}") @@ -360,7 +367,32 @@ class MainViewModel @Inject constructor( } } "all_joined" -> { - android.util.Log.d("MainViewModel", "All parties joined, protocol will start soon") + android.util.Log.d("MainViewModel", "All parties joined, starting session status polling as fallback") + + // Start polling as fallback mechanism (matching Electron's checkAndTriggerKeygen) + // This handles the case where gRPC stream disconnects (e.g., app goes to background) + // and session_started event is missed + val currentSessionId = _currentSessionId.value + val joinKeygenInfo = pendingJoinKeygenInfo + val joinSignInfo = pendingJoinSignInfo + + when { + // Initiator keygen session + currentSessionId != null && event.sessionId == currentSessionId -> { + android.util.Log.d("MainViewModel", "Starting polling for initiator keygen session: $currentSessionId") + repository.startSessionStatusPolling(currentSessionId, "keygen") + } + // Joiner keygen session + joinKeygenInfo != null && event.sessionId == joinKeygenInfo.sessionId -> { + android.util.Log.d("MainViewModel", "Starting polling for joiner keygen session: ${joinKeygenInfo.sessionId}") + repository.startSessionStatusPolling(joinKeygenInfo.sessionId, "keygen") + } + // Sign session + joinSignInfo != null && event.sessionId == joinSignInfo.sessionId -> { + android.util.Log.d("MainViewModel", "Starting polling for sign session: ${joinSignInfo.sessionId}") + repository.startSessionStatusPolling(joinSignInfo.sessionId, "sign") + } + } } } }