diff --git a/app/src/main/java/com/rosetta/messenger/network/Protocol.kt b/app/src/main/java/com/rosetta/messenger/network/Protocol.kt index e7d8f4d..eeeea78 100644 --- a/app/src/main/java/com/rosetta/messenger/network/Protocol.kt +++ b/app/src/main/java/com/rosetta/messenger/network/Protocol.kt @@ -311,14 +311,35 @@ class Protocol( when (resolve.solution) { DeviceResolveSolution.ACCEPT -> { log("✅ DEVICE VERIFICATION ACCEPTED (deviceId=${shortKey(resolve.deviceId, 12)})") - if (_state.value == ProtocolState.DEVICE_VERIFICATION_REQUIRED) { + val stateAtAccept = _state.value + if (stateAtAccept == ProtocolState.AUTHENTICATED) { + log("✅ ACCEPT ignored: already authenticated") + return@waitPacket + } + + if (stateAtAccept == ProtocolState.DEVICE_VERIFICATION_REQUIRED) { setState(ProtocolState.CONNECTED, "Device verification accepted") - val publicKey = lastPublicKey - val privateHash = lastPrivateHash - if (!publicKey.isNullOrBlank() && !privateHash.isNullOrBlank()) { + } + + val publicKey = lastPublicKey + val privateHash = lastPrivateHash + if (publicKey.isNullOrBlank() || privateHash.isNullOrBlank()) { + log("⚠️ ACCEPT received but credentials are missing, waiting for reconnect") + return@waitPacket + } + + when (_state.value) { + ProtocolState.DISCONNECTED -> { + log("🔄 ACCEPT while disconnected -> reconnecting") + connect() + } + + ProtocolState.CONNECTING -> { + log("⏳ ACCEPT while connecting -> waiting for onOpen auto-handshake") + } + + else -> { startHandshake(publicKey, privateHash, lastDevice) - } else { - log("⚠️ ACCEPT received but credentials are missing, waiting for reconnect") } } } @@ -644,7 +665,14 @@ class Protocol( val currentState = _state.value val socket = webSocket val socketReady = socket != null - val authReady = handshakeComplete && currentState == ProtocolState.AUTHENTICATED + val authReady = currentState == ProtocolState.AUTHENTICATED + if (authReady && !handshakeComplete) { + // Defensive self-heal: + // AUTHENTICATED state must imply completed handshake. + // If these flags diverge, message sending can be stuck in queue forever. + log("⚠️ AUTHENTICATED with handshakeComplete=false -> self-heal handshakeComplete=true") + handshakeComplete = true + } val preAuthAllowedPacket = packet is PacketSignalPeer || packet is PacketWebRTC || packet is PacketIceServers val preAuthReady = @@ -772,6 +800,13 @@ class Protocol( private fun handleDisconnect() { val previousState = _state.value log("🔌 DISCONNECT HANDLER: previousState=$previousState, manuallyClosed=$isManuallyClosed, reconnectAttempts=$reconnectAttempts, isConnecting=$isConnecting") + + // Duplicate callbacks are possible (e.g. heartbeat failure + onFailure/onClosed). + // If we are already disconnected and a reconnect is pending, avoid scheduling another one. + if (previousState == ProtocolState.DISCONNECTED && reconnectJob?.isActive == true) { + log("⚠️ DISCONNECT DUPLICATE: reconnect already scheduled, skipping") + return + } // КРИТИЧНО: если уже идет подключение, не делаем ничего if (isConnecting) { @@ -799,12 +834,16 @@ class Protocol( // КРИТИЧНО: отменяем предыдущий reconnect job если есть reconnectJob?.cancel() - // Экспоненциальная задержка: 1s, 2s, 4s, 8s, максимум 30s - val delayMs = minOf(1000L * (1 shl minOf(reconnectAttempts - 1, 4)), 30000L) - log("🔄 SCHEDULING RECONNECT: attempt #$reconnectAttempts, delay=${delayMs}ms") + // Экспоненциальная задержка: 1s, 2s, 4s, 8s, 16s, максимум 30s. + // IMPORTANT: reconnectAttempts may be 0 right after AUTHENTICATED reset. + // Using (1 shl -1) causes overflow (seen in logs as -2147483648000ms). + val nextAttemptNumber = (reconnectAttempts + 1).coerceAtLeast(1) + val exponent = (nextAttemptNumber - 1).coerceIn(0, 4) + val delayMs = minOf(1000L * (1L shl exponent), 30000L) + log("🔄 SCHEDULING RECONNECT: attempt #$nextAttemptNumber, delay=${delayMs}ms") - if (reconnectAttempts > 20) { - log("⚠️ WARNING: Too many reconnect attempts ($reconnectAttempts), may be stuck in loop") + if (nextAttemptNumber > 20) { + log("⚠️ WARNING: Too many reconnect attempts ($nextAttemptNumber), may be stuck in loop") } reconnectJob = scope.launch {