feat(android): add 5-minute polling timeout mechanism for keygen/sign

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 <noreply@anthropic.com>
This commit is contained in:
hailin 2026-01-01 18:51:02 -08:00
parent ad79679ee2
commit fc86af918f
2 changed files with 167 additions and 1 deletions

View File

@ -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
}
/**

View File

@ -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")
}
}
}
}
}