Доставка сообщений при потере сети, кэш фото при отправке, FPS клавиатуры, свайп фото, badge tab bar, release notes, sync unread fix

This commit is contained in:
2026-03-20 16:51:57 +05:00
parent 44652e0d97
commit e75c6bac12
20 changed files with 427 additions and 323 deletions

View File

@@ -104,41 +104,23 @@ final class ProtocolManager: @unchecked Sendable {
}
}
/// Verify connection health after returning from background.
/// Fast path: if already authenticated, send a WebSocket ping first (< 100ms).
/// If pong arrives, connection is alive no reconnect needed.
/// If ping fails or times out (500ms), force full reconnect.
/// Android parity: `reconnectNowIfNeeded()` if already in an active state,
/// skip reconnect. Otherwise reset backoff and connect immediately.
func reconnectIfNeeded() {
guard savedPublicKey != nil, savedPrivateHash != nil else { return }
// Don't interrupt active handshake
if connectionState == .handshaking { return }
// Fast path: if authenticated, try ping first before tearing down.
if connectionState == .authenticated, client.isConnected {
Self.logger.info("Foreground — ping check")
client.sendPing { [weak self] error in
guard let self else { return }
if error == nil {
// Pong received connection alive, send heartbeat to keep it fresh.
Self.logger.info("Foreground ping OK — connection alive")
self.client.sendText("heartbeat")
return
}
// Ping failed connection dead, force reconnect.
Self.logger.info("Foreground ping failed — force reconnecting")
self.handshakeComplete = false
self.heartbeatTask?.cancel()
Task { @MainActor in
self.connectionState = .connecting
}
self.client.forceReconnect()
}
// Android parity: skip if already in any active state.
switch connectionState {
case .authenticated, .handshaking, .deviceVerificationRequired, .connected:
return
case .connecting:
if client.isConnected { return }
case .disconnected:
break
}
// Not authenticated force reconnect immediately.
Self.logger.info("Foreground reconnect — force reconnecting")
// Reset backoff and connect immediately.
Self.logger.info("⚡ Fast reconnect — state=\(self.connectionState.rawValue)")
handshakeComplete = false
heartbeatTask?.cancel()
connectionState = .connecting
@@ -402,6 +384,8 @@ final class ProtocolManager: @unchecked Sendable {
switch packet.handshakeState {
case .completed:
handshakeComplete = true
// Android parity: reset backoff counter on successful authentication.
client.resetReconnectAttempts()
Self.logger.info("Handshake completed. Protocol v\(packet.protocolVersion), heartbeat \(packet.heartbeatInterval)s")
flushPacketQueue()
@@ -441,18 +425,41 @@ final class ProtocolManager: @unchecked Sendable {
// Android parity: heartbeat at 1/3 the server-specified interval (more aggressive keep-alive).
let intervalNs = UInt64(interval) * 1_000_000_000 / 3
heartbeatTask = Task {
heartbeatTask = Task { [weak self] in
// Send first heartbeat immediately
client.sendText("heartbeat")
self?.sendHeartbeat()
while !Task.isCancelled {
try? await Task.sleep(nanoseconds: intervalNs)
guard !Task.isCancelled else { break }
client.sendText("heartbeat")
self?.sendHeartbeat()
}
}
}
/// Android parity: send heartbeat and trigger disconnect on failure.
private func sendHeartbeat() {
let state = connectionState
guard state == .authenticated || state == .deviceVerificationRequired else { return }
guard client.isConnected else {
Self.logger.warning("💔 Heartbeat failed: socket not connected — triggering reconnect")
handleHeartbeatFailure()
return
}
client.sendText("heartbeat")
}
/// Android parity: failed heartbeat handleDisconnect.
private func handleHeartbeatFailure() {
heartbeatTask?.cancel()
handshakeComplete = false
Task { @MainActor in
self.connectionState = .disconnected
}
// Let WebSocketClient's own handleDisconnect schedule reconnect.
client.forceReconnect()
}
// MARK: - Packet Queue
private func sendPacketDirect(_ packet: any Packet) {