From 8f69781a66d0157c9a7d63a1ddf41a809e2c5d75 Mon Sep 17 00:00:00 2001 From: senseiGai Date: Wed, 1 Apr 2026 00:39:34 +0500 Subject: [PATCH] =?UTF-8?q?CallKit/PushKit=20=D0=B8=D0=BD=D1=82=D0=B5?= =?UTF-8?q?=D0=B3=D1=80=D0=B0=D1=86=D0=B8=D1=8F=20+=20=D1=84=D0=B8=D0=BA?= =?UTF-8?q?=D1=81=20PacketPushNotification=20(tokenType,=20deviceId)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 2 + .../Data/Repositories/MessageRepository.swift | 15 +- Rosetta/Core/Layout/MessageCellLayout.swift | 6 +- .../Packets/PacketPushNotification.swift | 18 +- .../Network/Protocol/ProtocolManager.swift | 94 +++++-- .../Network/Protocol/WebSocketClient.swift | 13 +- Rosetta/Core/Services/CallKitManager.swift | 250 +++++++++++++++++ .../Core/Services/CallManager+Runtime.swift | 13 + Rosetta/Core/Services/CallManager.swift | 14 + Rosetta/Core/Services/SessionManager.swift | 131 +++++++-- .../Chats/ChatDetail/ChatDetailView.swift | 10 +- .../ChatDetail/ChatDetailViewModel.swift | 19 ++ .../Chats/ChatDetail/NativeMessageList.swift | 25 +- Rosetta/RosettaApp.swift | 92 +++++++ RosettaTests/CallPushIntegrationTests.swift | 252 ++++++++++++++++++ .../PushNotificationPacketTests.swift | 167 ++++++++++++ 16 files changed, 1058 insertions(+), 63 deletions(-) create mode 100644 Rosetta/Core/Services/CallKitManager.swift create mode 100644 RosettaTests/CallPushIntegrationTests.swift create mode 100644 RosettaTests/PushNotificationPacketTests.swift diff --git a/.gitignore b/.gitignore index f407a48..acddb13 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,8 @@ server docs Telegram-iOS AGENTS.md +voip.p12 +CertificateSigningRequest.certSigningRequest # Xcode build/ diff --git a/Rosetta/Core/Data/Repositories/MessageRepository.swift b/Rosetta/Core/Data/Repositories/MessageRepository.swift index 7357b3e..f0179b8 100644 --- a/Rosetta/Core/Data/Repositories/MessageRepository.swift +++ b/Rosetta/Core/Data/Repositories/MessageRepository.swift @@ -441,8 +441,19 @@ final class MessageRepository: ObservableObject { } } - // Debounced cache refresh β€” batch during sync - scheduleCacheRefresh(for: opponentKey) + // Outgoing user-sent messages: immediate cache refresh (bypass 100ms debounce) + // so the bubble appears instantly. Sync/incoming still use debounced path. + if fromMe && !fromSync { + refreshDialogCache(for: opponentKey) + NotificationCenter.default.post( + name: .sentMessageInserted, + object: nil, + userInfo: ["opponentKey": opponentKey] + ) + } else { + // Debounced cache refresh β€” batch during sync + scheduleCacheRefresh(for: opponentKey) + } } func deliveryStatus(forMessageId messageId: String) -> DeliveryStatus? { diff --git a/Rosetta/Core/Layout/MessageCellLayout.swift b/Rosetta/Core/Layout/MessageCellLayout.swift index 337c099..373dd06 100644 --- a/Rosetta/Core/Layout/MessageCellLayout.swift +++ b/Rosetta/Core/Layout/MessageCellLayout.swift @@ -861,7 +861,9 @@ extension MessageCellLayout { : "" // Filter garbage/encrypted text (UIKit path parity with SwiftUI MessageCellView) - let displayText = isGarbageOrEncrypted(message.text) ? "" : message.text + // Convert emoji shortcodes (:emoji_1f631: β†’ 😱) β€” Android/Desktop send shortcodes. + let rawText = isGarbageOrEncrypted(message.text) ? "" : message.text + let displayText = EmojiParser.replaceShortcodes(in: rawText) // Calculate position (Telegram-like grouping rules) let position: BubblePosition = { @@ -916,7 +918,7 @@ extension MessageCellLayout { let first = replies.first { let fwdText = first.message.trimmingCharacters(in: .whitespacesAndNewlines) if !fwdText.isEmpty && !isGarbageOrEncrypted(fwdText) { - forwardCaption = fwdText + forwardCaption = EmojiParser.replaceShortcodes(in: fwdText) } forwardInnerImageCount = first.attachments.filter { $0.type == 0 }.count forwardInnerFileCount = first.attachments.filter { $0.type == 2 }.count diff --git a/Rosetta/Core/Network/Protocol/Packets/PacketPushNotification.swift b/Rosetta/Core/Network/Protocol/Packets/PacketPushNotification.swift index 1f668ff..91a925e 100644 --- a/Rosetta/Core/Network/Protocol/Packets/PacketPushNotification.swift +++ b/Rosetta/Core/Network/Protocol/Packets/PacketPushNotification.swift @@ -6,23 +6,35 @@ enum PushNotificationAction: Int { case unsubscribe = 1 } +/// Token type for push notification registration. +/// Server parity: im.rosetta.packet.runtime.TokenType +enum PushTokenType: Int { + case fcm = 0 // FCM token (iOS + Android) + case voipApns = 1 // VoIP APNs token (iOS only) +} + /// PushNotification packet (0x10) β€” registers or unregisters APNs/FCM token on server. /// Sent after successful handshake to enable push notifications. -/// Cross-platform compatible with Android PacketPushNotification. +/// Server stores tokens at device level (PushToken entity linked to Device). struct PacketPushNotification: Packet { static let packetId = 0x10 var notificationsToken: String = "" var action: PushNotificationAction = .subscribe + var tokenType: PushTokenType = .fcm + var deviceId: String = "" func write(to stream: Stream) { stream.writeString(notificationsToken) stream.writeInt8(action.rawValue) + stream.writeInt8(tokenType.rawValue) + stream.writeString(deviceId) } mutating func read(from stream: Stream) { notificationsToken = stream.readString() - let actionValue = stream.readInt8() - action = PushNotificationAction(rawValue: actionValue) ?? .subscribe + action = PushNotificationAction(rawValue: stream.readInt8()) ?? .subscribe + tokenType = PushTokenType(rawValue: stream.readInt8()) ?? .fcm + deviceId = stream.readString() } } diff --git a/Rosetta/Core/Network/Protocol/ProtocolManager.swift b/Rosetta/Core/Network/Protocol/ProtocolManager.swift index 8a65a8d..7905dd1 100644 --- a/Rosetta/Core/Network/Protocol/ProtocolManager.swift +++ b/Rosetta/Core/Network/Protocol/ProtocolManager.swift @@ -98,6 +98,12 @@ final class ProtocolManager: @unchecked Sendable { // Saved credentials for auto-reconnect private var savedPublicKey: String? private var savedPrivateHash: String? + /// Pre-built handshake packet for instant send on socket open. + /// Built once in connect() on MainActor (safe UIDevice access), reused across reconnects. + private var cachedHandshakeData: Data? + /// Timestamp of last successful authentication β€” used to decide whether to reset backoff. + /// If connection was short-lived (<10s), don't reset backoff counter (server RST loop). + private var lastAuthenticatedTime: CFAbsoluteTime = 0 var publicKey: String? { savedPublicKey } var privateHash: String? { savedPrivateHash } @@ -118,6 +124,7 @@ final class ProtocolManager: @unchecked Sendable { savedPublicKey = publicKey savedPrivateHash = privateKeyHash + cachedHandshakeData = buildHandshakeData() switch connectionState { case .authenticated, .handshaking, .deviceVerificationRequired: @@ -163,6 +170,8 @@ final class ProtocolManager: @unchecked Sendable { connectionState = .disconnected savedPublicKey = nil savedPrivateHash = nil + cachedHandshakeData = nil + lastAuthenticatedTime = 0 Task { @MainActor in TransportManager.shared.reset() } @@ -194,6 +203,8 @@ final class ProtocolManager: @unchecked Sendable { handshakeComplete = false heartbeatTask?.cancel() searchRouter.resetPending() + // User-initiated foreground β€” allow fast retry on next disconnect. + lastAuthenticatedTime = 0 connectionState = .connecting client.forceReconnect() } @@ -419,8 +430,16 @@ final class ProtocolManager: @unchecked Sendable { self.connectionState = .connected } - // Auto-handshake with saved credentials - if let pk = savedPublicKey, let hash = savedPrivateHash { + // Send pre-built handshake immediately β€” no packet construction on critical path. + if let data = cachedHandshakeData { + Self.logger.info("⚑ Sending pre-built handshake packet") + Task { @MainActor in + self.connectionState = .handshaking + } + client.send(data) + startHandshakeTimeout() + } else if let pk = savedPublicKey, let hash = savedPrivateHash { + // Fallback: build handshake on the fly startHandshake(publicKey: pk, privateHash: hash) } } @@ -468,6 +487,27 @@ final class ProtocolManager: @unchecked Sendable { // MARK: - Handshake + /// Build serialized handshake packet from saved credentials. + /// Called from MainActor context β€” safe to access UIDevice. + private func buildHandshakeData() -> Data? { + guard let pk = savedPublicKey, let hash = savedPrivateHash else { return nil } + let device = HandshakeDevice( + deviceId: DeviceIdentityManager.shared.currentDeviceId(), + deviceName: UIDevice.current.name, + deviceOs: "iOS \(UIDevice.current.systemVersion)" + ) + let handshake = PacketHandshake( + privateKey: hash, + publicKey: pk, + protocolVersion: 1, + heartbeatInterval: 15, + device: device, + handshakeState: .needDeviceVerification + ) + return PacketRegistry.encode(handshake) + } + + /// Fallback handshake β€” builds packet on the fly when cached data is unavailable. private func startHandshake(publicKey: String, privateHash: String) { Self.logger.info("Starting handshake for \(publicKey.prefix(20))...") @@ -491,24 +531,25 @@ final class ProtocolManager: @unchecked Sendable { ) sendPacketDirect(handshake) + startHandshakeTimeout() + } - // Timeout β€” force reconnect instead of permanent disconnect. - // `client.disconnect()` sets `isManuallyClosed = true` which kills all - // future reconnection attempts. Use `forceReconnect()` to retry. + private func startHandshakeTimeout() { + // 5s is generous for a single packet round-trip. Faster detection + // means faster recovery via instant first retry (0ms backoff). handshakeTimeoutTask?.cancel() handshakeTimeoutTask = Task { [weak self] in do { - try await Task.sleep(nanoseconds: 10_000_000_000) + try await Task.sleep(nanoseconds: 5_000_000_000) } catch { return } guard let self, !Task.isCancelled else { return } if !self.handshakeComplete { - Self.logger.error("Handshake timeout β€” forcing reconnect") + Self.logger.error("Handshake timeout (5s) β€” forcing reconnect") self.handshakeComplete = false self.heartbeatTask?.cancel() Task { @MainActor in - // Guard: only downgrade to .connecting if reconnect hasn't already progressed. let s = self.connectionState if s != .authenticated && s != .handshaking && s != .connected { self.connectionState = .connecting @@ -719,8 +760,17 @@ final class ProtocolManager: @unchecked Sendable { switch packet.handshakeState { case .completed: handshakeComplete = true - // Android parity: reset backoff counter on successful authentication. - client.resetReconnectAttempts() + // Reset backoff only if previous connection was stable (>10s). + // Prevents tight reconnect loop when server/proxy RSTs connections + // shortly after sync. Without this, resetReconnectAttempts on every auth + // means backoff always starts at 1s (attempt #1) = infinite 1s loop. + let connectionAge = CFAbsoluteTimeGetCurrent() - lastAuthenticatedTime + if lastAuthenticatedTime == 0 || connectionAge > 10 { + client.resetReconnectAttempts() + } else { + Self.logger.info("Short-lived connection (\(Int(connectionAge))s) β€” keeping backoff counter") + } + lastAuthenticatedTime = CFAbsoluteTimeGetCurrent() Self.logger.info("Handshake completed. Protocol v\(packet.protocolVersion), heartbeat \(packet.heartbeatInterval)s") flushPacketQueue() @@ -758,13 +808,20 @@ final class ProtocolManager: @unchecked Sendable { private func startHeartbeat(interval: Int) { heartbeatTask?.cancel() - // Android parity: heartbeat at 1/3 the server-specified interval (more aggressive keep-alive). - let intervalNs = UInt64(interval) * 1_000_000_000 / 3 + // Send heartbeat every 5 seconds β€” aggressive keep-alive to prevent + // server/proxy idle timeouts. Server timeout is heartbeat*2 = 60s, + // so 5s gives 12Γ— safety margin. + let intervalNs: UInt64 = 5_000_000_000 + + // Send first heartbeat SYNCHRONOUSLY on current thread (URLSession delegate queue). + // This bypasses the connectionState race: startHeartbeat() is called BEFORE + // the MainActor task sets .authenticated, so sendHeartbeat()'s guard would + // skip the first heartbeat. Direct sendText avoids this. + if client.isConnected { + client.sendText("heartbeat") + } heartbeatTask = Task { [weak self] in - // Send first heartbeat immediately - self?.sendHeartbeat() - while !Task.isCancelled { try? await Task.sleep(nanoseconds: intervalNs) guard !Task.isCancelled else { break } @@ -773,10 +830,11 @@ final class ProtocolManager: @unchecked Sendable { } } - /// Android parity: send heartbeat and trigger disconnect on failure. + /// Send heartbeat and trigger disconnect on failure. private func sendHeartbeat() { - let state = connectionState - guard state == .authenticated || state == .deviceVerificationRequired else { return } + // Allow heartbeat when handshake is complete (covers the gap before + // MainActor sets .authenticated) or in device verification. + guard handshakeComplete || connectionState == .deviceVerificationRequired else { return } guard client.isConnected else { Self.logger.warning("πŸ’” Heartbeat failed: socket not connected β€” triggering reconnect") handleHeartbeatFailure() diff --git a/Rosetta/Core/Network/Protocol/WebSocketClient.swift b/Rosetta/Core/Network/Protocol/WebSocketClient.swift index dbdfd09..8234167 100644 --- a/Rosetta/Core/Network/Protocol/WebSocketClient.swift +++ b/Rosetta/Core/Network/Protocol/WebSocketClient.swift @@ -95,14 +95,13 @@ final class WebSocketClient: NSObject, @unchecked Sendable, URLSessionWebSocketD receiveLoop() - // Safety net: if didOpenWithProtocol never fires within 15s, clean up - // and trigger reconnect. Matches URLSession's timeoutIntervalForResource - // but provides better logging and guaranteed cleanup of isConnecting flag. + // Safety net: if didOpenWithProtocol never fires within 8s, clean up + // and trigger reconnect. 8s is generous for TCP+TLS even on slow cellular. connectTimeoutTask?.cancel() connectTimeoutTask = Task { [weak self] in - try? await Task.sleep(nanoseconds: 15_000_000_000) + try? await Task.sleep(nanoseconds: 8_000_000_000) guard let self, !Task.isCancelled, self.isConnecting else { return } - Self.logger.warning("Connection establishment timeout (15s)") + Self.logger.warning("Connection establishment timeout (8s)") self.interruptConnecting() self.webSocketTask?.cancel( with: .normalClosure, @@ -299,8 +298,8 @@ final class WebSocketClient: NSObject, @unchecked Sendable, URLSessionWebSocketD guard !isManuallyClosed else { return } guard reconnectTask == nil else { return } - // Android parity: exponential backoff β€” 1s, 2s, 4s, 8s, 16s, 30s (cap). - // No instant first attempt. Formula: min(1000 * 2^(n-1), 30000). + // Exponential backoff: 1s, 2s, 4s, 8s, 16s, 30s (cap). + // forceReconnect() resets counter for instant retry on user action. reconnectAttempts += 1 if reconnectAttempts > 20 { diff --git a/Rosetta/Core/Services/CallKitManager.swift b/Rosetta/Core/Services/CallKitManager.swift new file mode 100644 index 0000000..246df50 --- /dev/null +++ b/Rosetta/Core/Services/CallKitManager.swift @@ -0,0 +1,250 @@ +import AVFAudio +import CallKit +import os + +/// CallKit integration layer β€” wraps CXProvider and CXCallController. +/// Reports incoming/outgoing calls to the system so they appear in the native call UI, +/// integrate with CarPlay, and satisfy Apple's PushKit requirement (every VoIP push +/// MUST result in reportNewIncomingCall or the app gets terminated). +/// +/// This class does NOT own call logic β€” it delegates to CallManager for actual call operations. +@MainActor +final class CallKitManager: NSObject { + + static let shared = CallKitManager() + + private static let logger = Logger(subsystem: "com.rosetta.messenger", category: "CallKit") + + private let provider: CXProvider + private let callController = CXCallController() + private(set) var currentCallUUID: UUID? + + /// Thread-safe UUID storage for synchronous PushKit access. + /// Prevents race where WebSocket signal arrives before MainActor assigns currentCallUUID. + /// Protected by uuidLock β€” accessed from nonisolated PushKit delegate methods. + private nonisolated(unsafe) let uuidLock = NSLock() + private nonisolated(unsafe) var _pendingCallUUID: UUID? + + private override init() { + let config = CXProviderConfiguration() + config.supportsVideo = false + config.maximumCallsPerCallGroup = 1 + config.maximumCallGroups = 1 + config.supportedHandleTypes = [.generic] + // Privacy: don't write peer public keys to system call log / iCloud. + config.includesCallsInRecents = false + provider = CXProvider(configuration: config) + super.init() + provider.setDelegate(self, queue: nil) + } + + /// Thread-safe check if a call UUID is pending (set synchronously from PushKit). + nonisolated func hasPendingCall() -> Bool { + uuidLock.lock() + let has = _pendingCallUUID != nil + uuidLock.unlock() + return has + } + + // MARK: - Incoming Call (synchronous β€” for PushKit) + + /// Reports an incoming call to CallKit SYNCHRONOUSLY. Called directly from + /// PushKit delegate (NOT via Task/@MainActor) to meet Apple's requirement + /// that reportNewIncomingCall is invoked before the PushKit handler returns. + nonisolated func reportIncomingCallSynchronously( + callerKey: String, + callerName: String, + completion: @escaping (Error?) -> Void + ) { + let uuid = UUID() + + // Assign UUID synchronously to prevent race with WebSocket signal. + uuidLock.lock() + _pendingCallUUID = uuid + uuidLock.unlock() + + let update = CXCallUpdate() + update.remoteHandle = CXHandle(type: .generic, value: callerKey) + update.localizedCallerName = callerName.isEmpty ? "Rosetta" : callerName + update.hasVideo = false + update.supportsHolding = false + update.supportsGrouping = false + update.supportsUngrouping = false + update.supportsDTMF = false + + provider.reportNewIncomingCall(with: uuid, update: update) { [weak self] error in + if let error { + Self.logger.error("Failed to report incoming call: \(error.localizedDescription)") + self?.uuidLock.lock() + self?._pendingCallUUID = nil + self?.uuidLock.unlock() + } else { + Self.logger.info("Incoming call reported to CallKit (uuid=\(uuid.uuidString.prefix(8)))") + } + // Assign to MainActor-isolated property. + Task { @MainActor in + if error == nil { + self?.currentCallUUID = uuid + } + } + completion(error) + } + } + + // MARK: - Incoming Call (from WebSocket signal) + + /// Reports an incoming call to CallKit. Called from CallManager when a `.call` + /// signal arrives via WebSocket (app already running, MainActor available). + func reportIncomingCall( + callerKey: String, + callerName: String, + completion: ((Error?) -> Void)? = nil + ) { + let uuid = currentCallUUID ?? UUID() + currentCallUUID = uuid + + let update = CXCallUpdate() + update.remoteHandle = CXHandle(type: .generic, value: callerKey) + update.localizedCallerName = callerName.isEmpty ? "Rosetta" : callerName + update.hasVideo = false + update.supportsHolding = false + update.supportsGrouping = false + update.supportsUngrouping = false + update.supportsDTMF = false + + provider.reportNewIncomingCall(with: uuid, update: update) { [weak self] error in + if let error { + Self.logger.error("Failed to report incoming call: \(error.localizedDescription)") + Task { @MainActor in + self?.currentCallUUID = nil + } + } else { + Self.logger.info("Incoming call reported to CallKit (uuid=\(uuid.uuidString.prefix(8)))") + } + completion?(error) + } + } + + // MARK: - Outgoing Call + + func startOutgoingCall(peerKey: String) { + let uuid = UUID() + currentCallUUID = uuid + + let handle = CXHandle(type: .generic, value: peerKey) + let action = CXStartCallAction(call: uuid, handle: handle) + action.isVideo = false + + callController.request(CXTransaction(action: action)) { error in + if let error { + Self.logger.error("Failed to start outgoing call: \(error.localizedDescription)") + } + } + + provider.reportOutgoingCall(with: uuid, startedConnectingAt: nil) + } + + func reportOutgoingCallConnected() { + guard let uuid = currentCallUUID else { return } + provider.reportOutgoingCall(with: uuid, connectedAt: nil) + } + + // MARK: - End Call + + func endCall() { + guard let uuid = currentCallUUID else { return } + currentCallUUID = nil + uuidLock.lock() + _pendingCallUUID = nil + uuidLock.unlock() + + let action = CXEndCallAction(call: uuid) + callController.request(CXTransaction(action: action)) { error in + if let error { + Self.logger.warning("CXEndCallAction failed: \(error.localizedDescription)") + self.provider.reportCall(with: uuid, endedAt: nil, reason: .remoteEnded) + } + } + } + + func reportCallEndedByRemote(reason: CXCallEndedReason = .remoteEnded) { + guard let uuid = currentCallUUID else { return } + currentCallUUID = nil + uuidLock.lock() + _pendingCallUUID = nil + uuidLock.unlock() + provider.reportCall(with: uuid, endedAt: nil, reason: reason) + } + + // MARK: - Mute + + func setMuted(_ muted: Bool) { + guard let uuid = currentCallUUID else { return } + let action = CXSetMutedCallAction(call: uuid, muted: muted) + callController.request(CXTransaction(action: action)) { error in + if let error { + Self.logger.warning("CXSetMutedCallAction failed: \(error.localizedDescription)") + } + } + } +} + +// MARK: - CXProviderDelegate + +extension CallKitManager: CXProviderDelegate { + + nonisolated func providerDidReset(_ provider: CXProvider) { + Self.logger.info("CXProvider did reset") + Task { @MainActor in + self.currentCallUUID = nil + self.uuidLock.lock() + self._pendingCallUUID = nil + self.uuidLock.unlock() + // notifyPeer: false β€” provider reset is system-initiated, peer connection + // is already gone. Sending endCall signal would be spurious. + CallManager.shared.finishCall(reason: nil, notifyPeer: false) + } + } + + nonisolated func provider(_ provider: CXProvider, perform action: CXAnswerCallAction) { + Self.logger.info("CXAnswerCallAction") + Task { @MainActor in + CallManager.shared.acceptIncomingCall() + action.fulfill() + } + } + + nonisolated func provider(_ provider: CXProvider, perform action: CXEndCallAction) { + Self.logger.info("CXEndCallAction") + action.fulfill() + Task { @MainActor in + self.currentCallUUID = nil + self.uuidLock.lock() + self._pendingCallUUID = nil + self.uuidLock.unlock() + CallManager.shared.endCall() + } + } + + nonisolated func provider(_ provider: CXProvider, perform action: CXSetMutedCallAction) { + Task { @MainActor in + if CallManager.shared.uiState.isMuted != action.isMuted { + CallManager.shared.toggleMute() + } + action.fulfill() + } + } + + nonisolated func provider(_ provider: CXProvider, perform action: CXStartCallAction) { + Self.logger.info("CXStartCallAction") + action.fulfill() + } + + nonisolated func provider(_ provider: CXProvider, didActivate audioSession: AVAudioSession) { + Self.logger.info("Audio session activated by CallKit") + } + + nonisolated func provider(_ provider: CXProvider, didDeactivate audioSession: AVAudioSession) { + Self.logger.info("Audio session deactivated by CallKit") + } +} diff --git a/Rosetta/Core/Services/CallManager+Runtime.swift b/Rosetta/Core/Services/CallManager+Runtime.swift index f1d66f8..ad89486 100644 --- a/Rosetta/Core/Services/CallManager+Runtime.swift +++ b/Rosetta/Core/Services/CallManager+Runtime.swift @@ -83,10 +83,23 @@ extension CallManager { } func finishCall(reason: String?, notifyPeer: Bool, skipAttachment: Bool = false) { + // Guard: finishCall can be called twice when CXEndCallAction callback + // re-enters via CallManager.endCall(). Skip if already idle. + guard uiState.phase != .idle else { return } + 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)") + + // Report call ended to CallKit. Use reportCallEndedByRemote when we're not + // the initiator of the end (avoids CXEndCallAction β†’ endCall() loop). + if notifyPeer { + CallKitManager.shared.endCall() + } else { + CallKitManager.shared.reportCallEndedByRemote() + } + pendingMinimizeTask?.cancel() pendingMinimizeTask = nil cancelRingTimeout() diff --git a/Rosetta/Core/Services/CallManager.swift b/Rosetta/Core/Services/CallManager.swift index 988b488..9abac32 100644 --- a/Rosetta/Core/Services/CallManager.swift +++ b/Rosetta/Core/Services/CallManager.swift @@ -79,6 +79,8 @@ final class CallManager: NSObject, ObservableObject { uiState.phase = .outgoing uiState.statusText = "Calling..." + CallKitManager.shared.startOutgoingCall(peerKey: target) + ProtocolManager.shared.sendCallSignal( signalType: .call, src: ownPublicKey, @@ -135,6 +137,7 @@ final class CallManager: NSObject, ObservableObject { let nextMuted = !uiState.isMuted uiState.isMuted = nextMuted localAudioTrack?.isEnabled = !nextMuted + CallKitManager.shared.setMuted(nextMuted) updateLiveActivity() print("[Call] toggleMute: isMuted=\(nextMuted), trackEnabled=\(localAudioTrack?.isEnabled ?? false), trackState=\(localAudioTrack?.readyState.rawValue ?? -1)") } @@ -229,6 +232,16 @@ final class CallManager: NSObject, ObservableObject { } uiState.statusText = "Incoming call..." hydratePeerIdentity(for: incomingPeer) + // Report to CallKit (skipped if already reported via VoIP push). + // Use hasPendingCall() for thread-safe check β€” PushKit sets the UUID + // synchronously before MainActor assigns currentCallUUID. + if CallKitManager.shared.currentCallUUID == nil, + !CallKitManager.shared.hasPendingCall() { + CallKitManager.shared.reportIncomingCall( + callerKey: incomingPeer, + callerName: uiState.displayName + ) + } CallSoundManager.shared.playRingtone() startRingTimeout() startLiveActivity() @@ -324,6 +337,7 @@ final class CallManager: NSObject, ObservableObject { uiState.statusText = "Call active" cancelRingTimeout() CallSoundManager.shared.playConnected() + CallKitManager.shared.reportOutgoingCallConnected() startDurationTimerIfNeeded() updateLiveActivity() } diff --git a/Rosetta/Core/Services/SessionManager.swift b/Rosetta/Core/Services/SessionManager.swift index a21e262..6d96560 100644 --- a/Rosetta/Core/Services/SessionManager.swift +++ b/Rosetta/Core/Services/SessionManager.swift @@ -66,6 +66,9 @@ final class SessionManager { private let maxOutgoingWaitingLifetimeMs: Int64 = ProtocolConstants.messageDeliveryTimeoutS * 1000 var attachmentFlowTransport: AttachmentFlowTransporting = LiveAttachmentFlowTransport() var packetFlowSender: PacketFlowSending = LivePacketFlowSender() + /// Guards onHandshakeCompleted from running before repositories are ready. + /// Set after DialogRepository + MessageRepository bootstraps complete in startSession(). + private(set) var repositoriesReady = false // MARK: - Foreground & Idle Detection (Desktop/Android parity) @@ -177,8 +180,13 @@ final class SessionManager { func startSession(password: String) async throws { let accountManager = AccountManager.shared let crypto = CryptoManager.shared + repositoriesReady = false - // Decrypt private key + #if DEBUG + let sessionStart = CFAbsoluteTimeGetCurrent() + #endif + + // 1. Decrypt private key let privateKeyHex: String do { privateKeyHex = try await accountManager.decryptPrivateKey(password: password) @@ -188,13 +196,16 @@ final class SessionManager { self.privateKeyHex = privateKeyHex // Android parity: provide private key to caches for encryption at rest AttachmentCache.shared.privateKey = privateKeyHex - Self.logger.info("Private key decrypted") + + #if DEBUG + Self.logger.info("⏱ CONN_PERF: decryptPrivateKey \(Int((CFAbsoluteTimeGetCurrent() - sessionStart) * 1000))ms") + #endif guard let account = accountManager.currentAccount else { throw CryptoError.decryptionFailed } - // Open SQLite database for this account (must happen before repository bootstrap). + // 2. Open SQLite database for this account (must happen before repository bootstrap). do { try DatabaseManager.shared.bootstrap(accountPublicKey: account.publicKey) } catch { @@ -204,12 +215,28 @@ final class SessionManager { throw StartSessionError.databaseBootstrapFailed(underlying: error) } + #if DEBUG + Self.logger.info("⏱ CONN_PERF: databaseBootstrap \(Int((CFAbsoluteTimeGetCurrent() - sessionStart) * 1000))ms") + #endif + + // 3. Generate hash + start TCP+TLS EARLY β€” overlaps with repository bootstraps. + // connect() is non-blocking: creates URLSessionWebSocketTask and returns immediately. + // TCP+TLS handshake (200-500ms) runs in parallel with steps 4-6 below. + let hash = crypto.generatePrivateKeyHash(privateKeyHex: privateKeyHex) + privateKeyHash = hash + ProtocolManager.shared.connect(publicKey: account.publicKey, privateKeyHash: hash) + + #if DEBUG + Self.logger.info("⏱ CONN_PERF: connectCalled \(Int((CFAbsoluteTimeGetCurrent() - sessionStart) * 1000))ms") + #endif + + // 4. Setup account state (fast, in-memory only) currentPublicKey = account.publicKey displayName = account.displayName ?? "" username = account.username ?? "" CallManager.shared.bindAccount(publicKey: account.publicKey) - // Migrate legacy JSON β†’ SQLite on first launch (before repositories read from DB). + // 5. Migrate legacy JSON β†’ SQLite on first launch (before repositories read from DB). let migrated = await DatabaseMigrationFromJSON.migrateIfNeeded( accountPublicKey: account.publicKey, storagePassword: privateKeyHex @@ -218,15 +245,24 @@ final class SessionManager { Self.logger.info("Migrated \(migrated) messages from JSON to SQLite") } - // Warm local state immediately, then let network sync reconcile updates. - await DialogRepository.shared.bootstrap( + // 6. Parallel repository bootstraps β€” TCP+TLS runs concurrently in background. + // GRDB DatabasePool supports concurrent reads via WAL mode. No shared state. + async let dialogBoot: Void = DialogRepository.shared.bootstrap( accountPublicKey: account.publicKey, storagePassword: privateKeyHex ) - await MessageRepository.shared.bootstrap( + async let messageBoot: Void = MessageRepository.shared.bootstrap( accountPublicKey: account.publicKey, storagePassword: privateKeyHex ) + await dialogBoot + await messageBoot + repositoriesReady = true + + #if DEBUG + Self.logger.info("⏱ CONN_PERF: repositoriesReady \(Int((CFAbsoluteTimeGetCurrent() - sessionStart) * 1000))ms") + #endif + RecentSearchesRepository.shared.setAccount(account.publicKey) // Desktop parity: send release notes as a system message from "Rosetta Updates" @@ -242,16 +278,11 @@ final class SessionManager { _ = CryptoManager.shared.cachedPBKDF2(password: pkForCache) } - // Generate private key hash for handshake - let hash = crypto.generatePrivateKeyHash(privateKeyHex: privateKeyHex) - privateKeyHash = hash - - Self.logger.info("Connecting to server...") - - // Connect + handshake - ProtocolManager.shared.connect(publicKey: account.publicKey, privateKeyHash: hash) - isAuthenticated = true + + #if DEBUG + Self.logger.info("⏱ CONN_PERF: sessionReady \(Int((CFAbsoluteTimeGetCurrent() - sessionStart) * 1000))ms") + #endif } // MARK: - Message Sending @@ -1088,6 +1119,7 @@ final class SessionManager { privateKeyHash = nil privateKeyHex = nil lastTypingSentAt.removeAll() + repositoriesReady = false syncBatchInProgress = false syncRequestInFlight = false pendingSyncReads.removeAll() @@ -1247,6 +1279,25 @@ final class SessionManager { Task { @MainActor in Self.logger.info("Handshake completed") + // Wait for repositories if connect() was started early (Phase 4 optimization). + // TCP+TLS (200-500ms) almost always exceeds repository bootstrap (50-150ms), + // so this loop rarely executes. 10s safety timeout prevents infinite wait. + if !self.repositoriesReady { + Self.logger.info("⏳ Waiting for repositories to finish bootstrap...") + var waitCount = 0 + while !self.repositoriesReady { + waitCount += 1 + if waitCount > 1000 { // 10s safety timeout (1000 Γ— 10ms) + Self.logger.error("Repository bootstrap timeout β€” proceeding anyway") + break + } + try? await Task.sleep(for: .milliseconds(10)) + } + if waitCount > 0 { + Self.logger.info("⏱ Repository wait: \(waitCount * 10)ms") + } + } + guard let hash = self.privateKeyHash else { return } // Only send UserInfo if we have profile data to update @@ -1295,8 +1346,9 @@ final class SessionManager { self.requestedUserInfoKeys.removeAll() self.onlineSubscribedKeys.removeAll() - // Send push token to server for push notifications (Android parity). + // Send push tokens to server for push notifications (Android parity). self.sendPushTokenToServer() + self.sendVoIPTokenToServer() CallManager.shared.onAuthenticated() // Desktop parity: user info refresh is deferred until sync completes. @@ -2469,8 +2521,51 @@ final class SessionManager { var packet = PacketPushNotification() packet.notificationsToken = token packet.action = .subscribe + packet.tokenType = .fcm + packet.deviceId = DeviceIdentityManager.shared.currentDeviceId() ProtocolManager.shared.sendPacket(packet) - Self.logger.info("Push token sent to server") + Self.logger.info("FCM push token sent to server") + } + + // MARK: - VoIP Push Token (PushKit) + + /// Stores the VoIP push token received from PushKit. + func setVoIPToken(_ token: String) { + UserDefaults.standard.set(token, forKey: "voip_push_token") + if ProtocolManager.shared.connectionState == .authenticated { + sendVoIPTokenToServer() + } + } + + /// Sends the stored VoIP push token to the server via PacketPushNotification (0x10). + private func sendVoIPTokenToServer() { + guard let token = UserDefaults.standard.string(forKey: "voip_push_token"), + !token.isEmpty, + ProtocolManager.shared.connectionState == .authenticated + else { return } + + var packet = PacketPushNotification() + packet.notificationsToken = token + packet.action = .subscribe + packet.tokenType = .voipApns + packet.deviceId = DeviceIdentityManager.shared.currentDeviceId() + ProtocolManager.shared.sendPacket(packet) + Self.logger.info("VoIP push token sent to server") + } + + /// Sends unsubscribe for a stale VoIP token (called when PushKit invalidates token). + func unsubscribeVoIPToken(_ token: String) { + guard !token.isEmpty, + ProtocolManager.shared.connectionState == .authenticated + else { return } + + var packet = PacketPushNotification() + packet.notificationsToken = token + packet.action = .unsubscribe + packet.tokenType = .voipApns + packet.deviceId = DeviceIdentityManager.shared.currentDeviceId() + ProtocolManager.shared.sendPacket(packet) + Self.logger.info("VoIP token unsubscribed from server") } // MARK: - Release Notes (Desktop Parity) diff --git a/Rosetta/Features/Chats/ChatDetail/ChatDetailView.swift b/Rosetta/Features/Chats/ChatDetail/ChatDetailView.swift index 93851d5..f17df58 100644 --- a/Rosetta/Features/Chats/ChatDetail/ChatDetailView.swift +++ b/Rosetta/Features/Chats/ChatDetail/ChatDetailView.swift @@ -1447,7 +1447,9 @@ private extension ChatDetailView { shouldScrollOnNextMessage = true messageText = "" pendingAttachments = [] - replyingToMessage = nil + // replyingToMessage cleared INSIDE Task after message is inserted into cache. + // This ensures reply panel disappears in the same SwiftUI render pass as + // the new bubble appears β€” no empty gap (Telegram parity). sendError = nil // Desktop parity: delete draft after sending. DraftManager.shared.deleteDraft(for: route.publicKey) @@ -1482,7 +1484,13 @@ private extension ChatDetailView { opponentUsername: route.username ) } + // Clear reply panel AFTER send β€” message is already in cache + // (upsertFromMessagePacket + refreshDialogCache + notification). + // SwiftUI batches this with the ViewModel's messages update + // β†’ reply bar disappears and bubble appears in the same frame. + replyingToMessage = nil } catch { + replyingToMessage = nil sendError = "Failed to send message" if messageText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { messageText = message diff --git a/Rosetta/Features/Chats/ChatDetail/ChatDetailViewModel.swift b/Rosetta/Features/Chats/ChatDetail/ChatDetailViewModel.swift index 06621ea..1b9cf71 100644 --- a/Rosetta/Features/Chats/ChatDetail/ChatDetailViewModel.swift +++ b/Rosetta/Features/Chats/ChatDetail/ChatDetailViewModel.swift @@ -70,6 +70,25 @@ final class ChatDetailViewModel: ObservableObject { } .store(in: &cancellables) + // Fast path: immediately update messages when a sent message is inserted, + // bypassing the 50ms Combine debounce for instant bubble appearance. + // No .receive(on:) β€” notification is always posted from @MainActor + // (SessionManager β†’ upsertFromMessagePacket), so subscriber fires + // synchronously on main thread. This is critical: the message must be + // in ViewModel.messages BEFORE sendCurrentMessage() clears replyingToMessage, + // so SwiftUI batches both changes into one render pass. + NotificationCenter.default.publisher(for: .sentMessageInserted) + .compactMap { $0.userInfo?["opponentKey"] as? String } + .filter { $0 == key } + .sink { [weak self] _ in + let fresh = repo.messages(for: key) + self?.messages = fresh + if self?.isLoading == true { + self?.isLoading = false + } + } + .store(in: &cancellables) + // Subscribe to typing state changes, filtered to our dialog let typingPublisher = repo.$typingDialogs .map { (dialogs: Set) -> Bool in diff --git a/Rosetta/Features/Chats/ChatDetail/NativeMessageList.swift b/Rosetta/Features/Chats/ChatDetail/NativeMessageList.swift index ce59a01..0434afb 100644 --- a/Rosetta/Features/Chats/ChatDetail/NativeMessageList.swift +++ b/Rosetta/Features/Chats/ChatDetail/NativeMessageList.swift @@ -298,7 +298,8 @@ final class NativeMessageListController: UIViewController { } else { // Reply quote replyName = name - replyText = first.message.isEmpty ? "Photo" : first.message + let rawReplyMsg = first.message.isEmpty ? "Photo" : first.message + replyText = EmojiParser.replaceShortcodes(in: rawReplyMsg) replyMessageId = first.message_id } } @@ -861,11 +862,11 @@ final class NativeMessageListController: UIViewController { updateScrollToBottomBadge() } - /// Telegram-style message insertion animation. - /// New messages: slide up from below (-height*1.6 offset) + alpha fade (0.2s). + /// Telegram-style message insertion animation (iOS 26+ parity). + /// New messages: slide up from below (-height*1.2 offset) + alpha fade (0.12s). /// Existing messages: spring position animation from old Y to new Y. - /// All position animations use CASpringAnimation (stiffness=443.7, damping=31.82). - /// Source: ChatMessageItemView.animateInsertion + ListView.insertNodeAtIndex. + /// All position animations use CASpringAnimation (stiffness=555, damping=47). + /// Source: UIKitUtils.m (iOS 26+ branch) + ListView.insertNodeAtIndex. private func applyInsertionAnimations(newIds: Set, oldPositions: [String: CGFloat]) { for ip in collectionView.indexPathsForVisibleItems { guard let cellId = dataSource.itemIdentifier(for: ip), @@ -874,25 +875,25 @@ final class NativeMessageListController: UIViewController { if newIds.contains(cellId) { // NEW cell: slide up from below + alpha fade // In inverted CV: negative offset = below on screen - let slideOffset = -cell.bounds.height * 1.6 + let slideOffset = -cell.bounds.height * 1.2 let slide = CASpringAnimation(keyPath: "position.y") slide.fromValue = slideOffset slide.toValue = 0.0 slide.isAdditive = true - slide.stiffness = 443.7 - slide.damping = 31.82 + slide.stiffness = 555.0 + slide.damping = 47.0 slide.mass = 1.0 slide.initialVelocity = 0 slide.duration = slide.settlingDuration slide.fillMode = .backwards cell.layer.add(slide, forKey: "insertionSlide") - // Alpha fade: 0 β†’ 1 (0.2s) + // Alpha fade: 0 β†’ 1 (Telegram-parity: fast fade) let alpha = CABasicAnimation(keyPath: "opacity") alpha.fromValue = 0.0 alpha.toValue = 1.0 - alpha.duration = 0.2 + alpha.duration = 0.12 alpha.fillMode = .backwards cell.contentView.layer.add(alpha, forKey: "insertionAlpha") @@ -905,8 +906,8 @@ final class NativeMessageListController: UIViewController { move.fromValue = delta move.toValue = 0.0 move.isAdditive = true - move.stiffness = 443.7 - move.damping = 31.82 + move.stiffness = 555.0 + move.damping = 47.0 move.mass = 1.0 move.initialVelocity = 0 move.duration = move.settlingDuration diff --git a/Rosetta/RosettaApp.swift b/Rosetta/RosettaApp.swift index 667ef66..781f9b5 100644 --- a/Rosetta/RosettaApp.swift +++ b/Rosetta/RosettaApp.swift @@ -2,6 +2,7 @@ import FirebaseCore import FirebaseCrashlytics import FirebaseMessaging import Intents +import PushKit import SwiftUI import UserNotifications @@ -15,6 +16,9 @@ final class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCent /// and background app (fallback if .onReceive misses the synchronous post). static var pendingChatRoute: ChatRoute? + /// PushKit registry β€” must be retained for VoIP push token delivery. + private var voipRegistry: PKPushRegistry? + func application( _ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil @@ -57,6 +61,14 @@ final class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCent } } + // Register for VoIP push notifications (PushKit). + // Apple requires CallKit integration: every VoIP push MUST result in + // reportNewIncomingCall or the app gets terminated. + let registry = PKPushRegistry(queue: .main) + registry.delegate = self + registry.desiredPushTypes = [.voIP] + voipRegistry = registry + return true } @@ -394,6 +406,82 @@ final class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCent } } +// MARK: - PKPushRegistryDelegate (VoIP Push) + +extension AppDelegate: PKPushRegistryDelegate { + + /// Called when PushKit delivers a VoIP token (or refreshes it). + func pushRegistry( + _ registry: PKPushRegistry, + didUpdate pushCredentials: PKPushCredentials, + for type: PKPushType + ) { + guard type == .voIP else { return } + let token = pushCredentials.token.map { String(format: "%02x", $0) }.joined() + Task { @MainActor in + SessionManager.shared.setVoIPToken(token) + } + } + + /// Called when a VoIP push arrives. MUST call reportNewIncomingCall or Apple + /// terminates the app. Server sends: { "dialog": callerKey, "title": callerName }. + func pushRegistry( + _ registry: PKPushRegistry, + didReceiveIncomingPushWith payload: PKPushPayload, + for type: PKPushType, + completion: @escaping () -> Void + ) { + guard type == .voIP else { + completion() + return + } + let data = payload.dictionaryPayload + let callerKey = data["dialog"] as? String ?? "" + let callerName = data["title"] as? String ?? "Rosetta" + + // 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 + ) { error in + completion() + + // If callerKey is empty/invalid, immediately end the orphaned call. + // Apple still required us to call reportNewIncomingCall, but we can't + // connect a call without a valid peer key. + if callerKey.isEmpty || error != nil { + 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. + Task { @MainActor in + if ProtocolManager.shared.connectionState != .authenticated { + ProtocolManager.shared.forceReconnectOnForeground() + } + } + } + } + + func pushRegistry( + _ registry: PKPushRegistry, + didInvalidatePushTokenFor type: PKPushType + ) { + guard type == .voIP else { return } + // Notify server to unsubscribe the stale VoIP token before clearing it. + let oldToken = UserDefaults.standard.string(forKey: "voip_push_token") + if let oldToken, !oldToken.isEmpty { + Task { @MainActor in + SessionManager.shared.unsubscribeVoIPToken(oldToken) + } + } + UserDefaults.standard.removeObject(forKey: "voip_push_token") + } +} + // MARK: - App State private enum AppState { @@ -561,4 +649,8 @@ extension Notification.Name { static let triggerAttachmentDownload = Notification.Name("triggerAttachmentDownload") /// Posted when user taps "Chats" toolbar title β€” triggers scroll-to-top. static let chatListScrollToTop = Notification.Name("chatListScrollToTop") + /// Posted immediately when an outgoing message is inserted into the DB cache. + /// Bypasses the 100ms repo + 50ms ViewModel debounce for instant bubble appearance. + /// userInfo: ["opponentKey": String] + static let sentMessageInserted = Notification.Name("sentMessageInserted") } diff --git a/RosettaTests/CallPushIntegrationTests.swift b/RosettaTests/CallPushIntegrationTests.swift new file mode 100644 index 0000000..b955cf8 --- /dev/null +++ b/RosettaTests/CallPushIntegrationTests.swift @@ -0,0 +1,252 @@ +import Testing +@testable import Rosetta + +// MARK: - Push Notification Extended Tests + +struct PushNotificationExtendedTests { + + @Test("Realistic FCM token with device ID round-trip") + func fcmTokenWithDeviceIdRoundTrip() throws { + // Real FCM tokens are ~163 chars + let fcmToken = "dQw4w9WgXcQ:APA91bHnzPc5Y0z4R8kP3mN6vX2tL7wJ1qA5sD8fG0hK3lZ9xC2vB4nM7oP1iU8yT6rE5wQ3jF4kL2mN0bV7cX9sD1aF3gH5jK7lP9oI2uY4tR6eW8qZ0xC" + var packet = PacketPushNotification() + packet.notificationsToken = fcmToken + packet.action = .subscribe + packet.tokenType = .fcm + packet.deviceId = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnop" + + let decoded = try decode(packet) + #expect(decoded.notificationsToken == fcmToken) + #expect(decoded.action == .subscribe) + #expect(decoded.tokenType == .fcm) + #expect(decoded.deviceId == "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnop") + } + + @Test("Realistic VoIP hex token round-trip") + func voipTokenWithDeviceIdRoundTrip() throws { + // PushKit tokens are 32 bytes = 64 hex chars + let voipToken = "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2" + var packet = PacketPushNotification() + packet.notificationsToken = voipToken + packet.action = .subscribe + packet.tokenType = .voipApns + packet.deviceId = "device-xyz-123" + + let decoded = try decode(packet) + #expect(decoded.notificationsToken == voipToken) + #expect(decoded.tokenType == .voipApns) + } + + @Test("Long token (256 chars) round-trip β€” stress test UInt32 string length") + func longTokenRoundTrip() throws { + let longToken = String(repeating: "x", count: 256) + var packet = PacketPushNotification() + packet.notificationsToken = longToken + packet.action = .subscribe + packet.tokenType = .fcm + packet.deviceId = "dev" + + let decoded = try decode(packet) + #expect(decoded.notificationsToken == longToken) + #expect(decoded.notificationsToken.count == 256) + } + + @Test("Unicode device ID with emoji and Cyrillic round-trip") + func unicodeDeviceIdRoundTrip() throws { + let unicodeId = "Π’Π΅Π»Π΅Ρ„ΠΎΠ½ Π“Π°ΠΉΠ΄Π°Ρ€Π° πŸ“±" + var packet = PacketPushNotification() + packet.notificationsToken = "token" + packet.action = .subscribe + packet.tokenType = .fcm + packet.deviceId = unicodeId + + let decoded = try decode(packet) + #expect(decoded.deviceId == unicodeId) + } + + @Test("Unsubscribe action round-trip for both token types", + arguments: [PushTokenType.fcm, PushTokenType.voipApns]) + func unsubscribeRoundTrip(tokenType: PushTokenType) throws { + var packet = PacketPushNotification() + packet.notificationsToken = "test-token" + packet.action = .unsubscribe + packet.tokenType = tokenType + packet.deviceId = "dev" + + let decoded = try decode(packet) + #expect(decoded.action == .unsubscribe) + #expect(decoded.tokenType == tokenType) + } + + private func decode(_ packet: PacketPushNotification) throws -> PacketPushNotification { + let data = PacketRegistry.encode(packet) + guard let result = PacketRegistry.decode(from: data), + let decoded = result.packet as? PacketPushNotification + else { throw TestError("Failed to decode PacketPushNotification") } + #expect(result.packetId == 0x10) + return decoded + } +} + +// MARK: - Signal Peer Call Flow Tests + +struct SignalPeerCallFlowTests { + + @Test("Incoming call signal with realistic secp256k1 keys") + func incomingCallSignalRoundTrip() throws { + let caller = "02a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2" + let callee = "03f0e1d2c3b4a5968778695a4b3c2d1e0f9e8d7c6b5a49382716051a2b3c4d5e6f" + let packet = PacketSignalPeer(src: caller, dst: callee, sharedPublic: "", + signalType: .call, roomId: "") + let decoded = try decode(packet) + #expect(decoded.signalType == .call) + #expect(decoded.src == caller) + #expect(decoded.dst == callee) + #expect(decoded.sharedPublic == "") + #expect(decoded.roomId == "") + } + + @Test("Key exchange with X25519 public key") + func keyExchangeRoundTrip() throws { + let x25519Key = "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789" + let packet = PacketSignalPeer(src: "02src", dst: "02dst", sharedPublic: x25519Key, + signalType: .keyExchange, roomId: "") + let decoded = try decode(packet) + #expect(decoded.signalType == .keyExchange) + #expect(decoded.sharedPublic == x25519Key) + #expect(decoded.roomId == "") + } + + @Test("Create room with UUID room ID") + func createRoomRoundTrip() throws { + let roomId = "550e8400-e29b-41d4-a716-446655440000" + let packet = PacketSignalPeer(src: "02src", dst: "02dst", sharedPublic: "", + signalType: .createRoom, roomId: roomId) + let decoded = try decode(packet) + #expect(decoded.signalType == .createRoom) + #expect(decoded.roomId == roomId) + #expect(decoded.sharedPublic == "") + } + + @Test("endCallBecauseBusy short format β€” 3 bytes wire size, no src/dst") + func endCallBusyShortFormat() throws { + let packet = PacketSignalPeer(src: "ignored", dst: "ignored", sharedPublic: "ignored", + signalType: .endCallBecauseBusy, roomId: "ignored") + let data = PacketRegistry.encode(packet) + // Short form: 2 bytes packetId + 1 byte signalType = 3 bytes + #expect(data.count == 3) + + let decoded = try decode(packet) + #expect(decoded.signalType == .endCallBecauseBusy) + #expect(decoded.src == "") + #expect(decoded.dst == "") + } + + @Test("endCallBecausePeerDisconnected short format β€” 3 bytes wire size") + func endCallDisconnectedShortFormat() throws { + let packet = PacketSignalPeer(src: "ignored", dst: "ignored", sharedPublic: "ignored", + signalType: .endCallBecausePeerDisconnected, roomId: "ignored") + let data = PacketRegistry.encode(packet) + #expect(data.count == 3) + + let decoded = try decode(packet) + #expect(decoded.signalType == .endCallBecausePeerDisconnected) + } + + private func decode(_ packet: PacketSignalPeer) throws -> PacketSignalPeer { + let data = PacketRegistry.encode(packet) + guard let result = PacketRegistry.decode(from: data), + let decoded = result.packet as? PacketSignalPeer + else { throw TestError("Failed to decode PacketSignalPeer") } + #expect(result.packetId == 0x1A) + return decoded + } +} + +// MARK: - Enum Parity Tests + +struct CallPushEnumParityTests { + + @Test("SignalType enum values match server", + arguments: [ + (SignalType.call, 0), (SignalType.keyExchange, 1), (SignalType.activeCall, 2), + (SignalType.endCall, 3), (SignalType.createRoom, 4), + (SignalType.endCallBecausePeerDisconnected, 5), (SignalType.endCallBecauseBusy, 6) + ]) + func signalTypeEnumValues(pair: (SignalType, Int)) { + #expect(pair.0.rawValue == pair.1) + } + + @Test("WebRTCSignalType enum values match server", + arguments: [(WebRTCSignalType.offer, 0), (WebRTCSignalType.answer, 1), + (WebRTCSignalType.iceCandidate, 2)]) + func webRTCSignalTypeValues(pair: (WebRTCSignalType, Int)) { + #expect(pair.0.rawValue == pair.1) + } + + @Test("PushTokenType enum values match server") + func pushTokenTypeValues() { + #expect(PushTokenType.fcm.rawValue == 0) + #expect(PushTokenType.voipApns.rawValue == 1) + } +} + +// MARK: - Wire Format Byte-Level Tests + +struct CallPushWireFormatTests { + + @Test("PushNotification byte layout: tokenβ†’actionβ†’tokenTypeβ†’deviceId") + func pushNotificationByteLayout() { + var packet = PacketPushNotification() + packet.notificationsToken = "A" + packet.action = .unsubscribe + packet.tokenType = .fcm + packet.deviceId = "B" + + let data = PacketRegistry.encode(packet) + #expect(data.count == 16) + + // packetId = 0x0010 + #expect(data[0] == 0x00); #expect(data[1] == 0x10) + // token "A": length=1, 'A'=0x0041 + #expect(data[2] == 0x00); #expect(data[3] == 0x00) + #expect(data[4] == 0x00); #expect(data[5] == 0x01) + #expect(data[6] == 0x00); #expect(data[7] == 0x41) + // action = 1 (unsubscribe) + #expect(data[8] == 0x01) + // tokenType = 0 (fcm) + #expect(data[9] == 0x00) + // deviceId "B": length=1, 'B'=0x0042 + #expect(data[10] == 0x00); #expect(data[11] == 0x00) + #expect(data[12] == 0x00); #expect(data[13] == 0x01) + #expect(data[14] == 0x00); #expect(data[15] == 0x42) + } + + @Test("SignalPeer call byte layout: signalTypeβ†’srcβ†’dst") + func signalPeerCallByteLayout() { + let packet = PacketSignalPeer(src: "S", dst: "D", sharedPublic: "", + signalType: .call, roomId: "") + let data = PacketRegistry.encode(packet) + #expect(data.count == 15) + + // packetId = 0x001A + #expect(data[0] == 0x00); #expect(data[1] == 0x1A) + // signalType = 0 (call) + #expect(data[2] == 0x00) + // src "S": length=1, 'S'=0x0053 + #expect(data[3] == 0x00); #expect(data[4] == 0x00) + #expect(data[5] == 0x00); #expect(data[6] == 0x01) + #expect(data[7] == 0x00); #expect(data[8] == 0x53) + // dst "D": length=1, 'D'=0x0044 + #expect(data[9] == 0x00); #expect(data[10] == 0x00) + #expect(data[11] == 0x00); #expect(data[12] == 0x01) + #expect(data[13] == 0x00); #expect(data[14] == 0x44) + } +} + +// MARK: - Helpers + +private struct TestError: Error, CustomStringConvertible { + let description: String + init(_ message: String) { self.description = message } +} diff --git a/RosettaTests/PushNotificationPacketTests.swift b/RosettaTests/PushNotificationPacketTests.swift new file mode 100644 index 0000000..c16829f --- /dev/null +++ b/RosettaTests/PushNotificationPacketTests.swift @@ -0,0 +1,167 @@ +import Testing +@testable import Rosetta + +/// Verifies PacketPushNotification wire format matches server +/// (im.rosetta.packet.Packet16PushNotification). +/// +/// Server wire format: +/// writeInt16(packetId=0x10) +/// writeString(notificationToken) +/// writeInt8(action) β€” 0=subscribe, 1=unsubscribe +/// writeInt8(tokenType) β€” 0=FCM, 1=VoIPApns +/// writeString(deviceId) +struct PushNotificationPacketTests { + + // MARK: - Enum Value Parity + + @Test("PushNotificationAction.subscribe == 0 (server: SUBSCRIBE)") + func subscribeActionValue() { + #expect(PushNotificationAction.subscribe.rawValue == 0) + } + + @Test("PushNotificationAction.unsubscribe == 1 (server: UNSUBSCRIBE)") + func unsubscribeActionValue() { + #expect(PushNotificationAction.unsubscribe.rawValue == 1) + } + + @Test("PushTokenType.fcm == 0 (server: FCM)") + func fcmTokenTypeValue() { + #expect(PushTokenType.fcm.rawValue == 0) + } + + @Test("PushTokenType.voipApns == 1 (server: VoIPApns)") + func voipTokenTypeValue() { + #expect(PushTokenType.voipApns.rawValue == 1) + } + + // MARK: - Round Trip (encode β†’ decode) + + @Test("FCM subscribe round-trip preserves all fields") + func fcmSubscribeRoundTrip() throws { + var packet = PacketPushNotification() + packet.notificationsToken = "test-fcm-token-abc123" + packet.action = .subscribe + packet.tokenType = .fcm + packet.deviceId = "device-id-xyz" + + let decoded = try decodePushNotification(packet) + #expect(decoded.notificationsToken == "test-fcm-token-abc123") + #expect(decoded.action == .subscribe) + #expect(decoded.tokenType == .fcm) + #expect(decoded.deviceId == "device-id-xyz") + } + + @Test("VoIP unsubscribe round-trip preserves all fields") + func voipUnsubscribeRoundTrip() throws { + var packet = PacketPushNotification() + packet.notificationsToken = "voip-hex-token-deadbeef" + packet.action = .unsubscribe + packet.tokenType = .voipApns + packet.deviceId = "another-device-id" + + let decoded = try decodePushNotification(packet) + #expect(decoded.notificationsToken == "voip-hex-token-deadbeef") + #expect(decoded.action == .unsubscribe) + #expect(decoded.tokenType == .voipApns) + #expect(decoded.deviceId == "another-device-id") + } + + @Test("Empty token and deviceId round-trip") + func emptyFieldsRoundTrip() throws { + var packet = PacketPushNotification() + packet.notificationsToken = "" + packet.action = .subscribe + packet.tokenType = .fcm + packet.deviceId = "" + + let decoded = try decodePushNotification(packet) + #expect(decoded.notificationsToken == "") + #expect(decoded.deviceId == "") + } + + // MARK: - Wire Format Byte Verification + + @Test("Packet ID is 0x10 in encoded data") + func packetIdInEncodedData() { + #expect(PacketPushNotification.packetId == 0x10) + + let packet = PacketPushNotification() + let data = PacketRegistry.encode(packet) + + // First 2 bytes = packetId in big-endian: 0x00 0x10 + #expect(data.count >= 2) + #expect(data[0] == 0x00) + #expect(data[1] == 0x10) + } + + @Test("Wire format field order matches server: token β†’ action β†’ tokenType β†’ deviceId") + func wireFormatFieldOrder() throws { + // Use known short values so we can verify byte positions. + var packet = PacketPushNotification() + packet.notificationsToken = "T" // 1 UTF-16 code unit + packet.action = .subscribe // 0 + packet.tokenType = .voipApns // 1 + packet.deviceId = "D" // 1 UTF-16 code unit + + let data = PacketRegistry.encode(packet) + + // Expected layout: + // [0-1] packetId = 0x0010 (2 bytes) + // [2-5] string length = 1 (UInt32 big-endian) for "T" + // [6-7] 'T' = 0x0054 (UInt16 big-endian) + // [8] action = 0 (subscribe) + // [9] tokenType = 1 (voipApns) + // [10-13] string length = 1 for "D" + // [14-15] 'D' = 0x0044 (UInt16 big-endian) + + #expect(data.count == 16) + + // packetId + #expect(data[0] == 0x00) + #expect(data[1] == 0x10) + + // token string length = 1 + #expect(data[2] == 0x00) + #expect(data[3] == 0x00) + #expect(data[4] == 0x00) + #expect(data[5] == 0x01) + + // 'T' in UTF-16 BE + #expect(data[6] == 0x00) + #expect(data[7] == 0x54) + + // action = 0 (subscribe) + #expect(data[8] == 0x00) + + // tokenType = 1 (voipApns) + #expect(data[9] == 0x01) + + // deviceId string length = 1 + #expect(data[10] == 0x00) + #expect(data[11] == 0x00) + #expect(data[12] == 0x00) + #expect(data[13] == 0x01) + + // 'D' in UTF-16 BE + #expect(data[14] == 0x00) + #expect(data[15] == 0x44) + } + + // MARK: - Helper + + private func decodePushNotification( + _ packet: PacketPushNotification + ) throws -> PacketPushNotification { + let encoded = PacketRegistry.encode(packet) + guard let decoded = PacketRegistry.decode(from: encoded), + let decodedPacket = decoded.packet as? PacketPushNotification + else { + throw NSError( + domain: "PushNotificationPacketTests", code: 1, + userInfo: [NSLocalizedDescriptionKey: "Failed to decode PacketPushNotification"] + ) + } + #expect(decoded.packetId == 0x10) + return decodedPacket + } +}