Групповые чаты: sender name/avatar в ячейках, multi-typer typing, фикс скачивания фото/аватарок и verified badge

This commit is contained in:
2026-04-03 18:04:41 +05:00
parent de0818fe69
commit da6b3d7c3f
35 changed files with 2728 additions and 386 deletions

View File

@@ -88,6 +88,7 @@ final class ProtocolManager: @unchecked Sendable {
private let signalPeerHandlersLock = NSLock()
private let webRTCHandlersLock = NSLock()
private let iceServersHandlersLock = NSLock()
private let groupOneShotLock = NSLock()
private let packetQueueLock = NSLock()
private let searchRouter = SearchPacketRouter()
private var resultHandlers: [UUID: (PacketResult) -> Void] = [:]
@@ -95,6 +96,10 @@ final class ProtocolManager: @unchecked Sendable {
private var webRTCHandlers: [UUID: (PacketWebRTC) -> Void] = [:]
private var iceServersHandlers: [UUID: (PacketIceServers) -> Void] = [:]
/// Generic one-shot handlers for group packets. Key: (packetId, handlerId).
/// Handler returns `true` if it consumed the packet (auto-removed).
private var groupOneShotHandlers: [UUID: (packetId: Int, handler: (any Packet) -> Bool)] = [:]
/// Background task to keep WebSocket alive during brief background periods (active call).
/// iOS gives ~30s; enough for the call to survive app switching / notification interactions.
private var callBackgroundTask: UIBackgroundTaskIdentifier = .invalid
@@ -140,10 +145,13 @@ final class ProtocolManager: @unchecked Sendable {
return
}
case .connecting:
if client.isConnecting {
Self.logger.info("Connect already in progress, skipping duplicate connect()")
return
}
// Always skip if already in connecting state. Previous code only
// checked client.isConnecting which has a race gap between
// setting connectionState=.connecting and client.connect() setting
// isConnecting=true, a second call would slip through.
// This caused 3 parallel WebSocket connections (3x every packet).
Self.logger.info("Connect already in progress, skipping duplicate connect()")
return
case .disconnected:
break
}
@@ -189,13 +197,19 @@ final class ProtocolManager: @unchecked Sendable {
func forceReconnectOnForeground() {
guard savedPublicKey != nil, savedPrivateHash != nil else { return }
// During an active call the WebSocket may still be alive (background task
// keeps the process running for ~30s). Tearing it down would break signaling
// and trigger server re-delivery of .call causing endCallBecauseBusy.
// If the connection is authenticated, trust it and skip reconnect.
// During an active call, WebRTC media flows via DTLS/SRTP (not WebSocket).
// Tearing down the socket would trigger server re-delivery of .call and
// cause unnecessary signaling disruption (endCallBecauseBusy).
// For .active phase: skip entirely WS is only needed for endCall signal,
// which will work after natural reconnect or ICE timeout ends the call.
// For other call phases: skip only if WS is authenticated (still alive).
if CallManager.shared.uiState.phase == .active {
Self.logger.info("⚡ Foreground reconnect skipped — call active, media via DTLS")
return
}
if CallManager.shared.uiState.phase != .idle,
connectionState == .authenticated {
Self.logger.info("⚡ Foreground reconnect skipped — active call, WS authenticated")
Self.logger.info("⚡ Foreground reconnect skipped — call in progress, WS authenticated")
return
}
@@ -205,7 +219,8 @@ final class ProtocolManager: @unchecked Sendable {
case .handshaking, .deviceVerificationRequired:
return
case .connecting:
if client.isConnecting { return }
// Same fix as connect() unconditional return to prevent triple connections
return
case .authenticated, .connected, .disconnected:
break // Always reconnect .authenticated/.connected may be zombie on iOS
}
@@ -229,6 +244,10 @@ final class ProtocolManager: @unchecked Sendable {
func beginCallBackgroundTask() {
guard callBackgroundTask == .invalid else { return }
callBackgroundTask = UIApplication.shared.beginBackgroundTask(withName: "RosettaCall") { [weak self] in
// Don't end the call here CallKit keeps the process alive for active calls.
// This background task only buys time for WebSocket reconnection.
// Killing the call on expiry was causing premature call termination
// during keyExchange phase (~30s before Desktop could respond).
self?.endCallBackgroundTask()
}
Self.logger.info("📞 Background task started for call")
@@ -252,8 +271,8 @@ final class ProtocolManager: @unchecked Sendable {
case .authenticated, .handshaking, .deviceVerificationRequired, .connected:
return
case .connecting:
// Android parity: `(CONNECTING && isConnecting)` skip if connect() is in progress.
if client.isConnecting { return }
// Unconditional return prevent duplicate connections (same fix as connect())
return
case .disconnected:
break
}
@@ -352,6 +371,8 @@ final class ProtocolManager: @unchecked Sendable {
var packet = PacketWebRTC()
packet.signalType = signalType
packet.sdpOrCandidate = sdpOrCandidate
packet.publicKey = SessionManager.shared.currentPublicKey
packet.deviceId = DeviceIdentityManager.shared.currentDeviceId()
sendPacket(packet)
}
@@ -452,6 +473,47 @@ final class ProtocolManager: @unchecked Sendable {
resultHandlersLock.unlock()
}
// MARK: - Group One-Shot Handlers
/// Registers a one-shot handler for a specific packet type.
/// Handler receives the packet and returns `true` to consume (auto-remove), `false` to keep.
@discardableResult
func addGroupOneShotHandler(packetId: Int, handler: @escaping (any Packet) -> Bool) -> UUID {
let id = UUID()
groupOneShotLock.lock()
groupOneShotHandlers[id] = (packetId, handler)
groupOneShotLock.unlock()
return id
}
func removeGroupOneShotHandler(_ id: UUID) {
groupOneShotLock.lock()
groupOneShotHandlers.removeValue(forKey: id)
groupOneShotLock.unlock()
}
/// Called from `routeIncomingPacket` dispatches to matching one-shot handlers.
private func notifyGroupOneShotHandlers(packetId: Int, packet: any Packet) {
groupOneShotLock.lock()
let matching = groupOneShotHandlers.filter { $0.value.packetId == packetId }
groupOneShotLock.unlock()
var consumed: [UUID] = []
for (id, entry) in matching {
if entry.handler(packet) {
consumed.append(id)
}
}
if !consumed.isEmpty {
groupOneShotLock.lock()
for id in consumed {
groupOneShotHandlers.removeValue(forKey: id)
}
groupOneShotLock.unlock()
}
}
// MARK: - Private Setup
private func setupClientCallbacks() {
@@ -668,26 +730,32 @@ final class ProtocolManager: @unchecked Sendable {
case 0x11:
if let p = packet as? PacketCreateGroup {
onCreateGroupReceived?(p)
notifyGroupOneShotHandlers(packetId: 0x11, packet: p)
}
case 0x12:
if let p = packet as? PacketGroupInfo {
onGroupInfoReceived?(p)
notifyGroupOneShotHandlers(packetId: 0x12, packet: p)
}
case 0x13:
if let p = packet as? PacketGroupInviteInfo {
onGroupInviteInfoReceived?(p)
notifyGroupOneShotHandlers(packetId: 0x13, packet: p)
}
case 0x14:
if let p = packet as? PacketGroupJoin {
onGroupJoinReceived?(p)
notifyGroupOneShotHandlers(packetId: 0x14, packet: p)
}
case 0x15:
if let p = packet as? PacketGroupLeave {
onGroupLeaveReceived?(p)
notifyGroupOneShotHandlers(packetId: 0x15, packet: p)
}
case 0x16:
if let p = packet as? PacketGroupBan {
onGroupBanReceived?(p)
notifyGroupOneShotHandlers(packetId: 0x16, packet: p)
}
case 0x0F:
if let p = packet as? PacketRequestTransport {