From de0818fe69ec2a077d58c81e645bdc4e8e9c9d1f Mon Sep 17 00:00:00 2001 From: senseiGai Date: Thu, 2 Apr 2026 15:29:46 +0500 Subject: [PATCH] =?UTF-8?q?=D0=A4=D0=B8=D0=BA=D1=81:=20=D0=B4=D1=83=D0=B1?= =?UTF-8?q?=D0=BB=D0=B8=D0=BA=D0=B0=D1=82=20CallKit=20=D0=B2=D1=8B=D0=B7?= =?UTF-8?q?=D0=BE=D0=B2=D0=B0,=20disconnect=20recovery,=20WebRTC=20packet?= =?UTF-8?q?=20buffering=20=D0=B8=20E2EE=20rebind=20loop?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Network/Protocol/ProtocolManager.swift | 33 ++ Rosetta/Core/Services/CallKitManager.swift | 98 ++++- .../Core/Services/CallManager+Runtime.swift | 135 +++++-- Rosetta/Core/Services/CallManager.swift | 174 ++++++++- .../Services/SessionCredentialsManager.swift | 92 +++++ Rosetta/Core/Services/SessionManager.swift | 4 + Rosetta/Features/Auth/UnlockView.swift | 7 + .../ChatDetail/ForwardChatPickerView.swift | 147 +++---- .../Chats/ChatDetail/ImageGalleryViewer.swift | 359 ++++++++++-------- Rosetta/RosettaApp.swift | 109 +++++- 10 files changed, 863 insertions(+), 295 deletions(-) create mode 100644 Rosetta/Core/Services/SessionCredentialsManager.swift diff --git a/Rosetta/Core/Network/Protocol/ProtocolManager.swift b/Rosetta/Core/Network/Protocol/ProtocolManager.swift index 7905dd1..7ca3d5d 100644 --- a/Rosetta/Core/Network/Protocol/ProtocolManager.swift +++ b/Rosetta/Core/Network/Protocol/ProtocolManager.swift @@ -95,6 +95,10 @@ final class ProtocolManager: @unchecked Sendable { private var webRTCHandlers: [UUID: (PacketWebRTC) -> Void] = [:] private var iceServersHandlers: [UUID: (PacketIceServers) -> Void] = [:] + /// 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 + // Saved credentials for auto-reconnect private var savedPublicKey: String? private var savedPrivateHash: String? @@ -185,6 +189,16 @@ 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. + if CallManager.shared.uiState.phase != .idle, + connectionState == .authenticated { + Self.logger.info("⚑ Foreground reconnect skipped β€” active call, WS authenticated") + return + } + // Android parity: skip if handshake or device verification is in progress. // These are active flows that should not be interrupted. switch connectionState { @@ -209,6 +223,25 @@ final class ProtocolManager: @unchecked Sendable { client.forceReconnect() } + // MARK: - Call Background Task + + /// Keeps the process alive during active calls so WebSocket survives brief background. + func beginCallBackgroundTask() { + guard callBackgroundTask == .invalid else { return } + callBackgroundTask = UIApplication.shared.beginBackgroundTask(withName: "RosettaCall") { [weak self] in + self?.endCallBackgroundTask() + } + Self.logger.info("πŸ“ž Background task started for call") + } + + func endCallBackgroundTask() { + guard callBackgroundTask != .invalid else { return } + let task = callBackgroundTask + callBackgroundTask = .invalid + UIApplication.shared.endBackgroundTask(task) + Self.logger.info("πŸ“ž Background task ended for call") + } + /// Android parity: `reconnectNowIfNeeded()` β€” if already in an active state, /// skip reconnect. Otherwise reset backoff and connect immediately. func reconnectIfNeeded() { diff --git a/Rosetta/Core/Services/CallKitManager.swift b/Rosetta/Core/Services/CallKitManager.swift index 9dc0507..9d7c3a3 100644 --- a/Rosetta/Core/Services/CallKitManager.swift +++ b/Rosetta/Core/Services/CallKitManager.swift @@ -1,6 +1,7 @@ import AVFAudio import CallKit import os +import WebRTC /// CallKit integration layer β€” wraps CXProvider and CXCallController. /// Reports incoming/outgoing calls to the system so they appear in the native call UI, @@ -54,17 +55,43 @@ final class CallKitManager: NSObject { nonisolated func reportIncomingCallSynchronously( callerKey: String, callerName: String, + callId: String? = nil, completion: @escaping (Error?) -> Void ) { - let uuid = UUID() + // Guard: if WebSocket already reported this call via reportIncomingCall(), + // skip to prevent duplicate CallKit calls. Two calls with different UUIDs + // cause CallKit (maximumCallsPerCallGroup=1) to auto-end the first one, + // which sends endCall signal to desktop and drops the call. + uuidLock.lock() + let alreadyPending = _pendingCallUUID != nil + uuidLock.unlock() + + if alreadyPending { + Self.logger.info("Skipping duplicate CallKit report β€” call already pending from WebSocket") + completion(nil) + return + } + + // Stable UUID from server callId (Telegram parity) β€” prevents UUID mismatch + // between PushKit and WebSocket paths for the same call. + let uuid: UUID = { + if let callId, let parsed = UUID(uuidString: callId) { + return parsed + } + return UUID() + }() // Assign UUID synchronously to prevent race with WebSocket signal. uuidLock.lock() _pendingCallUUID = uuid uuidLock.unlock() + let handleValue = callerName.isEmpty + ? String(callerKey.prefix(7)) + : callerName + let update = CXCallUpdate() - update.remoteHandle = CXHandle(type: .generic, value: callerKey) + update.remoteHandle = CXHandle(type: .generic, value: handleValue) update.localizedCallerName = callerName.isEmpty ? "Rosetta" : callerName update.hasVideo = false update.supportsHolding = false @@ -105,8 +132,21 @@ final class CallKitManager: NSObject { let uuid = currentCallUUID ?? UUID() currentCallUUID = uuid + // Sync _pendingCallUUID so VoIP push path can see we already have a call. + // Without this, VoIP push creates a SECOND CallKit call with a different UUID, + // and CallKit auto-ends the first one β†’ sends endCall to desktop. + uuidLock.lock() + _pendingCallUUID = uuid + uuidLock.unlock() + + // Use display-friendly handle value β€” CallKit shows this on lock screen. + // Full public key is ugly; short prefix matches Android parity (7 chars). + let handleValue = callerName.isEmpty + ? String(callerKey.prefix(7)) + : callerName + let update = CXCallUpdate() - update.remoteHandle = CXHandle(type: .generic, value: callerKey) + update.remoteHandle = CXHandle(type: .generic, value: handleValue) update.localizedCallerName = callerName.isEmpty ? "Rosetta" : callerName update.hasVideo = false update.supportsHolding = false @@ -127,6 +167,28 @@ final class CallKitManager: NSObject { } } + /// Updates the caller display name on an active incoming call. + /// Called when hydratePeerIdentity resolves a name that wasn't available during + /// initial reportIncomingCall (e.g. DB not yet loaded on cold start from VoIP push). + func updateCallerName(_ name: String) { + guard !name.isEmpty else { return } + // Use currentCallUUID if available; fall back to _pendingCallUUID + // for PushKit race: currentCallUUID is set async via Task { @MainActor }, + // but _pendingCallUUID is set synchronously in reportIncomingCallSynchronously(). + // hydratePeerIdentity may run before the async Task assigns currentCallUUID. + let uuid: UUID? = currentCallUUID ?? { + uuidLock.lock() + let pending = _pendingCallUUID + uuidLock.unlock() + return pending + }() + guard let uuid else { return } + let update = CXCallUpdate() + update.localizedCallerName = name + update.remoteHandle = CXHandle(type: .generic, value: name) + provider.reportCall(with: uuid, updated: update) + } + // MARK: - Outgoing Call func startOutgoingCall(peerKey: String) { @@ -210,13 +272,16 @@ extension CallKitManager: CXProviderDelegate { nonisolated func provider(_ provider: CXProvider, perform action: CXAnswerCallAction) { Self.logger.info("CXAnswerCallAction") + action.fulfill() Task { @MainActor in let result = CallManager.shared.acceptIncomingCall() if result == .started { - action.fulfill() + Self.logger.info("CXAnswerCallAction: accepted") } else { - Self.logger.warning("CXAnswerCallAction failed: \(String(describing: result))") - action.fail() + // Phase not .incoming yet β€” WebSocket hasn't delivered .call signal. + // Set pending flag so handleSignalPacket(.call) auto-accepts when it arrives. + Self.logger.info("CXAnswerCallAction: pending (phase not incoming yet, waiting for WebSocket)") + CallManager.shared.pendingCallKitAccept = true } } } @@ -249,9 +314,30 @@ extension CallKitManager: CXProviderDelegate { nonisolated func provider(_ provider: CXProvider, didActivate audioSession: AVAudioSession) { Self.logger.info("Audio session activated by CallKit") + let rtcSession = RTCAudioSession.sharedInstance() + // 1. Tell WebRTC the session is active (updates internal isActive flag). + rtcSession.audioSessionDidActivate(audioSession) + // 2. Configure category BEFORE enabling audio β€” when isAudioEnabled + // becomes true, ADM may immediately init the audio unit and will + // use whatever category is currently set. Without this, it may use + // .soloAmbient (default) instead of .playAndRecord β†’ silent audio. + rtcSession.lockForConfiguration() + try? rtcSession.setCategory( + .playAndRecord, mode: .voiceChat, + options: [.allowBluetooth, .allowBluetoothA2DP, .defaultToSpeaker] + ) + rtcSession.unlockForConfiguration() + // 3. NOW enable audio β€” ADM will init with correct .playAndRecord category. + rtcSession.isAudioEnabled = true + Task { @MainActor in + await CallManager.shared.onAudioSessionActivated() + } } nonisolated func provider(_ provider: CXProvider, didDeactivate audioSession: AVAudioSession) { Self.logger.info("Audio session deactivated by CallKit") + let rtcSession = RTCAudioSession.sharedInstance() + rtcSession.audioSessionDidDeactivate(audioSession) + rtcSession.isAudioEnabled = false } } diff --git a/Rosetta/Core/Services/CallManager+Runtime.swift b/Rosetta/Core/Services/CallManager+Runtime.swift index 2ff529a..c28f5d0 100644 --- a/Rosetta/Core/Services/CallManager+Runtime.swift +++ b/Rosetta/Core/Services/CallManager+Runtime.swift @@ -1,6 +1,7 @@ import AVFAudio import CryptoKit import Foundation +import os import SwiftUI import UIKit import WebRTC @@ -10,19 +11,32 @@ extension CallManager { func handleWebRtcPacket(_ packet: PacketWebRTC) async { guard uiState.phase == .webRtcExchange || uiState.phase == .active else { return } - guard let peerConnection = self.peerConnection else { return } + // Buffer packets if peer connection not yet created (CallKit didActivate delay). + // Android doesn't need this β€” no CallKit, peerConnection created immediately. + guard let peerConnection = self.peerConnection else { + callLogger.info("[Call] Buffering WebRTC packet (type=\(packet.signalType.rawValue, privacy: .public)) β€” peerConnection not ready") + bufferedWebRtcPackets.append(packet) + return + } + + await processWebRtcPacket(packet, on: peerConnection) + } + + func processWebRtcPacket(_ packet: PacketWebRTC, on peerConnection: RTCPeerConnection) async { switch packet.signalType { case .answer: guard let answer = parseSessionDescription(from: packet.sdpOrCandidate), answer.type == .answer else { return } + callLogger.info("[Call] Received ANSWER from SFU") do { try await setRemoteDescription(answer, on: peerConnection) remoteDescriptionSet = true await flushBufferedRemoteCandidates() } catch { + callLogger.error("[Call] Failed to apply answer: \(error.localizedDescription, privacy: .public)") finishCall(reason: "Failed to apply answer", notifyPeer: false) } case .offer: @@ -30,6 +44,7 @@ extension CallManager { offer.type == .offer else { return } + callLogger.info("[Call] Received OFFER from SFU (renegotiation)") do { try await setRemoteDescription(offer, on: peerConnection) remoteDescriptionSet = true @@ -42,6 +57,7 @@ extension CallManager { sdpOrCandidate: serializeSessionDescription(answer) ) } catch { + callLogger.error("[Call] Failed to handle offer: \(error.localizedDescription, privacy: .public)") finishCall(reason: "Failed to handle offer", notifyPeer: false) } case .iceCandidate: @@ -54,11 +70,39 @@ extension CallManager { } } + /// Called by CallKit when audio session is activated (didActivate callback). + /// Category is already set in didActivate (before isAudioEnabled = true). + /// This method handles deferred WebRTC setup and non-audio-session config. + /// MUST be async β€” directly awaits ensurePeerConnectionAndOffer() to avoid + /// double-async nesting (Task inside Task) that causes race conditions. + func onAudioSessionActivated() async { + audioSessionActivated = true + callLogger.info("[Call] didActivate: phase=\(self.uiState.phase.rawValue, privacy: .public) pendingWebRtcSetup=\(self.pendingWebRtcSetup.description, privacy: .public)") + guard uiState.phase != .idle else { return } + + // Flush deferred WebRTC setup β€” .createRoom arrived before didActivate. + // Direct await (no Task wrapper) β€” eliminates the double-async race where + // remote offers arrive before peer connection exists. + if pendingWebRtcSetup { + pendingWebRtcSetup = false + await ensurePeerConnectionAndOffer() + } + + // Apply routing AFTER peer connection is created (track now exists). + applyAudioOutputRouting() + UIDevice.current.isProximityMonitoringEnabled = true + localAudioTrack?.isEnabled = !uiState.isMuted + callLogger.info("[Call] Audio ready, track=\((self.localAudioTrack != nil).description, privacy: .public) enabled=\((!self.uiState.isMuted).description, privacy: .public)") + } + func ensurePeerConnectionAndOffer() async { // Guard: finishCall() may have run during the async gap before this Task executes. - guard uiState.phase == .webRtcExchange else { return } + guard uiState.phase == .webRtcExchange else { + callLogger.info("[Call] ensurePeerConnectionAndOffer: skipped (phase=\(self.uiState.phase.rawValue, privacy: .public))") + return + } + callLogger.info("[Call] ensurePeerConnectionAndOffer: starting") do { - try configureAudioSession() let peerConnection = try ensurePeerConnection() applySenderCryptorIfPossible() @@ -69,7 +113,22 @@ extension CallManager { sdpOrCandidate: serializeSessionDescription(offer) ) offerSent = true + callLogger.info("[Call] ensurePeerConnectionAndOffer: offer sent") + + // Flush WebRTC packets AFTER local description is set. + // Buffered ANSWER requires local offer to be set first (signalingState must be + // have-local-offer, not stable). ICE candidates also need remote desc first β€” + // they'll go to bufferedRemoteCandidates via processWebRtcPacket. + if !bufferedWebRtcPackets.isEmpty { + callLogger.info("[Call] Flushing \(self.bufferedWebRtcPackets.count, privacy: .public) buffered WebRTC packets") + let packets = bufferedWebRtcPackets + bufferedWebRtcPackets.removeAll() + for packet in packets { + await processWebRtcPacket(packet, on: peerConnection) + } + } } catch { + callLogger.error("[Call] ensurePeerConnectionAndOffer FAILED: \(error.localizedDescription, privacy: .public)") finishCall(reason: "Failed to establish call", notifyPeer: false) } } @@ -89,15 +148,20 @@ extension CallManager { // re-enters via CallManager.endCall(). Skip if already idle or mid-finish. guard !isFinishingCall, uiState.phase != .idle else { return } isFinishingCall = true + pendingCallKitAccept = false defer { isFinishingCall = false } - print("[CallBar] finishCall(reason=\(reason ?? "nil")) β€” phase=\(uiState.phase.rawValue) isMinimized=\(uiState.isMinimized)") - // Log call stack to identify WHO triggered finishCall - let symbols = Thread.callStackSymbols.prefix(8).joined(separator: "\n ") - print("[CallBar] stack:\n \(symbols)") + callLogger.info("[Call] finishCall(reason=\(reason ?? "nil", privacy: .public)) phase=\(self.uiState.phase.rawValue, privacy: .public)") let snapshot = uiState + // Step 0: Cancel recovery/rebind tasks and clear packet buffer. + disconnectRecoveryTask?.cancel() + disconnectRecoveryTask = nil + e2eeRebindTask?.cancel() + e2eeRebindTask = nil + bufferedWebRtcPackets.removeAll() + // Step 1: Close WebRTC FIRST β€” SFU sees peer disconnect immediately. // Without this, SFU waits for ICE timeout (~30s) before releasing the room, // blocking new calls to the same peer. @@ -178,6 +242,8 @@ extension CallManager { offerSent = false remoteDescriptionSet = false lastPeerSharedPublicHex = "" + audioSessionActivated = false + pendingWebRtcSetup = false var finalState = CallUiState() if let reason, !reason.isEmpty { @@ -188,6 +254,7 @@ extension CallManager { } deactivateAudioSession() + ProtocolManager.shared.endCallBackgroundTask() } func ensureLocalSessionKeys() { @@ -198,6 +265,7 @@ extension CallManager { } func hydratePeerIdentity(for publicKey: String) { + let hadName = !uiState.peerTitle.isEmpty if let dialog = DialogRepository.shared.dialogs[publicKey] { if uiState.peerTitle.isEmpty { uiState.peerTitle = dialog.opponentTitle @@ -206,6 +274,11 @@ extension CallManager { uiState.peerUsername = dialog.opponentUsername } } + // If we just resolved a name that wasn't available before (e.g. DB loaded + // after cold start), update CallKit so lock screen shows the name. + if !hadName, !uiState.peerTitle.isEmpty { + CallKitManager.shared.updateCallerName(uiState.displayName) + } } func applySenderCryptorIfPossible() { @@ -237,6 +310,8 @@ extension CallManager { func ensurePeerConnection() throws -> RTCPeerConnection { if let currentPeerConnection = self.peerConnection { return currentPeerConnection } + callLogger.info("[Call] Creating new peer connection (iceServers=\(self.iceServers.count, privacy: .public))") + if peerConnectionFactory == nil { RTCPeerConnectionFactory.initialize() peerConnectionFactory = RTCPeerConnectionFactory() @@ -268,25 +343,36 @@ extension CallManager { self.localAudioSource = audioSource self.localAudioTrack = audioTrack self.peerConnection = connection + callLogger.info("[Call] Peer connection created, audio track enabled=\((!self.uiState.isMuted).description, privacy: .public)") return connection } func configureAudioSession() throws { - let session = AVAudioSession.sharedInstance() - try session.setCategory( + let rtcSession = RTCAudioSession.sharedInstance() + rtcSession.lockForConfiguration() + defer { rtcSession.unlockForConfiguration() } + try rtcSession.setCategory( .playAndRecord, mode: .voiceChat, options: [.allowBluetooth, .allowBluetoothA2DP, .defaultToSpeaker] ) - try session.setActive(true) + // Do NOT call setActive(true) β€” session is already activated by CallKit + // via audioSessionDidActivate(). Double activation increments WebRTC's + // internal activation count, causing deactivateAudioSession() to decrement + // to 1 instead of 0 β€” AVAudioSession never actually deactivates. applyAudioOutputRouting() UIDevice.current.isProximityMonitoringEnabled = true - print("[Call] AudioSession configured: category=\(session.category.rawValue) mode=\(session.mode.rawValue) sampleRate=\(session.sampleRate) outputs=\(session.currentRoute.outputs.map { "\($0.portName)(\($0.portType.rawValue))" }) inputs=\(session.currentRoute.inputs.map { "\($0.portName)(\($0.portType.rawValue))" })") + let session = AVAudioSession.sharedInstance() + callLogger.info("[Call] AudioSession configured: category=\(session.category.rawValue, privacy: .public) mode=\(session.mode.rawValue, privacy: .public)") } func deactivateAudioSession() { UIDevice.current.isProximityMonitoringEnabled = false - try? AVAudioSession.sharedInstance().setActive(false, options: [.notifyOthersOnDeactivation]) + let rtcSession = RTCAudioSession.sharedInstance() + rtcSession.lockForConfiguration() + try? rtcSession.setActive(false) + rtcSession.unlockForConfiguration() + rtcSession.isAudioEnabled = false } func applyAudioOutputRouting() { @@ -438,10 +524,10 @@ extension CallManager: RTCPeerConnectionDelegate { nonisolated func peerConnection(_ peerConnection: RTCPeerConnection, didChange stateChanged: RTCSignalingState) {} nonisolated func peerConnection(_ peerConnection: RTCPeerConnection, didAdd stream: RTCMediaStream) { Task { @MainActor in - print("[Call] Remote stream added: \(stream.streamId), audioTracks=\(stream.audioTracks.count), videoTracks=\(stream.videoTracks.count)") + callLogger.info("[Call] Remote stream added: \(stream.streamId, privacy: .public) audioTracks=\(stream.audioTracks.count, privacy: .public)") for audioTrack in stream.audioTracks { audioTrack.isEnabled = true - print("[Call] Remote audio track: \(audioTrack.trackId), enabled=\(audioTrack.isEnabled), state=\(audioTrack.readyState.rawValue)") + callLogger.info("[Call] Remote audio track: \(audioTrack.trackId, privacy: .public) enabled=\(audioTrack.isEnabled.description, privacy: .public) state=\(audioTrack.readyState.rawValue, privacy: .public)") } } } @@ -462,35 +548,28 @@ extension CallManager: RTCPeerConnectionDelegate { nonisolated func peerConnection(_ peerConnection: RTCPeerConnection, didOpen dataChannel: RTCDataChannel) {} nonisolated func peerConnection(_ peerConnection: RTCPeerConnection, didChange newState: RTCPeerConnectionState) { - print("[Call] PeerConnection state: \(newState.rawValue)") + callLogger.info("[Call] PeerConnection state: \(newState.rawValue, privacy: .public)") if newState == .connected { Task { @MainActor in self.setCallActiveIfNeeded() - // Log audio state let localEnabled = self.localAudioTrack?.isEnabled ?? false let senderCount = peerConnection.senders.count let receiverCount = peerConnection.receivers.count let transceiverCount = peerConnection.transceivers.count - print("[Call] CONNECTED β€” localAudio=\(localEnabled), senders=\(senderCount), receivers=\(receiverCount), transceivers=\(transceiverCount)") + callLogger.info("[Call] CONNECTED β€” localAudio=\(localEnabled.description, privacy: .public) senders=\(senderCount, privacy: .public) receivers=\(receiverCount, privacy: .public) transceivers=\(transceiverCount, privacy: .public)") for (i, t) in peerConnection.transceivers.enumerated() { let dir = t.direction.rawValue let hasSender = t.sender.track != nil let hasReceiver = t.receiver.track != nil let recvEnabled = t.receiver.track?.isEnabled ?? false - print("[Call] Transceiver[\(i)]: dir=\(dir) hasSender=\(hasSender) hasReceiver=\(hasReceiver) recvEnabled=\(recvEnabled)") + callLogger.info("[Call] Transceiver[\(i, privacy: .public)]: dir=\(dir, privacy: .public) hasSender=\(hasSender.description, privacy: .public) hasReceiver=\(hasReceiver.description, privacy: .public) recvEnabled=\(recvEnabled.description, privacy: .public)") } - // Log audio session let session = AVAudioSession.sharedInstance() - print("[Call] AudioSession: category=\(session.category.rawValue) mode=\(session.mode.rawValue) route.outputs=\(session.currentRoute.outputs.map { $0.portName })") - } - return - } - if newState == .failed || newState == .closed || newState == .disconnected { - Task { @MainActor in - print("[CallBar] PeerConnection \(newState.rawValue) β†’ finishCall()") - self.finishCall(reason: "Connection lost", notifyPeer: false) + callLogger.info("[Call] AudioSession: category=\(session.category.rawValue, privacy: .public) mode=\(session.mode.rawValue, privacy: .public)") } } + // NOTE: .failed/.disconnected/.closed handled by ICE state handler only. + // Previously both handlers called finishCall() β†’ dual-kill on single disconnect event. } nonisolated func peerConnection(_ peerConnection: RTCPeerConnection, didStartReceivingOn transceiver: RTCRtpTransceiver) { @@ -498,7 +577,7 @@ extension CallManager: RTCPeerConnectionDelegate { Task { @MainActor in receiver.track?.isEnabled = true let trackKind = transceiver.mediaType == .audio ? "audio" : "video" - print("[Call] didStartReceivingOn: \(trackKind), receiverId=\(receiver.receiverId), trackEnabled=\(receiver.track?.isEnabled ?? false)") + callLogger.info("[Call] didStartReceivingOn: \(trackKind, privacy: .public) receiverId=\(receiver.receiverId, privacy: .public) trackEnabled=\((receiver.track?.isEnabled ?? false).description, privacy: .public)") self.attachReceiverCryptor(receiver) } } diff --git a/Rosetta/Core/Services/CallManager.swift b/Rosetta/Core/Services/CallManager.swift index d7a398a..50ff723 100644 --- a/Rosetta/Core/Services/CallManager.swift +++ b/Rosetta/Core/Services/CallManager.swift @@ -3,9 +3,12 @@ import AVFAudio import Combine import CryptoKit import Foundation +import os import SwiftUI import WebRTC +let callLogger = Logger(subsystem: "com.rosetta.messenger", category: "Call") + @MainActor final class CallManager: NSObject, ObservableObject { @@ -43,9 +46,35 @@ final class CallManager: NSObject, ObservableObject { var liveActivity: Activity? /// Re-entrancy guard: prevents CXEndCallAction β†’ endCall() β†’ finishCall() loop. var isFinishingCall = false + /// Pending accept: user tapped Accept on CallKit before WebSocket delivered .call signal. + /// When handleSignalPacket(.call) arrives, auto-accept if this is true. + var pendingCallKitAccept = false + /// True after CallKit fires didActivate β€” audio session has the entitlement. + /// WebRTC peer connection MUST NOT be created before this flag is true, + /// otherwise AURemoteIO init fails with "Missing entitlement" (-12988). + var audioSessionActivated = false + /// Buffered when .createRoom arrives before didActivate. Flushed in onAudioSessionActivated(). + var pendingWebRtcSetup = false + /// Buffers WebRTC packets (OFFER/ANSWER/ICE) from SFU that arrive before + /// peerConnection is created (CallKit didActivate delay). Without this, packets + /// are silently dropped. Android doesn't need this β€” no CallKit. + var bufferedWebRtcPackets: [PacketWebRTC] = [] + /// Recovery timer for ICE .disconnected state. Android waits 15s before ending call; + /// iOS was killing instantly on any brief network hiccup. + var disconnectRecoveryTask: Task? + /// Periodic E2EE rebind loop (1.5s, Android parity). SFU may create new + /// RTP senders/receivers during renegotiation that need encryptor/decryptor attachment. + var e2eeRebindTask: Task? private override init() { super.init() + // Tell WebRTC to NOT auto-activate audio session when audio tracks are created. + // Audio will be enabled only after CallKit's didActivate callback fires. + // Without this, WebRTC calls AVAudioSession.setActive(true) before CallKit + // grants the entitlement β†’ -12988 "Missing entitlement" β†’ no audio on first call. + let rtcSession = RTCAudioSession.sharedInstance() + rtcSession.useManualAudio = true + rtcSession.isAudioEnabled = false wireProtocolHandlers() } @@ -61,6 +90,38 @@ final class CallManager: NSObject, ObservableObject { ownPublicKey = publicKey.trimmingCharacters(in: .whitespacesAndNewlines) } + /// Sets up incoming call state directly from VoIP push payload. + /// Called when app was killed β†’ PushKit wakes it β†’ WebSocket not yet connected. + /// The .call signal may never arrive (fire-and-forget), so we set up state from push. + func setupIncomingCallFromPush(callerKey: String, callerName: String) { + guard uiState.phase == .idle else { return } + guard !callerKey.isEmpty else { return } + callLogger.info("setupIncomingCallFromPush: callerKey=\(callerKey.prefix(12), privacy: .public) name=\(callerName, privacy: .public)") + // Don't call beginCallSession() β€” it calls finishCall() which kills the + // CallKit call that PushKit just reported. Set state directly instead. + uiState = CallUiState( + phase: .incoming, + peerPublicKey: callerKey, + peerTitle: callerName, + peerUsername: "" + ) + role = .callee + uiState.statusText = "Incoming call..." + ProtocolManager.shared.beginCallBackgroundTask() + hydratePeerIdentity(for: callerKey) + startRingTimeout() + + // Auto-accept if user already tapped Accept on CallKit before this ran. + // Happens when app was killed β†’ VoIP push β†’ CallKit β†’ Accept β†’ WebSocket + // not yet connected, so CXAnswerCallAction fired before phase was .incoming. + if pendingCallKitAccept { + pendingCallKitAccept = false + callLogger.info("setupIncomingCallFromPush: auto-accepting (pendingCallKitAccept)") + let result = acceptIncomingCall() + callLogger.info("setupIncomingCallFromPush: auto-accept result=\(String(describing: result), privacy: .public)") + } + } + func onAuthenticated() { ProtocolManager.shared.requestIceServers() } @@ -81,6 +142,7 @@ final class CallManager: NSObject, ObservableObject { uiState.phase = .outgoing uiState.statusText = "Calling..." + ProtocolManager.shared.beginCallBackgroundTask() CallKitManager.shared.startOutgoingCall(peerKey: target) ProtocolManager.shared.sendCallSignal( @@ -119,7 +181,7 @@ final class CallManager: NSObject, ObservableObject { } func declineIncomingCall() { - print("[CallBar] declineIncomingCall() β€” phase=\(uiState.phase.rawValue)") + callLogger.info("[Call] declineIncomingCall phase=\(self.uiState.phase.rawValue, privacy: .public)") guard uiState.phase == .incoming else { return } if ownPublicKey.isEmpty == false, uiState.peerPublicKey.isEmpty == false { ProtocolManager.shared.sendCallSignal( @@ -132,7 +194,7 @@ final class CallManager: NSObject, ObservableObject { } func endCall() { - print("[CallBar] endCall() β€” phase=\(uiState.phase.rawValue) isMinimized=\(uiState.isMinimized)") + callLogger.info("[Call] endCall phase=\(self.uiState.phase.rawValue, privacy: .public)") finishCall(reason: nil, notifyPeer: true) } @@ -142,7 +204,7 @@ final class CallManager: NSObject, ObservableObject { localAudioTrack?.isEnabled = !nextMuted CallKitManager.shared.setMuted(nextMuted) updateLiveActivity() - print("[Call] toggleMute: isMuted=\(nextMuted), trackEnabled=\(localAudioTrack?.isEnabled ?? false), trackState=\(localAudioTrack?.readyState.rawValue ?? -1)") + callLogger.info("[Call] toggleMute isMuted=\(nextMuted.description, privacy: .public)") } func toggleSpeaker() { @@ -150,14 +212,14 @@ final class CallManager: NSObject, ObservableObject { uiState.isSpeakerOn = nextSpeaker applyAudioOutputRouting() let route = AVAudioSession.sharedInstance().currentRoute - print("[Call] toggleSpeaker: isSpeakerOn=\(nextSpeaker), outputs=\(route.outputs.map { $0.portName })") + callLogger.info("[Call] toggleSpeaker isSpeakerOn=\(nextSpeaker.description, privacy: .public)") } func minimizeCall() { guard uiState.isVisible else { return } pendingMinimizeTask?.cancel() pendingMinimizeTask = nil - print("[CallBar] minimizeCall() β€” phase=\(uiState.phase.rawValue)") + callLogger.info("[Call] minimizeCall phase=\(self.uiState.phase.rawValue, privacy: .public)") withAnimation(.spring(response: 0.4, dampingFraction: 0.85)) { uiState.isMinimized = true } @@ -167,7 +229,7 @@ final class CallManager: NSObject, ObservableObject { pendingMinimizeTask?.cancel() pendingMinimizeTask = nil guard uiState.isVisible else { return } - print("[CallBar] expandCall() β€” phase=\(uiState.phase.rawValue)") + callLogger.info("[Call] expandCall phase=\(self.uiState.phase.rawValue, privacy: .public)") withAnimation(.spring(response: 0.4, dampingFraction: 0.85)) { uiState.isMinimized = false } @@ -194,7 +256,7 @@ final class CallManager: NSObject, ObservableObject { } private func handleSignalPacket(_ packet: PacketSignalPeer) { - print("[CallBar] handleSignalPacket: type=\(packet.signalType) phase=\(uiState.phase.rawValue) isMinimized=\(uiState.isMinimized)") + callLogger.info("[Call] handleSignalPacket: type=\(String(describing: packet.signalType), privacy: .public) phase=\(self.uiState.phase.rawValue, privacy: .public)") switch packet.signalType { case .endCallBecauseBusy: finishCall(reason: "User is busy", notifyPeer: false, skipAttachment: true) @@ -220,6 +282,14 @@ final class CallManager: NSObject, ObservableObject { let incomingPeer = packet.src.trimmingCharacters(in: .whitespacesAndNewlines) guard incomingPeer.isEmpty == false else { return } guard uiState.phase == .idle else { + // Already in a call with this peer β€” ignore duplicate .call signal. + // Server re-delivers .call after WebSocket reconnect; without this guard, + // the code sends .endCallBecauseBusy which terminates the active call. + if incomingPeer == uiState.peerPublicKey { + callLogger.info("Ignoring duplicate .call signal β€” already in call with this peer (phase=\(self.uiState.phase.rawValue, privacy: .public))") + return + } + // Different peer trying to call β€” send busy. ProtocolManager.shared.sendCallSignal( signalType: .endCallBecauseBusy, src: ownPublicKey, @@ -230,6 +300,7 @@ final class CallManager: NSObject, ObservableObject { beginCallSession(peerPublicKey: incomingPeer, title: "", username: "") role = .callee uiState.phase = .incoming + ProtocolManager.shared.beginCallBackgroundTask() withAnimation(.spring(response: 0.4, dampingFraction: 0.85)) { uiState.isMinimized = false } @@ -247,6 +318,14 @@ final class CallManager: NSObject, ObservableObject { } // No playRingtone / startLiveActivity β€” CallKit handles ringtone and Dynamic Island. startRingTimeout() + + // Auto-accept if user already tapped Accept on CallKit before WebSocket connected. + // This happens when app was killed β†’ VoIP push β†’ CallKit β†’ Accept β†’ WebSocket reconnects. + if pendingCallKitAccept { + pendingCallKitAccept = false + let result = acceptIncomingCall() + callLogger.info("Auto-accept: result=\(String(describing: result), privacy: .public) ownKey=\(self.ownPublicKey.isEmpty ? "EMPTY" : String(self.ownPublicKey.prefix(12)), privacy: .public) phase=\(self.uiState.phase.rawValue, privacy: .public) wsState=\(String(describing: ProtocolManager.shared.connectionState), privacy: .public)") + } case .keyExchange: handleKeyExchange(packet) case .createRoom: @@ -255,8 +334,17 @@ final class CallManager: NSObject, ObservableObject { roomId = incomingRoomId uiState.phase = .webRtcExchange uiState.statusText = "Connecting..." - Task { [weak self] in - await self?.ensurePeerConnectionAndOffer() + // Defer WebRTC peer connection setup until CallKit grants audio entitlement. + // Creating RTCPeerConnection + audio track BEFORE didActivate causes + // AURemoteIO to fail with "Missing entitlement" (-12988), poisoning audio + // for the entire call. If didActivate already fired, proceed immediately. + if audioSessionActivated { + Task { [weak self] in + await self?.ensurePeerConnectionAndOffer() + } + } else { + pendingWebRtcSetup = true + callLogger.info("[Call] Deferring WebRTC setup β€” waiting for CallKit didActivate") } case .activeCall: break @@ -286,6 +374,7 @@ final class CallManager: NSObject, ObservableObject { sharedKey = derivedSharedKey uiState.keyCast = derivedSharedKey.hexString applySenderCryptorIfPossible() + startE2EERebindLoop() cancelRingTimeout() CallSoundManager.shared.stopAll() @@ -375,18 +464,18 @@ final class CallManager: NSObject, ObservableObject { } let authInfo = ActivityAuthorizationInfo() - print("[Call] LiveActivity: areActivitiesEnabled=\(authInfo.areActivitiesEnabled), frequentPushesEnabled=\(authInfo.frequentPushesEnabled)") + callLogger.info("[Call] LiveActivity: areActivitiesEnabled=\(authInfo.areActivitiesEnabled.description, privacy: .public)") guard authInfo.areActivitiesEnabled else { - print("[Call] LiveActivity DISABLED by user settings") + callLogger.info("[Call] LiveActivity DISABLED by user settings") return } // Compress avatar to fit ActivityKit 4KB limit while maximizing quality var avatarThumb: Data? if let avatar = AvatarRepository.shared.loadAvatar(publicKey: uiState.peerPublicKey) { avatarThumb = Self.compressAvatarForActivity(avatar) - print("[Call] Avatar thumb: \(avatarThumb?.count ?? 0) bytes") + callLogger.info("[Call] Avatar thumb: \(avatarThumb?.count ?? 0, privacy: .public) bytes") } else { - print("[Call] No avatar for peer") + callLogger.info("[Call] No avatar for peer") } let attributes = CallActivityAttributes( peerName: uiState.displayName, @@ -399,16 +488,16 @@ final class CallManager: NSObject, ObservableObject { isActive: uiState.phase == .active, isMuted: uiState.isMuted ) - print("[Call] LiveActivity starting: peerName=\(uiState.displayName), isActive=\(uiState.phase == .active)") + callLogger.info("[Call] LiveActivity starting: peerName=\(self.uiState.displayName, privacy: .public)") do { liveActivity = try Activity.request( attributes: attributes, content: .init(state: state, staleDate: nil), pushType: nil ) - print("[Call] LiveActivity started: id=\(liveActivity?.id ?? "nil"), state=\(String(describing: liveActivity?.activityState))") + callLogger.info("[Call] LiveActivity started: id=\(self.liveActivity?.id ?? "nil", privacy: .public)") } catch { - print("[Call] LiveActivity FAILED: \(error)") + callLogger.error("[Call] LiveActivity FAILED: \(error.localizedDescription, privacy: .public)") } } @@ -448,6 +537,26 @@ final class CallManager: NSObject, ObservableObject { } } + /// Periodic E2EE rebind loop (Android parity: 1.5s interval). + /// SFU may create new RTP senders/receivers during renegotiation. + /// Without this, encrypted frames from new senders can't be decrypted β†’ silence. + func startE2EERebindLoop() { + e2eeRebindTask?.cancel() + e2eeRebindTask = Task { @MainActor [weak self] in + while !Task.isCancelled { + try? await Task.sleep(for: .milliseconds(1500)) + guard !Task.isCancelled else { return } + guard let self else { return } + guard self.uiState.phase == .webRtcExchange || self.uiState.phase == .active else { continue } + guard self.sharedKey != nil, let pc = self.peerConnection else { continue } + self.applySenderCryptorIfPossible() + for receiver in pc.receivers { + self.attachReceiverCryptor(receiver) + } + } + } + } + func handleGeneratedCandidate(_ candidate: RTCIceCandidate) { let payload: [String: Any] = [ "candidate": candidate.sdp, @@ -462,17 +571,42 @@ final class CallManager: NSObject, ObservableObject { } func handleIceConnectionStateChanged(_ state: RTCIceConnectionState) { - print("[CallBar] ICE state changed: \(state.rawValue) β€” phase=\(uiState.phase.rawValue) isMinimized=\(uiState.isMinimized)") + callLogger.info("[Call] ICE state: \(state.rawValue, privacy: .public) phase=\(self.uiState.phase.rawValue, privacy: .public)") switch state { case .connected, .completed: + disconnectRecoveryTask?.cancel() + disconnectRecoveryTask = nil setCallActiveIfNeeded() - case .failed, .closed, .disconnected: - print("[CallBar] ICE \(state.rawValue) β†’ finishCall()") - finishCall(reason: "Connection lost", notifyPeer: false) + case .disconnected: + // Temporary β€” ICE may recover (WiFi↔Cellular switch, brief interruption). + // Android waits 15s. Previous iOS code killed instantly β†’ unstable calls. + startDisconnectRecoveryTimer(timeout: 15) + case .failed: + // More serious β€” unlikely to recover. Shorter timeout than .disconnected. + startDisconnectRecoveryTimer(timeout: 5) + case .closed: + finishCall(reason: "Connection closed", notifyPeer: false) default: break } } + + func startDisconnectRecoveryTimer(timeout: Int) { + disconnectRecoveryTask?.cancel() + disconnectRecoveryTask = Task { @MainActor [weak self] in + callLogger.info("[Call] Waiting \(timeout, privacy: .public)s for ICE recovery...") + try? await Task.sleep(for: .seconds(timeout)) + guard !Task.isCancelled else { return } + guard let self else { return } + let iceState = self.peerConnection?.iceConnectionState + if iceState == .connected || iceState == .completed { + callLogger.info("[Call] ICE recovered during wait β€” call continues") + return + } + callLogger.info("[Call] ICE did not recover in \(timeout, privacy: .public)s β€” ending call") + self.finishCall(reason: "Connection lost", notifyPeer: false) + } + } // MARK: - Adaptive Avatar Compressor /// Compresses avatar to fit ActivityKit's ~4KB attributes limit. diff --git a/Rosetta/Core/Services/SessionCredentialsManager.swift b/Rosetta/Core/Services/SessionCredentialsManager.swift new file mode 100644 index 0000000..9f86768 --- /dev/null +++ b/Rosetta/Core/Services/SessionCredentialsManager.swift @@ -0,0 +1,92 @@ +import Foundation +import os + +/// Stores minimal session credentials in Keychain for background VoIP push wake-up. +/// Only `publicKey` + `privateKeyHash` β€” NOT the raw private key. +/// Uses `kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly` so PushKit can read +/// them even when the app was killed, as long as the device has been unlocked once. +final class SessionCredentialsManager: @unchecked Sendable { + + static let shared = SessionCredentialsManager() + + private static let logger = Logger(subsystem: "com.rosetta.messenger", category: "SessionCreds") + private static let service = "com.rosetta.messenger.session" + private static let account = "voip_session_credentials" + + struct Credentials { + let publicKey: String + let privateKeyHash: String + } + + private init() {} + + // MARK: - Save + + func save(publicKey: String, privateKeyHash: String) { + let payload = "\(publicKey):\(privateKeyHash)" + guard let data = payload.data(using: .utf8) else { return } + + // Delete existing first + let deleteQuery: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: Self.service, + kSecAttrAccount as String: Self.account, + ] + SecItemDelete(deleteQuery as CFDictionary) + + let addQuery: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: Self.service, + kSecAttrAccount as String: Self.account, + kSecValueData as String: data, + kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly, + ] + + let status = SecItemAdd(addQuery as CFDictionary, nil) + if status == errSecSuccess { + Self.logger.info("Session credentials saved to Keychain") + } else { + Self.logger.error("Failed to save session credentials: \(status)") + } + } + + // MARK: - Load + + func load() -> Credentials? { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: Self.service, + kSecAttrAccount as String: Self.account, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne, + ] + + var result: AnyObject? + let status = SecItemCopyMatching(query as CFDictionary, &result) + + guard status == errSecSuccess, + let data = result as? Data, + let payload = String(data: data, encoding: .utf8) else { + return nil + } + + let parts = payload.components(separatedBy: ":") + guard parts.count == 2, !parts[0].isEmpty, !parts[1].isEmpty else { + return nil + } + + return Credentials(publicKey: parts[0], privateKeyHash: parts[1]) + } + + // MARK: - Clear + + func clear() { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: Self.service, + kSecAttrAccount as String: Self.account, + ] + SecItemDelete(query as CFDictionary) + Self.logger.info("Session credentials cleared from Keychain") + } +} diff --git a/Rosetta/Core/Services/SessionManager.swift b/Rosetta/Core/Services/SessionManager.swift index 6d96560..319517e 100644 --- a/Rosetta/Core/Services/SessionManager.swift +++ b/Rosetta/Core/Services/SessionManager.swift @@ -225,6 +225,9 @@ final class SessionManager { let hash = crypto.generatePrivateKeyHash(privateKeyHex: privateKeyHex) privateKeyHash = hash ProtocolManager.shared.connect(publicKey: account.publicKey, privateKeyHash: hash) + // Persist minimal credentials for background VoIP push wake-up. + // PushKit can restore WebSocket connection without user interaction. + SessionCredentialsManager.shared.save(publicKey: account.publicKey, privateKeyHash: hash) #if DEBUG Self.logger.info("⏱ CONN_PERF: connectCalled \(Int((CFAbsoluteTimeGetCurrent() - sessionStart) * 1000))ms") @@ -1116,6 +1119,7 @@ final class SessionManager { /// Ends the session and disconnects. func endSession() { ProtocolManager.shared.disconnect() + SessionCredentialsManager.shared.clear() privateKeyHash = nil privateKeyHex = nil lastTypingSentAt.removeAll() diff --git a/Rosetta/Features/Auth/UnlockView.swift b/Rosetta/Features/Auth/UnlockView.swift index 4393642..2b1bc50 100644 --- a/Rosetta/Features/Auth/UnlockView.swift +++ b/Rosetta/Features/Auth/UnlockView.swift @@ -363,6 +363,13 @@ private extension UnlockView { /// Auto-triggers biometric unlock after animations complete. func autoTriggerBiometric() async { + // Don't trigger Face ID during an active call β€” CallKit handles the call UI. + // Face ID fails in background anyway ("Caller is not running foreground"). + if CallKitManager.shared.hasPendingCall() || + CallManager.shared.uiState.phase != .idle { + return + } + guard canUseBiometric, !biometricTriggered else { return } biometricTriggered = true diff --git a/Rosetta/Features/Chats/ChatDetail/ForwardChatPickerView.swift b/Rosetta/Features/Chats/ChatDetail/ForwardChatPickerView.swift index 9aed360..6131ef4 100644 --- a/Rosetta/Features/Chats/ChatDetail/ForwardChatPickerView.swift +++ b/Rosetta/Features/Chats/ChatDetail/ForwardChatPickerView.swift @@ -41,84 +41,87 @@ struct ForwardChatPickerView: View { } var body: some View { - VStack(spacing: 0) { - // MARK: - Header - ForwardPickerHeader( - isMultiSelect: isMultiSelect, - onClose: { dismiss() }, - onSelect: { - withAnimation(.easeInOut(duration: 0.2)) { - isMultiSelect = true + ZStack { + Color.black.ignoresSafeArea() + VStack(spacing: 0) { + // MARK: - Header + ForwardPickerHeader( + isMultiSelect: isMultiSelect, + onClose: { dismiss() }, + onSelect: { + withAnimation(.easeInOut(duration: 0.2)) { + isMultiSelect = true + } } - } - ) + ) - // MARK: - Search - ForwardPickerSearchBar(searchText: $searchText) - .padding(.horizontal, 8) - .padding(.top, 8) - .padding(.bottom, 6) + // MARK: - Search + ForwardPickerSearchBar(searchText: $searchText) + .padding(.horizontal, 8) + .padding(.top, 8) + .padding(.bottom, 6) - // MARK: - Chat List - if dialogs.isEmpty && !searchText.isEmpty { - VStack { - Spacer() - Text("No chats found") - .font(.system(size: 15)) - .foregroundStyle(Color(white: 0.5)) - Spacer() - } - } else { - ScrollView { - LazyVStack(spacing: 0) { - ForEach(Array(dialogs.enumerated()), id: \.element.id) { index, dialog in - ForwardPickerRow( - dialog: dialog, - isMultiSelect: isMultiSelect, - isSelected: selectedIds.contains(dialog.opponentKey) - ) { - if isMultiSelect { - withAnimation(.easeInOut(duration: 0.15)) { - if selectedIds.contains(dialog.opponentKey) { - selectedIds.remove(dialog.opponentKey) - } else { - selectedIds.insert(dialog.opponentKey) + // MARK: - Chat List + if dialogs.isEmpty && !searchText.isEmpty { + VStack { + Spacer() + Text("No chats found") + .font(.system(size: 15)) + .foregroundStyle(Color(white: 0.5)) + Spacer() + } + } else { + ScrollView { + LazyVStack(spacing: 0) { + ForEach(Array(dialogs.enumerated()), id: \.element.id) { index, dialog in + ForwardPickerRow( + dialog: dialog, + isMultiSelect: isMultiSelect, + isSelected: selectedIds.contains(dialog.opponentKey) + ) { + if isMultiSelect { + withAnimation(.easeInOut(duration: 0.15)) { + if selectedIds.contains(dialog.opponentKey) { + selectedIds.remove(dialog.opponentKey) + } else { + selectedIds.insert(dialog.opponentKey) + } } + } else { + onSelect([ChatRoute(dialog: dialog)]) } - } else { - onSelect([ChatRoute(dialog: dialog)]) } - } - if index < dialogs.count - 1 { - Divider() - .padding(.leading, 65) - .foregroundStyle(Color(red: 0x54 / 255.0, green: 0x54 / 255.0, blue: 0x58 / 255.0).opacity(0.55)) + if index < dialogs.count - 1 { + Divider() + .padding(.leading, 65) + .foregroundStyle(Color(red: 0x54 / 255.0, green: 0x54 / 255.0, blue: 0x58 / 255.0).opacity(0.55)) + } } } } + .scrollDismissesKeyboard(.interactively) } - .scrollDismissesKeyboard(.interactively) - } - // MARK: - Bottom Bar (multi-select) - if isMultiSelect { - ForwardPickerBottomBar( - selectedCount: selectedIds.count, - onSend: { - let routes = dialogs - .filter { selectedIds.contains($0.opponentKey) } - .map { ChatRoute(dialog: $0) } - if !routes.isEmpty { onSelect(routes) } - } - ) - .transition(.move(edge: .bottom).combined(with: .opacity)) + // MARK: - Bottom Bar (multi-select) + if isMultiSelect { + ForwardPickerBottomBar( + selectedCount: selectedIds.count, + onSend: { + let routes = dialogs + .filter { selectedIds.contains($0.opponentKey) } + .map { ChatRoute(dialog: $0) } + if !routes.isEmpty { onSelect(routes) } + } + ) + .transition(.move(edge: .bottom).combined(with: .opacity)) + } } } - .background(Color.black.ignoresSafeArea()) .preferredColorScheme(.dark) .presentationBackground(Color.black) - .presentationDragIndicator(.visible) + .presentationDragIndicator(.hidden) + .presentationDetents([.large]) } } @@ -205,9 +208,17 @@ private struct ForwardPickerHeader: View { if !isMultiSelect { HStack { Spacer() - Button("Select", action: onSelect) - .font(.system(size: 17, weight: .regular)) - .foregroundStyle(.white) + Button(action: onSelect) { + Text("Select") + .font(.system(size: 17, weight: .regular)) + .foregroundStyle(.white) + .padding(.horizontal, 14) + .frame(height: 30) + .background { + Capsule() + .fill(Color(white: 0.16)) + } + } } } } @@ -255,7 +266,7 @@ private struct ForwardPickerSearchBar: View { .frame(height: 44) .background { RoundedRectangle(cornerRadius: 22, style: .continuous) - .fill(Color(white: 0.14)) + .fill(Color.white.opacity(0.1)) } } } @@ -275,7 +286,7 @@ private struct ForwardPickerRow: View { HStack(spacing: 4) { Text(dialog.isSavedMessages ? "Saved Messages" : dialog.opponentTitle) - .font(.system(size: 17, weight: .regular)) + .font(.system(size: 17, weight: .medium)) .foregroundStyle(.white) .lineLimit(1) @@ -361,7 +372,7 @@ private struct ForwardPickerBottomBar: View { .frame(height: 42) .background { RoundedRectangle(cornerRadius: 21, style: .continuous) - .fill(Color(white: 0.14)) + .fill(Color.white.opacity(0.1)) } // Telegram send button: 33pt circle + SVG arrow diff --git a/Rosetta/Features/Chats/ChatDetail/ImageGalleryViewer.swift b/Rosetta/Features/Chats/ChatDetail/ImageGalleryViewer.swift index acb7921..30eafc4 100644 --- a/Rosetta/Features/Chats/ChatDetail/ImageGalleryViewer.swift +++ b/Rosetta/Features/Chats/ChatDetail/ImageGalleryViewer.swift @@ -72,10 +72,9 @@ final class ImageViewerPresenter { // MARK: - ImageGalleryViewer -/// Telegram-style multi-photo gallery viewer with hero transition animation. -/// Reference: PhotosTransition/Helpers/PhotoGridView.swift β€” hero expand/collapse pattern. -/// Android parity: `ImageViewerScreen.kt` β€” top bar with sender/date, -/// bottom caption bar, edge-tap navigation, velocity dismiss, share/save. +/// Multi-photo gallery viewer with hero transition animation. +/// Adapted 1:1 from PhotosTransition/Helpers/PhotoGridView.swift β€” DetailPhotosView. +/// Hero positioning is per-page INSIDE ForEach (not on TabView). struct ImageGalleryViewer: View { let state: ImageViewerState @@ -85,11 +84,8 @@ struct ImageGalleryViewer: View { @State private var showControls = true @State private var currentZoomScale: CGFloat = 1.0 @State private var isDismissing = false - /// Hero transition state: false = positioned at source frame, true = fullscreen. @State private var isExpanded: Bool = false - /// Drag offset for interactive pan-to-dismiss. @State private var dragOffset: CGSize = .zero - /// Full screen dimensions (captured from geometry). @State private var viewSize: CGSize = UIScreen.main.bounds.size private static let dateFormatter: DateFormatter = { @@ -108,95 +104,69 @@ struct ImageGalleryViewer: View { state.images.indices.contains(currentPage) ? state.images[currentPage] : nil } - /// Whether the source frame is valid for hero animation (non-zero). - private var hasHeroSource: Bool { - state.sourceFrame.width > 0 && state.sourceFrame.height > 0 - } - - /// Hero animation spring β€” matches PhotosTransition reference. private var heroAnimation: Animation { .interpolatingSpring(duration: 0.3, bounce: 0, initialVelocity: 0) } - /// Opacity that decreases as user drags further from center. private var interactiveOpacity: CGFloat { let opacityY = abs(dragOffset.height) / (viewSize.height * 0.3) - return max(1 - opacityY, 0) + return isExpanded ? max(1 - opacityY, 0) : 0 } var body: some View { let sourceFrame = state.sourceFrame - ZStack { - // Background β€” fades with hero expansion and drag progress - Color.black - .opacity(isExpanded ? interactiveOpacity : 0) + // Hero positioning per-page inside ForEach β€” matches reference exactly + TabView(selection: $currentPage) { + ForEach(Array(state.images.enumerated()), id: \.element.attachmentId) { index, info in + ZoomableImagePage( + attachmentId: info.attachmentId, + onDismiss: { dismissAction() }, + showControls: $showControls, + currentScale: $currentZoomScale, + onEdgeTap: { direction in navigateEdgeTap(direction: direction) } + ) + .frame( + width: isExpanded ? viewSize.width : sourceFrame.width, + height: isExpanded ? viewSize.height : sourceFrame.height + ) + .clipped() + .offset( + x: isExpanded ? 0 : sourceFrame.minX, + y: isExpanded ? 0 : sourceFrame.minY + ) + .offset(dragOffset) + .frame( + maxWidth: .infinity, + maxHeight: .infinity, + alignment: isExpanded ? .center : .topLeading + ) + .tag(index) .ignoresSafeArea() - - // Pager with hero positioning - TabView(selection: $currentPage) { - ForEach(Array(state.images.enumerated()), id: \.element.attachmentId) { index, info in - ZoomableImagePage( - attachmentId: info.attachmentId, - onDismiss: { dismiss() }, - showControls: $showControls, - currentScale: $currentZoomScale, - onEdgeTap: { direction in - navigateEdgeTap(direction: direction) - } - ) - .tag(index) - } } - .tabViewStyle(.page(indexDisplayMode: .never)) - .scrollDisabled(currentZoomScale > 1.05 || isDismissing) - // Hero frame: source rect when collapsed, full screen when expanded - .frame( - width: isExpanded ? viewSize.width : (hasHeroSource ? sourceFrame.width : viewSize.width), - height: isExpanded ? viewSize.height : (hasHeroSource ? sourceFrame.height : viewSize.height) - ) - .clipped() - .offset( - x: isExpanded ? 0 : (hasHeroSource ? sourceFrame.minX : 0), - y: isExpanded ? 0 : (hasHeroSource ? sourceFrame.minY : 0) - ) - .offset(dragOffset) - .frame( - maxWidth: .infinity, - maxHeight: .infinity, - alignment: isExpanded ? .center : (hasHeroSource ? .topLeading : .center) - ) - .ignoresSafeArea() - // Interactive drag gesture for hero dismiss (vertical only, when not zoomed) - .simultaneousGesture( - currentZoomScale <= 1.05 ? - DragGesture(minimumDistance: 40) - .onChanged { value in - let dy = abs(value.translation.height) - let dx = abs(value.translation.width) - guard dy > dx * 2.0 else { return } - dragOffset = .init(width: value.translation.width, height: value.translation.height) - } - .onEnded { value in - if dragOffset.height > 50 { - heroDismiss() - } else { - withAnimation(heroAnimation.speed(1.2)) { - dragOffset = .zero - } - } - } - : nil - ) - - // Controls overlay β€” fades with hero expansion - controlsOverlay - .opacity(isExpanded ? 1 : 0) - .opacity(interactiveOpacity) } - .statusBarHidden(true) + .tabViewStyle(.page(indexDisplayMode: .never)) + .ignoresSafeArea() + .scrollDisabled(currentZoomScale > 1.05 || isDismissing) + .contentShape(Rectangle()) + .overlay { + // Pan gesture overlay β€” UIKit gesture for iOS 17+ compat + HeroPanGestureOverlay { gesture in + handlePanGesture(gesture) + } + .allowsHitTesting(isExpanded && currentZoomScale <= 1.05) + } + .overlay { + overlayActions + } + .background { + Color.black + .opacity(interactiveOpacity) + .opacity(isExpanded ? 1 : 0) + .ignoresSafeArea() + } .allowsHitTesting(isExpanded) - .onGeometryChange(for: CGSize.self, of: { $0.size }) { viewSize = $0 } + .statusBarHidden(true) .task { prefetchAdjacentImages(around: state.initialIndex) guard !isExpanded else { return } @@ -209,101 +179,95 @@ struct ImageGalleryViewer: View { } } - // MARK: - Controls Overlay + // MARK: - Pan Gesture + + private func handlePanGesture(_ gesture: UIPanGestureRecognizer) { + let panState = gesture.state + let translation = gesture.translation(in: gesture.view) + + if panState == .began || panState == .changed { + dragOffset = .init(width: translation.x, height: translation.y) + } else { + if dragOffset.height > 50 { + heroDismiss() + } else { + withAnimation(heroAnimation.speed(1.2)) { + dragOffset = .zero + } + } + } + } + + // MARK: - Overlay Actions (matches PhotosTransition/ContentView.swift OverlayActionView) @ViewBuilder - private var controlsOverlay: some View { - VStack(spacing: 0) { - if showControls && !isDismissing { - topBar - .transition(.move(edge: .top).combined(with: .opacity)) - } - Spacer() - if showControls && !isDismissing { - bottomBar - .transition(.move(edge: .bottom).combined(with: .opacity)) - } - } - .animation(.easeOut(duration: 0.2), value: showControls) - } + private var overlayActions: some View { + let overlayOpacity: CGFloat = 1 - min(abs(dragOffset.height / 30), 1) - // MARK: - Top Bar + if showControls && !isDismissing && isExpanded { + VStack { + // Top actions + HStack { + glassButton(systemName: "chevron.left") { dismissAction() } - private var topBar: some View { - HStack(spacing: 8) { - Button { dismiss() } label: { - Image(systemName: "chevron.left") - .font(.system(size: 20, weight: .medium)) - .foregroundStyle(.white) - .frame(width: 44, height: 44) - } + Spacer(minLength: 0) - if let info = currentInfo { - VStack(alignment: .leading, spacing: 1) { - Text(info.senderName) - .font(.system(size: 16, weight: .semibold)) - .foregroundStyle(.white) - .lineLimit(1) - - Text(Self.dateFormatter.string(from: info.timestamp)) - .font(.system(size: 13)) - .foregroundStyle(.white.opacity(0.7)) + if state.images.count > 1 { + glassLabel("\(currentPage + 1) / \(state.images.count)") + } } - } - - Spacer() - - if state.images.count > 1 { - Text("\(currentPage + 1) / \(state.images.count)") - .font(.system(size: 15, weight: .medium)) - .foregroundStyle(.white.opacity(0.8)) - .padding(.trailing, 8) - } - } - .padding(.horizontal, 4) - .padding(.vertical, 8) - .background(Color.black.opacity(0.5).ignoresSafeArea(edges: .top)) - } - - // MARK: - Bottom Bar - - private var bottomBar: some View { - VStack(spacing: 0) { - if let caption = currentInfo?.caption, !caption.isEmpty { - Text(caption) - .font(.system(size: 15)) - .foregroundStyle(.white) - .lineLimit(4) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal, 16) - .padding(.vertical, 12) - .background(Color.black.opacity(0.5)) - } - - HStack(spacing: 32) { - Button { shareCurrentImage() } label: { - Image(systemName: "square.and.arrow.up") - .font(.system(size: 20, weight: .medium)) - .foregroundStyle(.white) - .frame(width: 44, height: 44) + .overlay { + if let info = currentInfo { + glassLabel(info.senderName) + .contentTransition(.numericText()) + .animation(.easeInOut, value: currentPage) + .frame(maxWidth: .infinity) + } } - Spacer() + Spacer(minLength: 0) - Button { saveCurrentImage() } label: { - Image(systemName: "square.and.arrow.down") - .font(.system(size: 20, weight: .medium)) - .foregroundStyle(.white) - .frame(width: 44, height: 44) + // Bottom actions + HStack { + glassButton(systemName: "square.and.arrow.up.fill") { shareCurrentImage() } + + Spacer(minLength: 0) + + glassButton(systemName: "square.and.arrow.down") { saveCurrentImage() } } } - .padding(.horizontal, 24) - .padding(.bottom, 8) - .background(Color.black.opacity(0.5).ignoresSafeArea(edges: .bottom)) + .padding(.horizontal, 15) + .compositingGroup() + .opacity(overlayOpacity) + .environment(\.colorScheme, .dark) + .transition(.opacity) + .animation(.easeOut(duration: 0.2), value: showControls) } } - // MARK: - Edge Tap Navigation + // MARK: - Glass Button / Label helpers + + private func glassButton(systemName: String, action: @escaping () -> Void) -> some View { + Button(action: action) { + Image(systemName: systemName) + .font(.title3) + .foregroundStyle(.white) + .frame(width: 36, height: 36) + } + .background { TelegramGlassCircle() } + } + + private func glassLabel(_ text: String) -> some View { + Text(text) + .font(.callout) + .foregroundStyle(.white) + .lineLimit(1) + .padding(.horizontal, 15) + .padding(.vertical, 10) + .background { TelegramGlassCapsule() } + } + + // MARK: - Navigation private func navigateEdgeTap(direction: Int) { let target = currentPage + direction @@ -313,8 +277,7 @@ struct ImageGalleryViewer: View { // MARK: - Dismiss - /// Unified dismiss: hero collapse when not zoomed, fade when zoomed. - private func dismiss() { + private func dismissAction() { if currentZoomScale > 1.05 { fadeDismiss() } else { @@ -322,7 +285,6 @@ struct ImageGalleryViewer: View { } } - /// Hero collapse back to source frame. private func heroDismiss() { guard !isDismissing else { return } isDismissing = true @@ -337,7 +299,6 @@ struct ImageGalleryViewer: View { } } - /// Fallback fade dismiss when zoomed. private func fadeDismiss() { guard !isDismissing else { return } isDismissing = true @@ -402,3 +363,69 @@ struct ImageGalleryViewer: View { } } +// MARK: - HeroPanGestureOverlay + +/// Transparent UIView overlay with UIPanGestureRecognizer for vertical hero dismiss. +/// Uses UIKit gesture (not UIGestureRecognizerRepresentable) for iOS 17+ compat. +/// Matches PanGesture from PhotosTransition reference β€” vertical only, single touch. +private struct HeroPanGestureOverlay: UIViewRepresentable { + var onPan: (UIPanGestureRecognizer) -> Void + + func makeUIView(context: Context) -> UIView { + let view = UIView() + view.backgroundColor = .clear + + let pan = UIPanGestureRecognizer(target: context.coordinator, action: #selector(Coordinator.handlePan(_:))) + pan.minimumNumberOfTouches = 1 + pan.maximumNumberOfTouches = 1 + pan.delegate = context.coordinator + view.addGestureRecognizer(pan) + + return view + } + + func updateUIView(_ uiView: UIView, context: Context) { + context.coordinator.onPan = onPan + } + + func makeCoordinator() -> Coordinator { Coordinator(onPan: onPan) } + + final class Coordinator: NSObject, UIGestureRecognizerDelegate { + var onPan: (UIPanGestureRecognizer) -> Void + + init(onPan: @escaping (UIPanGestureRecognizer) -> Void) { + self.onPan = onPan + } + + @objc func handlePan(_ gesture: UIPanGestureRecognizer) { + onPan(gesture) + } + + // Only begin for downward vertical drags + func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { + guard let pan = gestureRecognizer as? UIPanGestureRecognizer else { return false } + let velocity = pan.velocity(in: pan.view) + return velocity.y > abs(velocity.x) + } + + // Let TabView scroll pass through when scrolled down + func gestureRecognizer( + _ gestureRecognizer: UIGestureRecognizer, + shouldBeRequiredToFailBy otherGestureRecognizer: UIGestureRecognizer + ) -> Bool { + if let scrollView = otherGestureRecognizer.view as? UIScrollView { + return scrollView.contentOffset.y <= 0 + } + return false + } + + // Allow simultaneous recognition with other gestures (taps, pinch, etc.) + func gestureRecognizer( + _ gestureRecognizer: UIGestureRecognizer, + shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer + ) -> Bool { + return !(otherGestureRecognizer is UIPanGestureRecognizer) + } + } +} + diff --git a/Rosetta/RosettaApp.swift b/Rosetta/RosettaApp.swift index 7b196b3..189f7bd 100644 --- a/Rosetta/RosettaApp.swift +++ b/Rosetta/RosettaApp.swift @@ -2,10 +2,16 @@ import FirebaseCore import FirebaseCrashlytics import FirebaseMessaging import Intents +import os import PushKit +import SQLite3 import SwiftUI import UserNotifications +private extension Logger { + static let voip = Logger(subsystem: "com.rosetta.messenger", category: "VoIP") +} + // MARK: - Firebase AppDelegate final class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDelegate, @@ -333,6 +339,44 @@ final class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCent } } + // MARK: - Caller Name from SQLite (VoIP push fallback) + + /// Reads caller display name directly from SQLite when app is killed and + /// UserDefaults hasn't been loaded yet. Database file persists on disk. + static func resolveCallerNameFromDB(callerKey: String, accountKey: String) -> String? { + let key = accountKey.trimmingCharacters(in: .whitespacesAndNewlines) + guard !key.isEmpty else { return nil } + + let normalized = String(key.unicodeScalars.map { + CharacterSet.alphanumerics.contains($0) ? Character($0) : "_" + }) + + let baseURL = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first + ?? URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) + let dbPath = baseURL + .appendingPathComponent("Rosetta/Database/rosetta_\(normalized).sqlite") + .path + + guard FileManager.default.fileExists(atPath: dbPath) else { return nil } + + var db: OpaquePointer? + guard sqlite3_open_v2(dbPath, &db, SQLITE_OPEN_READONLY, nil) == SQLITE_OK else { return nil } + defer { sqlite3_close(db) } + + var stmt: OpaquePointer? + let sql = "SELECT opponent_title, opponent_username FROM dialogs WHERE opponent_key = ? LIMIT 1" + guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return nil } + defer { sqlite3_finalize(stmt) } + + sqlite3_bind_text(stmt, 1, (callerKey as NSString).utf8String, -1, nil) + + guard sqlite3_step(stmt) == SQLITE_ROW else { return nil } + + let title = sqlite3_column_text(stmt, 0).map { String(cString: $0) } ?? "" + let username = sqlite3_column_text(stmt, 1).map { String(cString: $0) } ?? "" + return title.isEmpty ? (username.isEmpty ? nil : username) : title + } + // MARK: - Push Payload Helpers (Android parity) /// Android parity: extract sender public key from multiple possible key names. @@ -484,15 +528,40 @@ extension AppDelegate: PKPushRegistryDelegate { return } let data = payload.dictionaryPayload - let callerKey = data["dialog"] as? String ?? "" - let callerName = data["title"] as? String ?? "Rosetta" + Logger.voip.info("VoIP push received: \(data.description, privacy: .public)") + // Server sends: { "type": "CALL", "from": "", "callId": "" } + // Fallback to "dialog" for backward compat with older server versions. + let callerKey = data["from"] as? String + ?? data["dialog"] as? String + ?? "" + let callId = data["callId"] as? String + // Resolve caller display name from multiple sources. + let callerName: String = { + // 1. Push payload (if server sends title) + if let title = data["title"] as? String, !title.isEmpty { return title } + // 2. UserDefaults (synced by DialogRepository.syncContactNamesToDefaults) + for defaults in [UserDefaults(suiteName: "group.com.rosetta.dev"), UserDefaults.standard] { + if let names = defaults?.dictionary(forKey: "contact_display_names") as? [String: String], + let name = names[callerKey], !name.isEmpty { + return name + } + } + // 3. SQLite direct read (data persists on disk even when app was killed) + if let creds = SessionCredentialsManager.shared.load() { + let name = Self.resolveCallerNameFromDB(callerKey: callerKey, accountKey: creds.publicKey) + if let name, !name.isEmpty { return name } + } + return "Rosetta" + }() + Logger.voip.info("VoIP resolved: key=\(callerKey.prefix(16), privacy: .public) name=\(callerName, privacy: .public) callId=\(callId ?? "nil", privacy: .public)") // Apple REQUIREMENT: reportNewIncomingCall MUST be called SYNCHRONOUSLY. // Using Task { @MainActor } would introduce an async hop that may be // delayed if the main actor is busy, causing Apple to terminate the app. CallKitManager.shared.reportIncomingCallSynchronously( callerKey: callerKey.isEmpty ? "unknown" : callerKey, - callerName: callerName + callerName: callerName, + callId: callId ) { error in completion() @@ -503,11 +572,37 @@ extension AppDelegate: PKPushRegistryDelegate { return } - // Trigger WebSocket reconnection so the actual .call signal - // packet arrives and CallManager can handle the call. Without this, the - // app wakes from killed state but CallManager stays idle β†’ Accept does nothing. + // Restore WebSocket connection so the .call signal packet arrives + // and CallManager can handle the call. When app was killed, SessionManager + // has no credentials in memory β€” load from Keychain (saved during startSession). Task { @MainActor in - if ProtocolManager.shared.connectionState != .authenticated { + // Set up incoming call state from push payload IMMEDIATELY. + // Don't wait for WebSocket .call signal β€” it's fire-and-forget + // and may have been sent before our WebSocket connected. + if !callerKey.isEmpty, CallManager.shared.uiState.phase == .idle { + // Ensure account is bound for acceptIncomingCall() + if CallManager.shared.ownPublicKey.isEmpty, + let creds = SessionCredentialsManager.shared.load() { + CallManager.shared.bindAccount(publicKey: creds.publicKey) + } + CallManager.shared.setupIncomingCallFromPush( + callerKey: callerKey, + callerName: callerName + ) + } + + // Restore WebSocket so keyExchange can be sent when user accepts. + if ProtocolManager.shared.connectionState == .authenticated { + return + } + if ProtocolManager.shared.publicKey == nil, + let creds = SessionCredentialsManager.shared.load() { + Logger.voip.info("Restoring session from Keychain for VoIP wake-up") + ProtocolManager.shared.connect( + publicKey: creds.publicKey, + privateKeyHash: creds.privateKeyHash + ) + } else { ProtocolManager.shared.forceReconnectOnForeground() } }