feat(android): strengthen gRPC connection reliability
Major improvements to Android gRPC client: - Add automatic reconnection with exponential backoff (1s to 30s) - Add heartbeat mechanism with failure detection (30s interval, 3 failures trigger reconnect) - Add stream version tracking to filter stale callbacks - Add channel state monitoring (every 5s) - Add per-call deadline instead of one-time deadline for stubs - Add SharedFlow for connection events (Connected, Disconnected, Reconnecting, Reconnected, PendingMessages) - Add callback exception handling for robustness - Add stream recovery after reconnection via callback mechanism TssRepository changes: - Save message routing params for recovery after reconnect - Expose grpcConnectionEvents SharedFlow for UI notifications - Auto-restore event subscriptions after reconnection Other changes: - Add QR code to Electron Create page for mobile scanning - Auto version increment from version.properties - SettingsScreen shows BuildConfig version info - CreateWalletScreen tracks hasEnteredSession state 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
a3ee831193
commit
444b720f8d
|
|
@ -94,3 +94,6 @@ Thumbs.db
|
||||||
# Signing configs - don't commit
|
# Signing configs - don't commit
|
||||||
signing.properties
|
signing.properties
|
||||||
keystore.properties
|
keystore.properties
|
||||||
|
|
||||||
|
# Auto-generated version file
|
||||||
|
app/version.properties
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
import java.util.Properties
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
id("com.android.application")
|
id("com.android.application")
|
||||||
id("org.jetbrains.kotlin.android")
|
id("org.jetbrains.kotlin.android")
|
||||||
|
|
@ -6,6 +8,19 @@ plugins {
|
||||||
kotlin("kapt")
|
kotlin("kapt")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Auto-increment version code from file
|
||||||
|
val versionFile = file("version.properties")
|
||||||
|
val versionProps = Properties()
|
||||||
|
if (versionFile.exists()) {
|
||||||
|
versionProps.load(versionFile.inputStream())
|
||||||
|
}
|
||||||
|
val autoVersionCode = (versionProps.getProperty("VERSION_CODE")?.toIntOrNull() ?: 0) + 1
|
||||||
|
val autoVersionName = "1.0.${autoVersionCode}"
|
||||||
|
|
||||||
|
// Save new version code
|
||||||
|
versionProps.setProperty("VERSION_CODE", autoVersionCode.toString())
|
||||||
|
versionFile.outputStream().use { versionProps.store(it, "Auto-generated version properties") }
|
||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "com.durian.tssparty"
|
namespace = "com.durian.tssparty"
|
||||||
compileSdk = 34
|
compileSdk = 34
|
||||||
|
|
@ -14,8 +29,8 @@ android {
|
||||||
applicationId = "com.durian.tssparty"
|
applicationId = "com.durian.tssparty"
|
||||||
minSdk = 26
|
minSdk = 26
|
||||||
targetSdk = 34
|
targetSdk = 34
|
||||||
versionCode = 1
|
versionCode = autoVersionCode
|
||||||
versionName = "1.0.0"
|
versionName = autoVersionName
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -67,6 +67,7 @@ fun TssPartyApp(
|
||||||
val sessionParticipants by viewModel.sessionParticipants.collectAsState()
|
val sessionParticipants by viewModel.sessionParticipants.collectAsState()
|
||||||
val currentRound by viewModel.currentRound.collectAsState()
|
val currentRound by viewModel.currentRound.collectAsState()
|
||||||
val publicKey by viewModel.publicKey.collectAsState()
|
val publicKey by viewModel.publicKey.collectAsState()
|
||||||
|
val hasEnteredSession by viewModel.hasEnteredSession.collectAsState()
|
||||||
|
|
||||||
// Transfer state
|
// Transfer state
|
||||||
val preparedTx by viewModel.preparedTx.collectAsState()
|
val preparedTx by viewModel.preparedTx.collectAsState()
|
||||||
|
|
@ -232,6 +233,7 @@ fun TssPartyApp(
|
||||||
inviteCode = createdInviteCode,
|
inviteCode = createdInviteCode,
|
||||||
sessionId = currentSessionId,
|
sessionId = currentSessionId,
|
||||||
sessionStatus = sessionStatus,
|
sessionStatus = sessionStatus,
|
||||||
|
hasEnteredSession = hasEnteredSession,
|
||||||
participants = sessionParticipants,
|
participants = sessionParticipants,
|
||||||
currentRound = currentRound,
|
currentRound = currentRound,
|
||||||
totalRounds = 9,
|
totalRounds = 9,
|
||||||
|
|
|
||||||
|
|
@ -1,64 +1,563 @@
|
||||||
package com.durian.tssparty.data.remote
|
package com.durian.tssparty.data.remote
|
||||||
|
|
||||||
import android.util.Base64
|
import android.util.Base64
|
||||||
|
import android.util.Log
|
||||||
import com.durian.tssparty.domain.model.Participant
|
import com.durian.tssparty.domain.model.Participant
|
||||||
import com.durian.tssparty.grpc.*
|
import com.durian.tssparty.grpc.*
|
||||||
import io.grpc.ManagedChannel
|
import io.grpc.ManagedChannel
|
||||||
import io.grpc.ManagedChannelBuilder
|
import io.grpc.ManagedChannelBuilder
|
||||||
|
import io.grpc.ConnectivityState
|
||||||
import io.grpc.stub.StreamObserver
|
import io.grpc.stub.StreamObserver
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.*
|
||||||
import kotlinx.coroutines.channels.awaitClose
|
import kotlinx.coroutines.channels.awaitClose
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.*
|
||||||
import kotlinx.coroutines.flow.callbackFlow
|
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean
|
||||||
|
import java.util.concurrent.atomic.AtomicInteger
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
import kotlin.math.min
|
||||||
|
import kotlin.math.pow
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Connection state for gRPC client
|
||||||
|
*/
|
||||||
|
sealed class GrpcConnectionState {
|
||||||
|
object Disconnected : GrpcConnectionState()
|
||||||
|
object Connecting : GrpcConnectionState()
|
||||||
|
object Connected : GrpcConnectionState()
|
||||||
|
data class Reconnecting(val attempt: Int, val maxAttempts: Int) : GrpcConnectionState()
|
||||||
|
data class Failed(val reason: String) : GrpcConnectionState()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Connection events for UI notification
|
||||||
|
*/
|
||||||
|
sealed class GrpcConnectionEvent {
|
||||||
|
data class Disconnected(val reason: String) : GrpcConnectionEvent()
|
||||||
|
object Reconnected : GrpcConnectionEvent()
|
||||||
|
data class ReconnectFailed(val reason: String) : GrpcConnectionEvent()
|
||||||
|
data class PendingMessages(val count: Int) : GrpcConnectionEvent()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reconnect configuration
|
||||||
|
*/
|
||||||
|
data class ReconnectConfig(
|
||||||
|
val maxRetries: Int = 10,
|
||||||
|
val initialDelayMs: Long = 1000,
|
||||||
|
val maxDelayMs: Long = 30000,
|
||||||
|
val backoffMultiplier: Double = 2.0
|
||||||
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* gRPC client for Message Router service
|
* gRPC client for Message Router service
|
||||||
|
*
|
||||||
|
* Features:
|
||||||
|
* - Automatic reconnection with exponential backoff
|
||||||
|
* - Heartbeat mechanism with failure detection
|
||||||
|
* - Stream auto-recovery on disconnect
|
||||||
|
* - Connection state notifications via StateFlow
|
||||||
*/
|
*/
|
||||||
@Singleton
|
@Singleton
|
||||||
class GrpcClient @Inject constructor() {
|
class GrpcClient @Inject constructor() {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "GrpcClient"
|
||||||
|
private const val HEARTBEAT_INTERVAL_MS = 30000L
|
||||||
|
private const val MAX_HEARTBEAT_FAILS = 3
|
||||||
|
private const val CONNECTION_TIMEOUT_SECONDS = 10L
|
||||||
|
private const val REQUEST_TIMEOUT_SECONDS = 30L
|
||||||
|
private const val MAX_REQUEST_RETRIES = 3
|
||||||
|
private const val REQUEST_RETRY_DELAY_MS = 500L
|
||||||
|
}
|
||||||
|
|
||||||
private var channel: ManagedChannel? = null
|
private var channel: ManagedChannel? = null
|
||||||
private var stub: MessageRouterGrpc.MessageRouterBlockingStub? = null
|
private var stub: MessageRouterGrpc.MessageRouterBlockingStub? = null
|
||||||
private var asyncStub: MessageRouterGrpc.MessageRouterStub? = null
|
private var asyncStub: MessageRouterGrpc.MessageRouterStub? = null
|
||||||
|
|
||||||
|
// Connection state
|
||||||
|
private val _connectionState = MutableStateFlow<GrpcConnectionState>(GrpcConnectionState.Disconnected)
|
||||||
|
val connectionState: StateFlow<GrpcConnectionState> = _connectionState.asStateFlow()
|
||||||
|
|
||||||
|
// Connection events (for UI notifications)
|
||||||
|
private val _connectionEvents = MutableSharedFlow<GrpcConnectionEvent>(extraBufferCapacity = 10)
|
||||||
|
val connectionEvents: SharedFlow<GrpcConnectionEvent> = _connectionEvents.asSharedFlow()
|
||||||
|
|
||||||
|
// Reconnection state
|
||||||
|
private val reconnectConfig = ReconnectConfig()
|
||||||
|
private var currentHost: String? = null
|
||||||
|
private var currentPort: Int? = null
|
||||||
|
private val isReconnecting = AtomicBoolean(false)
|
||||||
|
private val reconnectAttempts = AtomicInteger(0)
|
||||||
|
private val shouldReconnect = AtomicBoolean(true)
|
||||||
|
private var reconnectJob: Job? = null
|
||||||
|
|
||||||
|
// Registration state (for re-registration after reconnect)
|
||||||
|
private var registeredPartyId: String? = null
|
||||||
|
private var registeredPartyRole: String? = null
|
||||||
|
|
||||||
|
// Heartbeat state
|
||||||
|
private var heartbeatJob: Job? = null
|
||||||
|
private val heartbeatFailCount = AtomicInteger(0)
|
||||||
|
|
||||||
|
// Stream subscriptions (for recovery after reconnect)
|
||||||
|
private var activeMessageSubscription: MessageSubscription? = null
|
||||||
|
private var eventStreamSubscribed = AtomicBoolean(false)
|
||||||
|
private var eventStreamPartyId: String? = null
|
||||||
|
|
||||||
|
// Stream version tracking (to detect stale stream events)
|
||||||
|
private val messageStreamVersion = AtomicInteger(0)
|
||||||
|
private val eventStreamVersion = AtomicInteger(0)
|
||||||
|
|
||||||
|
// Reconnection callback - called when streams need to be re-established
|
||||||
|
private var onReconnectedCallback: (() -> Unit)? = null
|
||||||
|
|
||||||
|
// Channel state monitoring
|
||||||
|
private var channelStateMonitorJob: Job? = null
|
||||||
|
|
||||||
|
// Coroutine scope for background tasks
|
||||||
|
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Connect to the Message Router server
|
* Connect to the Message Router server
|
||||||
*/
|
*/
|
||||||
fun connect(host: String, port: Int) {
|
fun connect(host: String, port: Int) {
|
||||||
disconnect()
|
// Save connection params for reconnection
|
||||||
|
currentHost = host
|
||||||
|
currentPort = port
|
||||||
|
shouldReconnect.set(true)
|
||||||
|
reconnectAttempts.set(0)
|
||||||
|
|
||||||
val builder = ManagedChannelBuilder
|
doConnect(host, port)
|
||||||
.forAddress(host, port)
|
}
|
||||||
.keepAliveTime(30, TimeUnit.SECONDS)
|
|
||||||
.keepAliveTimeout(10, TimeUnit.SECONDS)
|
|
||||||
|
|
||||||
// Use TLS for port 443, plaintext for other ports (like local development)
|
private fun doConnect(host: String, port: Int) {
|
||||||
if (port == 443) {
|
Log.d(TAG, "Connecting to $host:$port")
|
||||||
builder.useTransportSecurity()
|
_connectionState.value = GrpcConnectionState.Connecting
|
||||||
} else {
|
|
||||||
builder.usePlaintext()
|
try {
|
||||||
|
// Disconnect existing connection
|
||||||
|
cleanupConnection()
|
||||||
|
|
||||||
|
val builder = ManagedChannelBuilder
|
||||||
|
.forAddress(host, port)
|
||||||
|
.keepAliveTime(30, TimeUnit.SECONDS)
|
||||||
|
.keepAliveTimeout(10, TimeUnit.SECONDS)
|
||||||
|
.keepAliveWithoutCalls(true)
|
||||||
|
.idleTimeout(5, TimeUnit.MINUTES)
|
||||||
|
|
||||||
|
// Use TLS for port 443, plaintext for other ports (like local development)
|
||||||
|
if (port == 443) {
|
||||||
|
builder.useTransportSecurity()
|
||||||
|
} else {
|
||||||
|
builder.usePlaintext()
|
||||||
|
}
|
||||||
|
|
||||||
|
channel = builder.build()
|
||||||
|
|
||||||
|
// Create stubs (deadline set per-call via getStubWithDeadline())
|
||||||
|
stub = MessageRouterGrpc.newBlockingStub(channel)
|
||||||
|
asyncStub = MessageRouterGrpc.newStub(channel)
|
||||||
|
|
||||||
|
// Wait for connection to be ready
|
||||||
|
scope.launch {
|
||||||
|
waitForConnection()
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Connection failed: ${e.message}")
|
||||||
|
_connectionState.value = GrpcConnectionState.Failed(e.message ?: "Unknown error")
|
||||||
|
triggerReconnect("Connection failed: ${e.message}")
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
channel = builder.build()
|
private suspend fun waitForConnection() {
|
||||||
|
val ch = channel ?: return
|
||||||
|
|
||||||
stub = MessageRouterGrpc.newBlockingStub(channel)
|
try {
|
||||||
asyncStub = MessageRouterGrpc.newStub(channel)
|
withTimeout(CONNECTION_TIMEOUT_SECONDS * 1000) {
|
||||||
|
while (true) {
|
||||||
|
val state = ch.getState(true)
|
||||||
|
Log.d(TAG, "Channel state: $state")
|
||||||
|
|
||||||
|
when (state) {
|
||||||
|
ConnectivityState.READY -> {
|
||||||
|
Log.d(TAG, "Connected successfully")
|
||||||
|
_connectionState.value = GrpcConnectionState.Connected
|
||||||
|
reconnectAttempts.set(0)
|
||||||
|
heartbeatFailCount.set(0)
|
||||||
|
isReconnecting.set(false)
|
||||||
|
|
||||||
|
// Start channel state monitoring
|
||||||
|
startChannelStateMonitor()
|
||||||
|
|
||||||
|
// Re-register if we were registered before
|
||||||
|
reRegisterIfNeeded()
|
||||||
|
|
||||||
|
// Restart heartbeat
|
||||||
|
startHeartbeat()
|
||||||
|
|
||||||
|
// Re-subscribe to streams
|
||||||
|
reSubscribeStreams()
|
||||||
|
|
||||||
|
return@withTimeout
|
||||||
|
}
|
||||||
|
ConnectivityState.TRANSIENT_FAILURE, ConnectivityState.SHUTDOWN -> {
|
||||||
|
throw Exception("Connection failed with state: $state")
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
delay(100)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Wait for connection failed: ${e.message}")
|
||||||
|
_connectionState.value = GrpcConnectionState.Failed(e.message ?: "Connection timeout")
|
||||||
|
triggerReconnect("Connection timeout")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Disconnect from the server
|
* Disconnect from the server (won't auto-reconnect)
|
||||||
*/
|
*/
|
||||||
fun disconnect() {
|
fun disconnect() {
|
||||||
channel?.shutdown()
|
Log.d(TAG, "Disconnecting")
|
||||||
|
shouldReconnect.set(false)
|
||||||
|
cleanupConnection()
|
||||||
|
_connectionState.value = GrpcConnectionState.Disconnected
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cleanup all connection resources
|
||||||
|
*/
|
||||||
|
private fun cleanupConnection() {
|
||||||
|
// Cancel reconnect job
|
||||||
|
reconnectJob?.cancel()
|
||||||
|
reconnectJob = null
|
||||||
|
|
||||||
|
// Stop channel state monitor
|
||||||
|
channelStateMonitorJob?.cancel()
|
||||||
|
channelStateMonitorJob = null
|
||||||
|
|
||||||
|
// Stop heartbeat
|
||||||
|
stopHeartbeat()
|
||||||
|
|
||||||
|
// Increment stream versions to invalidate old stream callbacks
|
||||||
|
messageStreamVersion.incrementAndGet()
|
||||||
|
eventStreamVersion.incrementAndGet()
|
||||||
|
|
||||||
|
// Shutdown channel
|
||||||
|
channel?.let { ch ->
|
||||||
|
try {
|
||||||
|
ch.shutdown()
|
||||||
|
val terminated = ch.awaitTermination(2, TimeUnit.SECONDS)
|
||||||
|
if (!terminated) {
|
||||||
|
ch.shutdownNow()
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Error shutting down channel: ${e.message}")
|
||||||
|
}
|
||||||
|
Unit
|
||||||
|
}
|
||||||
channel = null
|
channel = null
|
||||||
stub = null
|
stub = null
|
||||||
asyncStub = null
|
asyncStub = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start monitoring channel state for disconnections
|
||||||
|
*/
|
||||||
|
private fun startChannelStateMonitor() {
|
||||||
|
channelStateMonitorJob?.cancel()
|
||||||
|
channelStateMonitorJob = scope.launch {
|
||||||
|
val ch = channel ?: return@launch
|
||||||
|
var lastState = ConnectivityState.READY
|
||||||
|
|
||||||
|
while (isActive && _connectionState.value == GrpcConnectionState.Connected) {
|
||||||
|
delay(5000) // Check every 5 seconds
|
||||||
|
|
||||||
|
try {
|
||||||
|
val currentState = ch.getState(false)
|
||||||
|
if (currentState != lastState) {
|
||||||
|
Log.d(TAG, "Channel state changed: $lastState -> $currentState")
|
||||||
|
lastState = currentState
|
||||||
|
|
||||||
|
when (currentState) {
|
||||||
|
ConnectivityState.TRANSIENT_FAILURE, ConnectivityState.SHUTDOWN -> {
|
||||||
|
Log.w(TAG, "Channel entered failure state: $currentState")
|
||||||
|
triggerReconnect("Channel state: $currentState")
|
||||||
|
}
|
||||||
|
ConnectivityState.IDLE -> {
|
||||||
|
// Try to reconnect if idle
|
||||||
|
Log.d(TAG, "Channel idle, requesting connection")
|
||||||
|
ch.getState(true) // Request connection
|
||||||
|
}
|
||||||
|
else -> { /* READY, CONNECTING - OK */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Error checking channel state: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trigger reconnection with exponential backoff
|
||||||
|
*/
|
||||||
|
private fun triggerReconnect(reason: String) {
|
||||||
|
if (!shouldReconnect.get() || isReconnecting.getAndSet(true)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val host = currentHost
|
||||||
|
val port = currentPort
|
||||||
|
if (host == null || port == null) {
|
||||||
|
isReconnecting.set(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
Log.d(TAG, "Triggering reconnect: $reason")
|
||||||
|
|
||||||
|
// Emit disconnected event
|
||||||
|
_connectionEvents.tryEmit(GrpcConnectionEvent.Disconnected(reason))
|
||||||
|
|
||||||
|
reconnectJob?.cancel()
|
||||||
|
reconnectJob = scope.launch {
|
||||||
|
val attempt = reconnectAttempts.incrementAndGet()
|
||||||
|
|
||||||
|
if (attempt > reconnectConfig.maxRetries) {
|
||||||
|
Log.e(TAG, "Max reconnect attempts reached")
|
||||||
|
_connectionState.value = GrpcConnectionState.Failed("Max retries exceeded")
|
||||||
|
_connectionEvents.tryEmit(GrpcConnectionEvent.ReconnectFailed("Max retries exceeded"))
|
||||||
|
isReconnecting.set(false)
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
|
||||||
|
_connectionState.value = GrpcConnectionState.Reconnecting(attempt, reconnectConfig.maxRetries)
|
||||||
|
|
||||||
|
// Calculate delay with exponential backoff
|
||||||
|
val delay = min(
|
||||||
|
(reconnectConfig.initialDelayMs * reconnectConfig.backoffMultiplier.pow(attempt - 1.0)).toLong(),
|
||||||
|
reconnectConfig.maxDelayMs
|
||||||
|
)
|
||||||
|
|
||||||
|
Log.d(TAG, "Reconnecting in ${delay}ms (attempt $attempt/${reconnectConfig.maxRetries})")
|
||||||
|
delay(delay)
|
||||||
|
|
||||||
|
isReconnecting.set(false)
|
||||||
|
doConnect(host, port)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start heartbeat mechanism
|
||||||
|
*/
|
||||||
|
private fun startHeartbeat() {
|
||||||
|
stopHeartbeat()
|
||||||
|
|
||||||
|
val partyId = registeredPartyId ?: return
|
||||||
|
|
||||||
|
heartbeatJob = scope.launch {
|
||||||
|
while (isActive && _connectionState.value == GrpcConnectionState.Connected) {
|
||||||
|
delay(HEARTBEAT_INTERVAL_MS)
|
||||||
|
|
||||||
|
try {
|
||||||
|
val request = HeartbeatRequest.newBuilder()
|
||||||
|
.setPartyId(partyId)
|
||||||
|
.setTimestamp(System.currentTimeMillis())
|
||||||
|
.build()
|
||||||
|
|
||||||
|
val response = withContext(Dispatchers.IO) {
|
||||||
|
getStubWithDeadline()?.heartbeat(request)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response != null) {
|
||||||
|
heartbeatFailCount.set(0)
|
||||||
|
val pending = response.pendingMessages
|
||||||
|
Log.d(TAG, "Heartbeat OK, pending messages: $pending")
|
||||||
|
|
||||||
|
// Notify if there are pending messages (may have missed events)
|
||||||
|
if (pending > 0) {
|
||||||
|
Log.w(TAG, "Has $pending pending messages - may have missed events")
|
||||||
|
_connectionEvents.tryEmit(GrpcConnectionEvent.PendingMessages(pending))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
handleHeartbeatFailure("No response")
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
handleHeartbeatFailure(e.message ?: "Unknown error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleHeartbeatFailure(reason: String) {
|
||||||
|
val fails = heartbeatFailCount.incrementAndGet()
|
||||||
|
Log.w(TAG, "Heartbeat failed ($fails/$MAX_HEARTBEAT_FAILS): $reason")
|
||||||
|
|
||||||
|
if (fails >= MAX_HEARTBEAT_FAILS) {
|
||||||
|
Log.e(TAG, "Too many heartbeat failures, triggering reconnect")
|
||||||
|
triggerReconnect("Heartbeat failed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun stopHeartbeat() {
|
||||||
|
heartbeatJob?.cancel()
|
||||||
|
heartbeatJob = null
|
||||||
|
heartbeatFailCount.set(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-register party after reconnection
|
||||||
|
*/
|
||||||
|
private suspend fun reRegisterIfNeeded() {
|
||||||
|
val partyId = registeredPartyId
|
||||||
|
val role = registeredPartyRole
|
||||||
|
|
||||||
|
if (partyId != null && role != null) {
|
||||||
|
Log.d(TAG, "Re-registering party: $partyId")
|
||||||
|
try {
|
||||||
|
registerPartyInternal(partyId, role, "1.0.0")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Re-registration failed: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wait for channel to be ready (with timeout)
|
||||||
|
*/
|
||||||
|
private suspend fun waitForChannelReady(timeoutMs: Long = 2000): Boolean {
|
||||||
|
val ch = channel ?: return false
|
||||||
|
val startTime = System.currentTimeMillis()
|
||||||
|
|
||||||
|
while (System.currentTimeMillis() - startTime < timeoutMs) {
|
||||||
|
if (ch.getState(false) == ConnectivityState.READY) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
delay(50)
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-subscribe to streams after reconnection
|
||||||
|
* Notifies the repository layer to re-establish message/event subscriptions
|
||||||
|
*/
|
||||||
|
private fun reSubscribeStreams() {
|
||||||
|
val needsResubscribe = eventStreamSubscribed.get() || activeMessageSubscription != null
|
||||||
|
|
||||||
|
if (needsResubscribe) {
|
||||||
|
Log.d(TAG, "Triggering stream re-subscription callback")
|
||||||
|
Log.d(TAG, " - Event stream: ${eventStreamSubscribed.get()}, partyId: $eventStreamPartyId")
|
||||||
|
Log.d(TAG, " - Message stream: ${activeMessageSubscription?.sessionId}")
|
||||||
|
|
||||||
|
// Notify repository to re-establish streams
|
||||||
|
scope.launch {
|
||||||
|
// Wait for channel to be fully ready instead of fixed delay
|
||||||
|
if (waitForChannelReady()) {
|
||||||
|
try {
|
||||||
|
onReconnectedCallback?.invoke()
|
||||||
|
// Emit reconnected event
|
||||||
|
_connectionEvents.tryEmit(GrpcConnectionEvent.Reconnected)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Reconnect callback failed: ${e.message}")
|
||||||
|
// Don't let callback failure affect the connection state
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Log.w(TAG, "Channel not ready for stream re-subscription, skipping")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// No streams to restore, still emit reconnected event
|
||||||
|
_connectionEvents.tryEmit(GrpcConnectionEvent.Reconnected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set callback for reconnection events
|
||||||
|
* Called when connection is restored and streams need to be re-established
|
||||||
|
*/
|
||||||
|
fun setOnReconnectedCallback(callback: () -> Unit) {
|
||||||
|
onReconnectedCallback = callback
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get active message subscription info (for recovery)
|
||||||
|
*/
|
||||||
|
fun getActiveMessageSubscription(): Pair<String, String>? {
|
||||||
|
return activeMessageSubscription?.let { Pair(it.sessionId, it.partyId) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if event stream was subscribed (for recovery)
|
||||||
|
*/
|
||||||
|
fun wasEventStreamSubscribed(): Boolean = eventStreamSubscribed.get()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get event stream party ID (for recovery)
|
||||||
|
*/
|
||||||
|
fun getEventStreamPartyId(): String? = eventStreamPartyId
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if connected
|
||||||
|
*/
|
||||||
|
fun isConnected(): Boolean = _connectionState.value == GrpcConnectionState.Connected
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get stub with per-call deadline (fixes timeout issue)
|
||||||
|
*/
|
||||||
|
private fun getStubWithDeadline(): MessageRouterGrpc.MessageRouterBlockingStub? {
|
||||||
|
return stub?.withDeadlineAfter(REQUEST_TIMEOUT_SECONDS, TimeUnit.SECONDS)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute an RPC call with retry logic for transient failures
|
||||||
|
*/
|
||||||
|
private suspend fun <T> withRetry(
|
||||||
|
operation: String,
|
||||||
|
maxRetries: Int = MAX_REQUEST_RETRIES,
|
||||||
|
block: suspend () -> T
|
||||||
|
): T {
|
||||||
|
var lastException: Exception? = null
|
||||||
|
|
||||||
|
repeat(maxRetries) { attempt ->
|
||||||
|
try {
|
||||||
|
return block()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
lastException = e
|
||||||
|
val isRetryable = isRetryableError(e)
|
||||||
|
|
||||||
|
if (attempt < maxRetries - 1 && isRetryable) {
|
||||||
|
val delay = REQUEST_RETRY_DELAY_MS * (attempt + 1)
|
||||||
|
Log.w(TAG, "$operation failed (attempt ${attempt + 1}/$maxRetries), retrying in ${delay}ms: ${e.message}")
|
||||||
|
delay(delay)
|
||||||
|
} else {
|
||||||
|
Log.e(TAG, "$operation failed after ${attempt + 1} attempts: ${e.message}")
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw lastException ?: Exception("$operation failed after $maxRetries attempts")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if an error is retryable (transient network issues)
|
||||||
|
*/
|
||||||
|
private fun isRetryableError(e: Exception): Boolean {
|
||||||
|
val message = e.message.orEmpty().lowercase()
|
||||||
|
return message.contains("unavailable") ||
|
||||||
|
message.contains("deadline exceeded") ||
|
||||||
|
message.contains("connection") ||
|
||||||
|
message.contains("timeout") ||
|
||||||
|
message.contains("reset") ||
|
||||||
|
message.contains("broken pipe")
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Register party with the router
|
* Register party with the router
|
||||||
*/
|
*/
|
||||||
|
|
@ -67,16 +566,38 @@ class GrpcClient @Inject constructor() {
|
||||||
partyRole: String = "temporary",
|
partyRole: String = "temporary",
|
||||||
version: String = "1.0.0"
|
version: String = "1.0.0"
|
||||||
): Result<Boolean> = withContext(Dispatchers.IO) {
|
): Result<Boolean> = withContext(Dispatchers.IO) {
|
||||||
try {
|
// Save for re-registration
|
||||||
val request = RegisterPartyRequest.newBuilder()
|
registeredPartyId = partyId
|
||||||
.setPartyId(partyId)
|
registeredPartyRole = partyRole
|
||||||
.setPartyRole(partyRole)
|
|
||||||
.setVersion(version)
|
|
||||||
.build()
|
|
||||||
|
|
||||||
val response = stub?.registerParty(request)
|
registerPartyInternal(partyId, partyRole, version)
|
||||||
Result.success(response?.success ?: false)
|
}
|
||||||
|
|
||||||
|
private suspend fun registerPartyInternal(
|
||||||
|
partyId: String,
|
||||||
|
partyRole: String,
|
||||||
|
version: String
|
||||||
|
): Result<Boolean> = withContext(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
withRetry("RegisterParty") {
|
||||||
|
val request = RegisterPartyRequest.newBuilder()
|
||||||
|
.setPartyId(partyId)
|
||||||
|
.setPartyRole(partyRole)
|
||||||
|
.setVersion(version)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
val response = getStubWithDeadline()?.registerParty(request)
|
||||||
|
if (response?.success == true) {
|
||||||
|
Log.d(TAG, "Party registered: $partyId")
|
||||||
|
startHeartbeat()
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
throw Exception("Registration failed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Result.success(true)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Register party failed: ${e.message}")
|
||||||
Result.failure(e)
|
Result.failure(e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -90,21 +611,21 @@ class GrpcClient @Inject constructor() {
|
||||||
joinToken: String
|
joinToken: String
|
||||||
): Result<JoinSessionData> = withContext(Dispatchers.IO) {
|
): Result<JoinSessionData> = withContext(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
// Match Electron behavior: don't send device_info
|
val data = withRetry("JoinSession") {
|
||||||
val request = JoinSessionRequest.newBuilder()
|
// Match Electron behavior: don't send device_info
|
||||||
.setSessionId(sessionId)
|
val request = JoinSessionRequest.newBuilder()
|
||||||
.setPartyId(partyId)
|
.setSessionId(sessionId)
|
||||||
.setJoinToken(joinToken)
|
.setPartyId(partyId)
|
||||||
.build()
|
.setJoinToken(joinToken)
|
||||||
|
.build()
|
||||||
|
|
||||||
val response = stub?.joinSession(request)
|
val response = getStubWithDeadline()?.joinSession(request)
|
||||||
if (response?.success == true) {
|
if (response?.success == true) {
|
||||||
val sessionInfo = response.sessionInfo
|
val sessionInfo = response.sessionInfo
|
||||||
val participants = response.otherPartiesList.map { party ->
|
val participants = response.otherPartiesList.map { party ->
|
||||||
Participant(party.partyId, party.partyIndex)
|
Participant(party.partyId, party.partyIndex)
|
||||||
}
|
}
|
||||||
|
|
||||||
Result.success(
|
|
||||||
JoinSessionData(
|
JoinSessionData(
|
||||||
sessionId = sessionInfo.sessionId,
|
sessionId = sessionInfo.sessionId,
|
||||||
sessionType = sessionInfo.sessionType,
|
sessionType = sessionInfo.sessionType,
|
||||||
|
|
@ -116,11 +637,13 @@ class GrpcClient @Inject constructor() {
|
||||||
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
|
sessionStatus = if (sessionInfo.status.isNullOrEmpty()) null else sessionInfo.status
|
||||||
)
|
)
|
||||||
)
|
} else {
|
||||||
} else {
|
throw Exception("Failed to join session")
|
||||||
Result.failure(Exception("Failed to join session"))
|
}
|
||||||
}
|
}
|
||||||
|
Result.success(data)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Join session failed: ${e.message}")
|
||||||
Result.failure(e)
|
Result.failure(e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -138,9 +661,10 @@ class GrpcClient @Inject constructor() {
|
||||||
.setPartyId(partyId)
|
.setPartyId(partyId)
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
val response = stub?.markPartyReady(request)
|
val response = getStubWithDeadline()?.markPartyReady(request)
|
||||||
Result.success(response?.allReady ?: false)
|
Result.success(response?.allReady ?: false)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Mark party ready failed: ${e.message}")
|
||||||
Result.failure(e)
|
Result.failure(e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -157,30 +681,40 @@ class GrpcClient @Inject constructor() {
|
||||||
payload: ByteArray
|
payload: ByteArray
|
||||||
): Result<String> = withContext(Dispatchers.IO) {
|
): Result<String> = withContext(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
val request = RouteMessageRequest.newBuilder()
|
val messageId = withRetry("RouteMessage") {
|
||||||
.setSessionId(sessionId)
|
val request = RouteMessageRequest.newBuilder()
|
||||||
.setFromParty(fromParty)
|
.setSessionId(sessionId)
|
||||||
.addAllToParties(toParties)
|
.setFromParty(fromParty)
|
||||||
.setRoundNumber(roundNumber)
|
.addAllToParties(toParties)
|
||||||
.setMessageType(messageType)
|
.setRoundNumber(roundNumber)
|
||||||
.setPayload(com.google.protobuf.ByteString.copyFrom(payload))
|
.setMessageType(messageType)
|
||||||
.build()
|
.setPayload(com.google.protobuf.ByteString.copyFrom(payload))
|
||||||
|
.build()
|
||||||
|
|
||||||
val response = stub?.routeMessage(request)
|
val response = getStubWithDeadline()?.routeMessage(request)
|
||||||
if (response?.success == true) {
|
if (response?.success == true) {
|
||||||
Result.success(response.messageId)
|
response.messageId
|
||||||
} else {
|
} else {
|
||||||
Result.failure(Exception("Failed to route message"))
|
throw Exception("Failed to route message")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
Result.success(messageId)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Route message failed: ${e.message}")
|
||||||
Result.failure(e)
|
Result.failure(e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Subscribe to messages for a party
|
* Subscribe to messages for a party (with auto-recovery)
|
||||||
*/
|
*/
|
||||||
fun subscribeMessages(sessionId: String, partyId: String): Flow<IncomingMessage> = callbackFlow {
|
fun subscribeMessages(sessionId: String, partyId: String): Flow<IncomingMessage> = callbackFlow {
|
||||||
|
// Save subscription for recovery
|
||||||
|
activeMessageSubscription = MessageSubscription(sessionId, partyId)
|
||||||
|
|
||||||
|
// Capture current stream version to detect stale callbacks
|
||||||
|
val streamVersion = messageStreamVersion.incrementAndGet()
|
||||||
|
|
||||||
val request = SubscribeMessagesRequest.newBuilder()
|
val request = SubscribeMessagesRequest.newBuilder()
|
||||||
.setSessionId(sessionId)
|
.setSessionId(sessionId)
|
||||||
.setPartyId(partyId)
|
.setPartyId(partyId)
|
||||||
|
|
@ -188,6 +722,12 @@ class GrpcClient @Inject constructor() {
|
||||||
|
|
||||||
val observer = object : StreamObserver<MPCMessage> {
|
val observer = object : StreamObserver<MPCMessage> {
|
||||||
override fun onNext(message: MPCMessage) {
|
override fun onNext(message: MPCMessage) {
|
||||||
|
// Ignore events from stale streams
|
||||||
|
if (messageStreamVersion.get() != streamVersion) {
|
||||||
|
Log.d(TAG, "Ignoring message from stale stream (v$streamVersion, current v${messageStreamVersion.get()})")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
val incoming = IncomingMessage(
|
val incoming = IncomingMessage(
|
||||||
messageId = message.messageId,
|
messageId = message.messageId,
|
||||||
fromParty = message.fromParty,
|
fromParty = message.fromParty,
|
||||||
|
|
@ -199,29 +739,71 @@ class GrpcClient @Inject constructor() {
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onError(t: Throwable) {
|
override fun onError(t: Throwable) {
|
||||||
|
Log.e(TAG, "Message stream error: ${t.message}")
|
||||||
|
|
||||||
|
// Ignore events from stale streams
|
||||||
|
if (messageStreamVersion.get() != streamVersion) {
|
||||||
|
Log.d(TAG, "Ignoring error from stale message stream")
|
||||||
|
close(t)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Don't trigger reconnect for CANCELLED errors
|
||||||
|
if (!t.message.orEmpty().contains("CANCELLED")) {
|
||||||
|
triggerReconnect("Message stream error: ${t.message}")
|
||||||
|
}
|
||||||
close(t)
|
close(t)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onCompleted() {
|
override fun onCompleted() {
|
||||||
|
Log.d(TAG, "Message stream completed")
|
||||||
|
|
||||||
|
// Ignore events from stale streams
|
||||||
|
if (messageStreamVersion.get() != streamVersion) {
|
||||||
|
Log.d(TAG, "Ignoring completion from stale message stream")
|
||||||
|
close()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stream ended unexpectedly - trigger reconnect if we should still be subscribed
|
||||||
|
if (activeMessageSubscription != null && shouldReconnect.get()) {
|
||||||
|
Log.w(TAG, "Message stream ended unexpectedly, triggering reconnect")
|
||||||
|
triggerReconnect("Message stream ended")
|
||||||
|
}
|
||||||
close()
|
close()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
asyncStub?.subscribeMessages(request, observer)
|
asyncStub?.subscribeMessages(request, observer)
|
||||||
|
|
||||||
awaitClose { }
|
awaitClose {
|
||||||
|
activeMessageSubscription = null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Subscribe to session events
|
* Subscribe to session events (with auto-recovery)
|
||||||
*/
|
*/
|
||||||
fun subscribeSessionEvents(partyId: String): Flow<SessionEventData> = callbackFlow {
|
fun subscribeSessionEvents(partyId: String): Flow<SessionEventData> = callbackFlow {
|
||||||
|
// Save subscription for recovery
|
||||||
|
eventStreamSubscribed.set(true)
|
||||||
|
eventStreamPartyId = partyId
|
||||||
|
|
||||||
|
// Capture current stream version to detect stale callbacks
|
||||||
|
val streamVersion = eventStreamVersion.incrementAndGet()
|
||||||
|
|
||||||
val request = SubscribeSessionEventsRequest.newBuilder()
|
val request = SubscribeSessionEventsRequest.newBuilder()
|
||||||
.setPartyId(partyId)
|
.setPartyId(partyId)
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
val observer = object : StreamObserver<SessionEvent> {
|
val observer = object : StreamObserver<SessionEvent> {
|
||||||
override fun onNext(event: SessionEvent) {
|
override fun onNext(event: SessionEvent) {
|
||||||
|
// Ignore events from stale streams
|
||||||
|
if (eventStreamVersion.get() != streamVersion) {
|
||||||
|
Log.d(TAG, "Ignoring event from stale stream (v$streamVersion, current v${eventStreamVersion.get()})")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
val eventData = SessionEventData(
|
val eventData = SessionEventData(
|
||||||
eventId = event.eventId,
|
eventId = event.eventId,
|
||||||
eventType = event.eventType,
|
eventType = event.eventType,
|
||||||
|
|
@ -237,17 +819,62 @@ class GrpcClient @Inject constructor() {
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onError(t: Throwable) {
|
override fun onError(t: Throwable) {
|
||||||
|
Log.e(TAG, "Session event stream error: ${t.message}")
|
||||||
|
|
||||||
|
// Ignore events from stale streams
|
||||||
|
if (eventStreamVersion.get() != streamVersion) {
|
||||||
|
Log.d(TAG, "Ignoring error from stale event stream")
|
||||||
|
close(t)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Don't trigger reconnect for CANCELLED errors
|
||||||
|
if (!t.message.orEmpty().contains("CANCELLED")) {
|
||||||
|
triggerReconnect("Event stream error: ${t.message}")
|
||||||
|
}
|
||||||
close(t)
|
close(t)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onCompleted() {
|
override fun onCompleted() {
|
||||||
|
Log.d(TAG, "Session event stream completed")
|
||||||
|
|
||||||
|
// Ignore events from stale streams
|
||||||
|
if (eventStreamVersion.get() != streamVersion) {
|
||||||
|
Log.d(TAG, "Ignoring completion from stale event stream")
|
||||||
|
close()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stream ended unexpectedly - trigger reconnect if we should still be subscribed
|
||||||
|
if (eventStreamSubscribed.get() && shouldReconnect.get()) {
|
||||||
|
Log.w(TAG, "Event stream ended unexpectedly, triggering reconnect")
|
||||||
|
triggerReconnect("Event stream ended")
|
||||||
|
}
|
||||||
close()
|
close()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
asyncStub?.subscribeSessionEvents(request, observer)
|
asyncStub?.subscribeSessionEvents(request, observer)
|
||||||
|
|
||||||
awaitClose { }
|
awaitClose {
|
||||||
|
eventStreamSubscribed.set(false)
|
||||||
|
eventStreamPartyId = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unsubscribe from session events
|
||||||
|
*/
|
||||||
|
fun unsubscribeSessionEvents() {
|
||||||
|
eventStreamSubscribed.set(false)
|
||||||
|
eventStreamPartyId = null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unsubscribe from messages
|
||||||
|
*/
|
||||||
|
fun unsubscribeMessages() {
|
||||||
|
activeMessageSubscription = null
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -271,15 +898,16 @@ class GrpcClient @Inject constructor() {
|
||||||
builder.setSignature(com.google.protobuf.ByteString.copyFrom(it))
|
builder.setSignature(com.google.protobuf.ByteString.copyFrom(it))
|
||||||
}
|
}
|
||||||
|
|
||||||
val response = stub?.reportCompletion(builder.build())
|
val response = getStubWithDeadline()?.reportCompletion(builder.build())
|
||||||
Result.success(response?.allCompleted ?: false)
|
Result.success(response?.allCompleted ?: false)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Report completion failed: ${e.message}")
|
||||||
Result.failure(e)
|
Result.failure(e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Send heartbeat
|
* Send heartbeat (manual call for testing)
|
||||||
*/
|
*/
|
||||||
suspend fun heartbeat(partyId: String): Result<Int> = withContext(Dispatchers.IO) {
|
suspend fun heartbeat(partyId: String): Result<Int> = withContext(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -288,14 +916,42 @@ class GrpcClient @Inject constructor() {
|
||||||
.setTimestamp(System.currentTimeMillis())
|
.setTimestamp(System.currentTimeMillis())
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
val response = stub?.heartbeat(request)
|
val response = getStubWithDeadline()?.heartbeat(request)
|
||||||
Result.success(response?.pendingMessages ?: 0)
|
Result.success(response?.pendingMessages ?: 0)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Heartbeat failed: ${e.message}")
|
||||||
Result.failure(e)
|
Result.failure(e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Force reconnect (for manual recovery)
|
||||||
|
*/
|
||||||
|
fun forceReconnect() {
|
||||||
|
if (currentHost != null && currentPort != null) {
|
||||||
|
reconnectAttempts.set(0)
|
||||||
|
triggerReconnect("Manual reconnect requested")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current connection parameters
|
||||||
|
*/
|
||||||
|
fun getConnectionInfo(): Pair<String, Int>? {
|
||||||
|
val host = currentHost
|
||||||
|
val port = currentPort
|
||||||
|
return if (host != null && port != null) Pair(host, port) else null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Message subscription info
|
||||||
|
*/
|
||||||
|
private data class MessageSubscription(
|
||||||
|
val sessionId: String,
|
||||||
|
val partyId: String
|
||||||
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Data class for join session response
|
* Data class for join session response
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,8 @@ import com.durian.tssparty.data.local.ShareRecordDao
|
||||||
import com.durian.tssparty.data.local.ShareRecordEntity
|
import com.durian.tssparty.data.local.ShareRecordEntity
|
||||||
import com.durian.tssparty.data.local.TssNativeBridge
|
import com.durian.tssparty.data.local.TssNativeBridge
|
||||||
import com.durian.tssparty.data.remote.GrpcClient
|
import com.durian.tssparty.data.remote.GrpcClient
|
||||||
|
import com.durian.tssparty.data.remote.GrpcConnectionEvent
|
||||||
|
import com.durian.tssparty.data.remote.GrpcConnectionState
|
||||||
import com.durian.tssparty.data.remote.IncomingMessage
|
import com.durian.tssparty.data.remote.IncomingMessage
|
||||||
import com.durian.tssparty.data.remote.JoinSessionData
|
import com.durian.tssparty.data.remote.JoinSessionData
|
||||||
import com.durian.tssparty.data.remote.SessionEventData
|
import com.durian.tssparty.data.remote.SessionEventData
|
||||||
|
|
@ -34,13 +36,51 @@ class TssRepository @Inject constructor(
|
||||||
private val _sessionStatus = MutableStateFlow<SessionStatus>(SessionStatus.WAITING)
|
private val _sessionStatus = MutableStateFlow<SessionStatus>(SessionStatus.WAITING)
|
||||||
val sessionStatus: StateFlow<SessionStatus> = _sessionStatus.asStateFlow()
|
val sessionStatus: StateFlow<SessionStatus> = _sessionStatus.asStateFlow()
|
||||||
|
|
||||||
|
// Expose gRPC connection state for UI
|
||||||
|
val grpcConnectionState: StateFlow<GrpcConnectionState> = grpcClient.connectionState
|
||||||
|
|
||||||
|
// Expose gRPC connection events for UI notifications
|
||||||
|
val grpcConnectionEvents: SharedFlow<GrpcConnectionEvent> = grpcClient.connectionEvents
|
||||||
|
|
||||||
private var partyId: String = UUID.randomUUID().toString()
|
private var partyId: String = UUID.randomUUID().toString()
|
||||||
private var messageCollectionJob: Job? = null
|
private var messageCollectionJob: Job? = null
|
||||||
private var sessionEventJob: Job? = null
|
private var sessionEventJob: Job? = null
|
||||||
|
|
||||||
|
// Track current message routing params for reconnection recovery
|
||||||
|
private var currentMessageRoutingSessionId: String? = null
|
||||||
|
private var currentMessageRoutingPartyIndex: Int? = null
|
||||||
|
|
||||||
// Account service URL (configurable via settings)
|
// Account service URL (configurable via settings)
|
||||||
private var accountServiceUrl: String = "https://rwaapi.szaiai.com"
|
private var accountServiceUrl: String = "https://rwaapi.szaiai.com"
|
||||||
|
|
||||||
|
init {
|
||||||
|
// Set up reconnection callback to restore streams
|
||||||
|
grpcClient.setOnReconnectedCallback {
|
||||||
|
android.util.Log.d("TssRepository", "gRPC reconnected, restoring streams...")
|
||||||
|
restoreStreamsAfterReconnect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Restore message and event streams after gRPC reconnection
|
||||||
|
*/
|
||||||
|
private fun restoreStreamsAfterReconnect() {
|
||||||
|
val sessionId = currentMessageRoutingSessionId
|
||||||
|
val partyIndex = currentMessageRoutingPartyIndex
|
||||||
|
|
||||||
|
// Restore message routing if we had an active session
|
||||||
|
if (sessionId != null && partyIndex != null) {
|
||||||
|
android.util.Log.d("TssRepository", "Restoring message routing for session: $sessionId")
|
||||||
|
startMessageRouting(sessionId, partyIndex)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore session event subscription
|
||||||
|
if (grpcClient.wasEventStreamSubscribed()) {
|
||||||
|
android.util.Log.d("TssRepository", "Restoring session event subscription")
|
||||||
|
startSessionEventSubscription()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// HTTP client for API calls
|
// HTTP client for API calls
|
||||||
private val httpClient = okhttp3.OkHttpClient.Builder()
|
private val httpClient = okhttp3.OkHttpClient.Builder()
|
||||||
.connectTimeout(30, java.util.concurrent.TimeUnit.SECONDS)
|
.connectTimeout(30, java.util.concurrent.TimeUnit.SECONDS)
|
||||||
|
|
@ -70,6 +110,23 @@ class TssRepository @Inject constructor(
|
||||||
grpcClient.disconnect()
|
grpcClient.disconnect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if gRPC is connected
|
||||||
|
*/
|
||||||
|
fun isGrpcConnected(): Boolean = grpcClient.isConnected()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Force reconnect to gRPC server
|
||||||
|
*/
|
||||||
|
fun forceReconnect() {
|
||||||
|
grpcClient.forceReconnect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current gRPC connection info
|
||||||
|
*/
|
||||||
|
fun getGrpcConnectionInfo(): Pair<String, Int>? = grpcClient.getConnectionInfo()
|
||||||
|
|
||||||
// Session event callback (set by ViewModel)
|
// Session event callback (set by ViewModel)
|
||||||
private var sessionEventCallback: ((SessionEventData) -> Unit)? = null
|
private var sessionEventCallback: ((SessionEventData) -> Unit)? = null
|
||||||
|
|
||||||
|
|
@ -1096,6 +1153,10 @@ class TssRepository @Inject constructor(
|
||||||
* Start message routing between TSS and gRPC
|
* Start message routing between TSS and gRPC
|
||||||
*/
|
*/
|
||||||
private fun startMessageRouting(sessionId: String, partyIndex: Int) {
|
private fun startMessageRouting(sessionId: String, partyIndex: Int) {
|
||||||
|
// Save params for reconnection recovery
|
||||||
|
currentMessageRoutingSessionId = sessionId
|
||||||
|
currentMessageRoutingPartyIndex = partyIndex
|
||||||
|
|
||||||
messageCollectionJob?.cancel()
|
messageCollectionJob?.cancel()
|
||||||
messageCollectionJob = CoroutineScope(Dispatchers.IO).launch {
|
messageCollectionJob = CoroutineScope(Dispatchers.IO).launch {
|
||||||
// Collect outgoing messages from TSS and route via gRPC
|
// Collect outgoing messages from TSS and route via gRPC
|
||||||
|
|
@ -1219,6 +1280,10 @@ class TssRepository @Inject constructor(
|
||||||
sessionEventJob?.cancel()
|
sessionEventJob?.cancel()
|
||||||
_currentSession.value = null
|
_currentSession.value = null
|
||||||
_sessionStatus.value = SessionStatus.WAITING
|
_sessionStatus.value = SessionStatus.WAITING
|
||||||
|
|
||||||
|
// Clear reconnection recovery params
|
||||||
|
currentMessageRoutingSessionId = null
|
||||||
|
currentMessageRoutingPartyIndex = null
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,7 @@ fun CreateWalletScreen(
|
||||||
inviteCode: String?,
|
inviteCode: String?,
|
||||||
sessionId: String?,
|
sessionId: String?,
|
||||||
sessionStatus: SessionStatus,
|
sessionStatus: SessionStatus,
|
||||||
|
hasEnteredSession: Boolean = false,
|
||||||
participants: List<String> = emptyList(),
|
participants: List<String> = emptyList(),
|
||||||
currentRound: Int = 0,
|
currentRound: Int = 0,
|
||||||
totalRounds: Int = 9,
|
totalRounds: Int = 9,
|
||||||
|
|
@ -61,8 +62,10 @@ fun CreateWalletScreen(
|
||||||
var validationError by remember { mutableStateOf<String?>(null) }
|
var validationError by remember { mutableStateOf<String?>(null) }
|
||||||
|
|
||||||
// Determine current step based on state
|
// Determine current step based on state
|
||||||
|
// Show session screen if user clicked "进入会话" OR if keygen is in progress/completed/failed
|
||||||
val step = when {
|
val step = when {
|
||||||
sessionStatus == SessionStatus.IN_PROGRESS || sessionStatus == SessionStatus.COMPLETED || sessionStatus == SessionStatus.FAILED -> "session"
|
sessionStatus == SessionStatus.IN_PROGRESS || sessionStatus == SessionStatus.COMPLETED || sessionStatus == SessionStatus.FAILED -> "session"
|
||||||
|
hasEnteredSession && inviteCode != null -> "session"
|
||||||
inviteCode != null -> "created"
|
inviteCode != null -> "created"
|
||||||
isLoading -> "creating"
|
isLoading -> "creating"
|
||||||
else -> "config"
|
else -> "config"
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.durian.tssparty.BuildConfig
|
||||||
import com.durian.tssparty.domain.model.AppSettings
|
import com.durian.tssparty.domain.model.AppSettings
|
||||||
import com.durian.tssparty.domain.model.NetworkType
|
import com.durian.tssparty.domain.model.NetworkType
|
||||||
|
|
||||||
|
|
@ -479,7 +480,11 @@ fun SettingsScreen(
|
||||||
) {
|
) {
|
||||||
AboutRow("应用名称", "TSS Party")
|
AboutRow("应用名称", "TSS Party")
|
||||||
Divider(modifier = Modifier.padding(vertical = 8.dp))
|
Divider(modifier = Modifier.padding(vertical = 8.dp))
|
||||||
AboutRow("版本", "1.0.0")
|
AboutRow("版本", BuildConfig.VERSION_NAME)
|
||||||
|
Divider(modifier = Modifier.padding(vertical = 8.dp))
|
||||||
|
AboutRow("版本号", BuildConfig.VERSION_CODE.toString())
|
||||||
|
Divider(modifier = Modifier.padding(vertical = 8.dp))
|
||||||
|
AboutRow("构建类型", if (BuildConfig.DEBUG) "Debug" else "Release")
|
||||||
Divider(modifier = Modifier.padding(vertical = 8.dp))
|
Divider(modifier = Modifier.padding(vertical = 8.dp))
|
||||||
AboutRow("TSS 协议", "GG20")
|
AboutRow("TSS 协议", "GG20")
|
||||||
Divider(modifier = Modifier.padding(vertical = 8.dp))
|
Divider(modifier = Modifier.padding(vertical = 8.dp))
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,10 @@ class MainViewModel @Inject constructor(
|
||||||
private val _createdInviteCode = MutableStateFlow<String?>(null)
|
private val _createdInviteCode = MutableStateFlow<String?>(null)
|
||||||
val createdInviteCode: StateFlow<String?> = _createdInviteCode.asStateFlow()
|
val createdInviteCode: StateFlow<String?> = _createdInviteCode.asStateFlow()
|
||||||
|
|
||||||
|
// Flag to track if user has entered the session (clicked "进入会话")
|
||||||
|
private val _hasEnteredSession = MutableStateFlow(false)
|
||||||
|
val hasEnteredSession: StateFlow<Boolean> = _hasEnteredSession.asStateFlow()
|
||||||
|
|
||||||
init {
|
init {
|
||||||
// Start initialization on app launch
|
// Start initialization on app launch
|
||||||
checkAllServices()
|
checkAllServices()
|
||||||
|
|
@ -331,9 +335,10 @@ class MainViewModel @Inject constructor(
|
||||||
*/
|
*/
|
||||||
fun enterSession() {
|
fun enterSession() {
|
||||||
// Session events are already being listened to via the callback set in init
|
// Session events are already being listened to via the callback set in init
|
||||||
// This just transitions the UI to the session view
|
// This transitions the UI to the session view
|
||||||
val sessionId = _currentSessionId.value
|
val sessionId = _currentSessionId.value
|
||||||
android.util.Log.d("MainViewModel", "Entering session: $sessionId")
|
android.util.Log.d("MainViewModel", "Entering session: $sessionId")
|
||||||
|
_hasEnteredSession.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -383,6 +388,7 @@ class MainViewModel @Inject constructor(
|
||||||
_currentRound.value = 0
|
_currentRound.value = 0
|
||||||
_publicKey.value = null
|
_publicKey.value = null
|
||||||
_createdInviteCode.value = null
|
_createdInviteCode.value = null
|
||||||
|
_hasEnteredSession.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========== Join Keygen State ==========
|
// ========== Join Keygen State ==========
|
||||||
|
|
|
||||||
|
|
@ -318,3 +318,28 @@
|
||||||
.copyButton:hover {
|
.copyButton:hover {
|
||||||
background-color: var(--primary-light);
|
background-color: var(--primary-light);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* QR Code Section */
|
||||||
|
.qrSection {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
padding: var(--spacing-lg);
|
||||||
|
background-color: var(--background-color);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
margin-bottom: var(--spacing-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.qrCodeWrapper {
|
||||||
|
padding: var(--spacing-md);
|
||||||
|
background-color: white;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.qrHint {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { QRCodeSVG } from 'qrcode.react';
|
||||||
import styles from './Create.module.css';
|
import styles from './Create.module.css';
|
||||||
|
|
||||||
interface CreateSessionResult {
|
interface CreateSessionResult {
|
||||||
|
|
@ -220,6 +221,21 @@ export default function Create() {
|
||||||
<div className={styles.successIcon}>✓</div>
|
<div className={styles.successIcon}>✓</div>
|
||||||
<h3 className={styles.successTitle}>会话创建成功</h3>
|
<h3 className={styles.successTitle}>会话创建成功</h3>
|
||||||
|
|
||||||
|
{/* QR Code for mobile scanning */}
|
||||||
|
<div className={styles.qrSection}>
|
||||||
|
<div className={styles.qrCodeWrapper}>
|
||||||
|
<QRCodeSVG
|
||||||
|
value={result.inviteCode || ''}
|
||||||
|
size={180}
|
||||||
|
level="M"
|
||||||
|
includeMargin={true}
|
||||||
|
bgColor="#ffffff"
|
||||||
|
fgColor="#000000"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className={styles.qrHint}>使用手机 App 扫码加入</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className={styles.inviteSection}>
|
<div className={styles.inviteSection}>
|
||||||
<label className={styles.label}>邀请码</label>
|
<label className={styles.label}>邀请码</label>
|
||||||
<div className={styles.inviteCodeWrapper}>
|
<div className={styles.inviteCodeWrapper}>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue