Фикс: имя файла в пересланных сообщениях, потеря фоток/файлов при пересылке forwarded-сообщений, Фоллбэк при unwrap forwarded-сообщения, защита БД от перезаписи синком

This commit is contained in:
2026-03-21 20:28:11 +05:00
parent 224b8a2b54
commit 65e5991f97
24 changed files with 2715 additions and 1037 deletions

View File

@@ -57,6 +57,23 @@ final class ProtocolManager: @unchecked Sendable {
private var handshakeComplete = false
private var heartbeatTask: Task<Void, Never>?
private var handshakeTimeoutTask: Task<Void, Never>?
private var pingTimeoutTask: Task<Void, Never>?
/// Guards against overlapping ping-first verifications on foreground.
private var pingVerificationInProgress = false
/// Android parity: sync batch flag set SYNCHRONOUSLY on receive queue.
/// Prevents race where MainActor Task for BATCH_START runs after message Task.
/// Written on URLSession delegate queue, read on MainActor protected by lock.
private let syncBatchLock = NSLock()
private var _syncBatchActive = false
/// Thread-safe read for SessionManager to check sync state without MainActor race.
var isSyncBatchActive: Bool {
syncBatchLock.lock()
let val = _syncBatchActive
syncBatchLock.unlock()
return val
}
private let searchHandlersLock = NSLock()
private let resultHandlersLock = NSLock()
private let packetQueueLock = NSLock()
@@ -94,6 +111,9 @@ final class ProtocolManager: @unchecked Sendable {
Self.logger.info("Disconnecting")
heartbeatTask?.cancel()
handshakeTimeoutTask?.cancel()
pingTimeoutTask?.cancel()
pingTimeoutTask = nil
pingVerificationInProgress = false
handshakeComplete = false
client.disconnect()
connectionState = .disconnected
@@ -109,12 +129,13 @@ final class ProtocolManager: @unchecked Sendable {
func reconnectIfNeeded() {
guard savedPublicKey != nil, savedPrivateHash != nil else { return }
// Android parity: skip if already in any active state.
// Android parity (Protocol.kt:651-658): skip if already in any active state.
switch connectionState {
case .authenticated, .handshaking, .deviceVerificationRequired, .connected:
return
case .connecting:
if client.isConnected { return }
// Android parity: `(CONNECTING && isConnecting)` skip if connect() is in progress.
if client.isConnecting { return }
case .disconnected:
break
}
@@ -127,13 +148,63 @@ final class ProtocolManager: @unchecked Sendable {
client.forceReconnect()
}
/// Ping-first zombie socket detection for foreground resume.
/// iOS suspends the process in background server RSTs TCP, but `didCloseWith`
/// delegate never fires. `connectionState` stays `.authenticated` (stale).
/// This method sends a WebSocket ping: pong alive, no pong force reconnect.
func verifyConnectionOrReconnect() {
guard savedPublicKey != nil, savedPrivateHash != nil else { return }
guard connectionState == .authenticated || connectionState == .connected else { return }
guard !pingVerificationInProgress else { return }
pingVerificationInProgress = true
Self.logger.info("🏓 Verifying connection with ping after foreground...")
client.sendPing { [weak self] error in
guard let self, self.pingVerificationInProgress else { return }
self.pingVerificationInProgress = false
self.pingTimeoutTask?.cancel()
self.pingTimeoutTask = nil
if let error {
Self.logger.warning("🏓 Ping failed — zombie socket: \(error.localizedDescription)")
self.handlePingFailure()
} else {
Self.logger.info("🏓 Pong received — connection alive")
}
}
// Safety timeout: if sendPing never calls back (completely dead socket), force reconnect.
pingTimeoutTask?.cancel()
pingTimeoutTask = Task { [weak self] in
try? await Task.sleep(for: .seconds(3))
guard let self, !Task.isCancelled, self.pingVerificationInProgress else { return }
self.pingVerificationInProgress = false
Self.logger.warning("🏓 Ping timeout (3s) — zombie socket, forcing reconnect")
self.handlePingFailure()
}
}
private func handlePingFailure() {
pingTimeoutTask?.cancel()
pingTimeoutTask = nil
handshakeComplete = false
heartbeatTask?.cancel()
Task { @MainActor in
self.connectionState = .connecting
}
client.forceReconnect()
}
// MARK: - Sending
func sendPacket(_ packet: any Packet) {
PerformanceLogger.shared.track("protocol.sendPacket")
let id = String(type(of: packet).packetId, radix: 16)
if (!handshakeComplete && !(packet is PacketHandshake)) || !client.isConnected {
Self.logger.info("⏳ Queueing packet 0x\(id) — connected=\(self.client.isConnected), handshake=\(self.handshakeComplete)")
// Android parity (Protocol.kt:436-448): triple check handshakeComplete + socket alive + authenticated.
let isAuth = connectionState == .authenticated
if (!handshakeComplete && !(packet is PacketHandshake)) || !client.isConnected || !isAuth {
Self.logger.info("⏳ Queueing packet 0x\(id) — connected=\(self.client.isConnected), handshake=\(self.handshakeComplete), auth=\(isAuth)")
enqueuePacket(packet)
return
}
@@ -200,6 +271,9 @@ final class ProtocolManager: @unchecked Sendable {
}
heartbeatTask?.cancel()
handshakeComplete = false
pingVerificationInProgress = false
pingTimeoutTask?.cancel()
pingTimeoutTask = nil
Task { @MainActor in
self.connectionState = .disconnected
@@ -348,6 +422,18 @@ final class ProtocolManager: @unchecked Sendable {
}
case 0x19:
if let p = packet as? PacketSync {
// Android parity: set sync flag SYNCHRONOUSLY on receive queue
// BEFORE dispatching to MainActor callback. This prevents the race
// where a 0x06 message Task runs on MainActor before BATCH_START Task.
if p.status == .batchStart {
syncBatchLock.lock()
_syncBatchActive = true
syncBatchLock.unlock()
} else if p.status == .notNeeded {
syncBatchLock.lock()
_syncBatchActive = false
syncBatchLock.unlock()
}
onSyncReceived?(p)
}
default:
@@ -412,8 +498,8 @@ final class ProtocolManager: @unchecked Sendable {
self.connectionState = .deviceVerificationRequired
}
// Keep packet queue: messages will be flushed when the other device
// approves this login and the server re-sends handshake with .completed
// Android parity (Protocol.kt:163): clear packet queue on device verification.
clearPacketQueue()
startHeartbeat(interval: packet.heartbeatInterval)
}
}