feat(android): add 5-minute countdown timer UI for keygen/sign sessions

Displays remaining time during the 5-minute polling timeout:
- Shows countdown in CreateWalletScreen (SessionScreen)
- Shows countdown in JoinKeygenScreen (JoiningScreen, KeygenProgressScreen)
- Shows countdown in CoSignJoinScreen (JoiningScreen, SigningProgressScreen)
- Format: mm:ss with Timer icon in tertiary container card
- Countdown starts on all_joined event and stops on session start/cancel

🤖 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 19:33:20 -08:00
parent b30017f3a7
commit 3576af0f25
6 changed files with 285 additions and 5 deletions

View File

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

View File

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

View File

@ -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<String>,
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

View File

@ -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(

View File

@ -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<String>,
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

View File

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