From d65624ad35e5c3fdc5d741c79a666a3aa8cd1e53 Mon Sep 17 00:00:00 2001 From: senseiGai Date: Sun, 5 Apr 2026 12:16:24 +0500 Subject: [PATCH] =?UTF-8?q?=D0=A2=D0=B5=D0=BC=D0=B8=D0=B7=D0=B0=D1=86?= =?UTF-8?q?=D0=B8=D1=8F:=20adaptive=20=D1=86=D0=B2=D0=B5=D1=82=D0=B0=20?= =?UTF-8?q?=D1=87=D0=B0=D1=82=D0=B0,=20context=20menu,=20attachment=20pick?= =?UTF-8?q?er,=20auth=20+=20instant=20=D0=BE=D1=82=D0=BA=D0=BB=D0=B8=D0=BA?= =?UTF-8?q?=20DarkMode=20=D0=BA=D0=BD=D0=BE=D0=BF=D0=BA=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Protocol/Packets/PacketSignalPeer.swift | 47 ++- .../Network/Protocol/ProtocolManager.swift | 4 + .../Core/Services/CallManager+Runtime.swift | 14 +- Rosetta/Core/Services/CallManager.swift | 280 +++++++++++++++--- Rosetta/Core/Services/CallModels.swift | 6 + Rosetta/Core/Utils/DarkMode+Helpers.swift | 110 ++++--- .../Features/Auth/ConfirmSeedPhraseView.swift | 6 +- .../Features/Auth/ImportSeedPhraseView.swift | 6 +- .../Features/Auth/PasswordStrengthView.swift | 4 +- Rosetta/Features/Auth/SeedPhraseView.swift | 4 +- Rosetta/Features/Auth/SetPasswordView.swift | 10 +- Rosetta/Features/Auth/WelcomeView.swift | 6 +- .../ChatDetail/AttachmentPanelView.swift | 42 +-- .../Chats/ChatDetail/ComposerView.swift | 36 +++ .../Chats/ChatDetail/NativeMessageCell.swift | 70 ++++- .../Chats/ChatDetail/NativeMessageList.swift | 30 +- .../TelegramContextMenuCardView.swift | 24 +- Rosetta/RosettaApp.swift | 16 +- RosettaTests/CallPacketParityTests.swift | 93 +++++- RosettaTests/CallPushIntegrationTests.swift | 30 +- RosettaTests/CallRoutingTests.swift | 124 ++++++++ 21 files changed, 803 insertions(+), 159 deletions(-) diff --git a/Rosetta/Core/Network/Protocol/Packets/PacketSignalPeer.swift b/Rosetta/Core/Network/Protocol/Packets/PacketSignalPeer.swift index 0f17279..89d8e45 100644 --- a/Rosetta/Core/Network/Protocol/Packets/PacketSignalPeer.swift +++ b/Rosetta/Core/Network/Protocol/Packets/PacketSignalPeer.swift @@ -1,9 +1,10 @@ import Foundation /// Call signaling packet (0x1A / 26). -/// Wire format mirrors desktop/android: -/// `signalType` always first, then short-form for busy/disconnected, -/// otherwise `src`, `dst`, optional `sharedPublic`, optional `roomId`. +/// Wire format mirrors server/desktop legacy signaling: +/// `signalType` always first, then short-form for busy/disconnected/ringingTimeout, +/// otherwise `src`, `dst`, optional `sharedPublic`, optional `callId/joinToken`, +/// optional `roomId` for create-room fallback compatibility. enum SignalType: Int, Sendable { case call = 0 case keyExchange = 1 @@ -12,6 +13,8 @@ enum SignalType: Int, Sendable { case createRoom = 4 case endCallBecausePeerDisconnected = 5 case endCallBecauseBusy = 6 + case accept = 7 + case ringingTimeout = 8 } struct PacketSignalPeer: Packet { @@ -21,11 +24,13 @@ struct PacketSignalPeer: Packet { var dst: String = "" var sharedPublic: String = "" var signalType: SignalType = .call + var callId: String = "" + var joinToken: String = "" var roomId: String = "" func write(to stream: Stream) { stream.writeInt8(signalType.rawValue) - if signalType == .endCallBecauseBusy || signalType == .endCallBecausePeerDisconnected { + if isShortSignal { return } stream.writeString(src) @@ -33,14 +38,27 @@ struct PacketSignalPeer: Packet { if signalType == .keyExchange { stream.writeString(sharedPublic) } - if signalType == .createRoom { + if hasLegacyCallMetadata { + stream.writeString(callId) + stream.writeString(joinToken) + } + // Signal code 4 is mode-aware: + // - legacy ACTIVE: no roomId on wire + // - create-room fallback: roomId present on wire + if signalType == .createRoom && !roomId.isEmpty { stream.writeString(roomId) } } mutating func read(from stream: Stream) { + src = "" + dst = "" + sharedPublic = "" + callId = "" + joinToken = "" + roomId = "" signalType = SignalType(rawValue: stream.readInt8()) ?? .call - if signalType == .endCallBecauseBusy || signalType == .endCallBecausePeerDisconnected { + if isShortSignal { return } src = stream.readString() @@ -48,8 +66,25 @@ struct PacketSignalPeer: Packet { if signalType == .keyExchange { sharedPublic = stream.readString() } + if hasLegacyCallMetadata { + callId = stream.readString() + joinToken = stream.readString() + } + // Signal code 4 is mode-aware on read: + // - empty roomId => legacy ACTIVE + // - non-empty roomId => create-room fallback if signalType == .createRoom { roomId = stream.readString() } } + + private var isShortSignal: Bool { + signalType == .endCallBecauseBusy + || signalType == .endCallBecausePeerDisconnected + || signalType == .ringingTimeout + } + + private var hasLegacyCallMetadata: Bool { + signalType == .call || signalType == .accept || signalType == .endCall + } } diff --git a/Rosetta/Core/Network/Protocol/ProtocolManager.swift b/Rosetta/Core/Network/Protocol/ProtocolManager.swift index b0ba362..6f9a18c 100644 --- a/Rosetta/Core/Network/Protocol/ProtocolManager.swift +++ b/Rosetta/Core/Network/Protocol/ProtocolManager.swift @@ -356,6 +356,8 @@ final class ProtocolManager: @unchecked Sendable { src: String = "", dst: String = "", sharedPublic: String = "", + callId: String = "", + joinToken: String = "", roomId: String = "" ) { var packet = PacketSignalPeer() @@ -363,6 +365,8 @@ final class ProtocolManager: @unchecked Sendable { packet.src = src packet.dst = dst packet.sharedPublic = sharedPublic + packet.callId = callId + packet.joinToken = joinToken packet.roomId = roomId sendPacket(packet) } diff --git a/Rosetta/Core/Services/CallManager+Runtime.swift b/Rosetta/Core/Services/CallManager+Runtime.swift index 0d20124..1e78b11 100644 --- a/Rosetta/Core/Services/CallManager+Runtime.swift +++ b/Rosetta/Core/Services/CallManager+Runtime.swift @@ -144,6 +144,10 @@ extension CallManager { func beginCallSession(peerPublicKey: String, title: String, username: String) { finishCall(reason: nil, notifyPeer: false) + signalingMode = .undecided + callId = "" + joinToken = "" + pendingIncomingAccept = false uiState = CallUiState( phase: .idle, peerPublicKey: peerPublicKey, @@ -158,11 +162,14 @@ extension CallManager { guard !isFinishingCall, uiState.phase != .idle else { return } isFinishingCall = true pendingCallKitAccept = false + pendingIncomingAccept = false defer { isFinishingCall = false } callLogger.notice("[Call] finishCall(reason=\(reason ?? "nil", privacy: .public)) phase=\(self.uiState.phase.rawValue, privacy: .public)") let snapshot = uiState + let snapshotCallId = callId + let snapshotJoinToken = joinToken // Step 0: Cancel recovery/rebind tasks and clear packet buffer. disconnectRecoveryTask?.cancel() @@ -221,7 +228,9 @@ extension CallManager { ProtocolManager.shared.sendCallSignal( signalType: .endCall, src: ownPublicKey, - dst: snapshot.peerPublicKey + dst: snapshot.peerPublicKey, + callId: snapshotCallId, + joinToken: snapshotJoinToken ) } @@ -244,7 +253,10 @@ extension CallManager { // Step 6: Reset all state. role = nil + signalingMode = .undecided roomId = "" + callId = "" + joinToken = "" localPrivateKey = nil localPublicKeyHex = "" sharedKey = nil diff --git a/Rosetta/Core/Services/CallManager.swift b/Rosetta/Core/Services/CallManager.swift index cb7a5e1..55f0c40 100644 --- a/Rosetta/Core/Services/CallManager.swift +++ b/Rosetta/Core/Services/CallManager.swift @@ -18,7 +18,10 @@ final class CallManager: NSObject, ObservableObject { var ownPublicKey: String = "" var role: CallRole? + var signalingMode: CallSignalingMode = .undecided var roomId: String = "" + var callId: String = "" + var joinToken: String = "" var localPrivateKey: Curve25519.KeyAgreement.PrivateKey? var localPublicKeyHex: String = "" var sharedKey: Data? @@ -49,6 +52,9 @@ final class CallManager: NSObject, ObservableObject { /// 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 + /// Deferred incoming accept: user accepted, but call signaling metadata/mode is + /// not yet sufficient to send the next signal (legacy ACCEPT or create-room KEY_EXCHANGE). + var pendingIncomingAccept = 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). @@ -98,22 +104,39 @@ final class CallManager: NSObject, ObservableObject { /// 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.notice("setupIncomingCallFromPush: callerKey=\(callerKey.prefix(12), privacy: .public) name=\(callerName, privacy: .public)") + func setupIncomingCallFromPush( + callerKey: String, + callerName: String, + callId: String? = nil, + joinToken: String? = nil + ) { + let peer = callerKey.trimmingCharacters(in: .whitespacesAndNewlines) + guard !peer.isEmpty else { return } + mergeLegacySessionMetadata(callId: callId, joinToken: joinToken, source: "push") + if hasLegacySessionMetadata { + setSignalingMode(.legacy, reason: "push metadata") + } + + guard uiState.phase == .idle else { + completePendingIncomingAcceptIfPossible(trigger: "push update") + return + } + + callLogger.notice( + "setupIncomingCallFromPush: callerKey=\(peer.prefix(12), privacy: .public) name=\(callerName, privacy: .public) mode=\(self.signalingMode.rawValue, 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, + peerPublicKey: peer, peerTitle: callerName, peerUsername: "" ) role = .callee uiState.statusText = "Incoming call..." ProtocolManager.shared.beginCallBackgroundTask() - hydratePeerIdentity(for: callerKey) + hydratePeerIdentity(for: peer) startRingTimeout() // Auto-accept if user already tapped Accept on CallKit before this ran. @@ -125,6 +148,7 @@ final class CallManager: NSObject, ObservableObject { let result = acceptIncomingCall() callLogger.info("setupIncomingCallFromPush: auto-accept result=\(String(describing: result), privacy: .public)") } + completePendingIncomingAcceptIfPossible(trigger: "push setup") } func onAuthenticated() { @@ -143,6 +167,7 @@ final class CallManager: NSObject, ObservableObject { beginCallSession(peerPublicKey: target, title: title, username: username) role = .caller + setSignalingMode(.undecided, reason: "outgoing call started") ensureLocalSessionKeys() uiState.phase = .outgoing uiState.statusText = "Calling..." @@ -172,16 +197,14 @@ final class CallManager: NSObject, ObservableObject { ensureLocalSessionKeys() guard localPublicKeyHex.isEmpty == false else { return .invalidTarget } - ProtocolManager.shared.sendCallSignal( - signalType: .keyExchange, - src: ownPublicKey, - dst: uiState.peerPublicKey, - sharedPublic: localPublicKeyHex - ) - + pendingIncomingAccept = true uiState.phase = .keyExchange uiState.isMinimized = false // Show full-screen custom overlay after accept - uiState.statusText = "Exchanging keys..." + uiState.statusText = "Connecting..." + callLogger.notice( + "[Call] acceptIncomingCall: queued mode=\(self.signalingMode.rawValue, privacy: .public) hasMetadata=\(self.hasLegacySessionMetadata.description, privacy: .public)" + ) + completePendingIncomingAcceptIfPossible(trigger: "acceptIncomingCall") return .started } @@ -192,7 +215,9 @@ final class CallManager: NSObject, ObservableObject { ProtocolManager.shared.sendCallSignal( signalType: .endCall, src: ownPublicKey, - dst: uiState.peerPublicKey + dst: uiState.peerPublicKey, + callId: callId, + joinToken: joinToken ) } finishCall(reason: nil, notifyPeer: false) @@ -264,8 +289,80 @@ final class CallManager: NSObject, ObservableObject { } } + private var hasLegacySessionMetadata: Bool { + !callId.isEmpty && !joinToken.isEmpty + } + + private func setSignalingMode(_ mode: CallSignalingMode, reason: String) { + guard signalingMode != mode else { return } + callLogger.notice( + "[Call] signaling mode \(self.signalingMode.rawValue, privacy: .public) -> \(mode.rawValue, privacy: .public) (\(reason, privacy: .public))" + ) + signalingMode = mode + } + + private func mergeLegacySessionMetadata(callId: String?, joinToken: String?, source: String) { + let normalizedCallId = callId? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let normalizedJoinToken = joinToken? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + + if !normalizedCallId.isEmpty && self.callId != normalizedCallId { + callLogger.info( + "[Call] merge callId from \(source, privacy: .public): \(normalizedCallId.prefix(12), privacy: .public)" + ) + self.callId = normalizedCallId + } + if !normalizedJoinToken.isEmpty && self.joinToken != normalizedJoinToken { + callLogger.info("[Call] merge joinToken from \(source, privacy: .public)") + self.joinToken = normalizedJoinToken + } + } + + private func completePendingIncomingAcceptIfPossible(trigger: String) { + guard pendingIncomingAccept else { return } + guard ownPublicKey.isEmpty == false else { return } + guard uiState.peerPublicKey.isEmpty == false else { return } + + switch signalingMode { + case .legacy: + guard hasLegacySessionMetadata else { + callLogger.info("[Call] pending accept waits for legacy metadata (\(trigger, privacy: .public))") + return + } + ProtocolManager.shared.sendCallSignal( + signalType: .accept, + src: ownPublicKey, + dst: uiState.peerPublicKey, + callId: callId, + joinToken: joinToken + ) + pendingIncomingAccept = false + uiState.statusText = "Waiting for key exchange..." + callLogger.notice("[Call] pending accept completed via ACCEPT (\(trigger, privacy: .public))") + + case .createRoom: + ensureLocalSessionKeys() + guard !localPublicKeyHex.isEmpty else { return } + ProtocolManager.shared.sendCallSignal( + signalType: .keyExchange, + src: ownPublicKey, + dst: uiState.peerPublicKey, + sharedPublic: localPublicKeyHex + ) + pendingIncomingAccept = false + uiState.statusText = "Exchanging keys..." + callLogger.notice("[Call] pending accept completed via KEY_EXCHANGE (\(trigger, privacy: .public))") + + case .undecided: + callLogger.info("[Call] pending accept waits for signaling mode decision (\(trigger, privacy: .public))") + } + } + private func handleSignalPacket(_ packet: PacketSignalPeer) { - callLogger.notice("[Call] handleSignalPacket: type=\(String(describing: packet.signalType), privacy: .public) phase=\(self.uiState.phase.rawValue, privacy: .public)") + callLogger.notice( + "[Call] handleSignalPacket: type=\(String(describing: packet.signalType), privacy: .public) phase=\(self.uiState.phase.rawValue, privacy: .public) mode=\(self.signalingMode.rawValue, privacy: .public)" + ) switch packet.signalType { case .endCallBecauseBusy: finishCall(reason: "User is busy", notifyPeer: false, skipAttachment: true) @@ -273,6 +370,9 @@ final class CallManager: NSObject, ObservableObject { case .endCallBecausePeerDisconnected: finishCall(reason: "Peer disconnected", notifyPeer: false) return + case .ringingTimeout: + finishCall(reason: "No answer", notifyPeer: false, skipAttachment: true) + return case .endCall: finishCall(reason: "Call ended", notifyPeer: false) return @@ -290,12 +390,23 @@ final class CallManager: NSObject, ObservableObject { case .call: let incomingPeer = packet.src.trimmingCharacters(in: .whitespacesAndNewlines) guard incomingPeer.isEmpty == false else { return } + mergeLegacySessionMetadata( + callId: packet.callId, + joinToken: packet.joinToken, + source: "signal.call" + ) + if hasLegacySessionMetadata { + setSignalingMode(.legacy, reason: "incoming .call with call metadata") + } else if signalingMode == .undecided { + setSignalingMode(.createRoom, reason: "incoming .call without metadata") + } 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))") + completePendingIncomingAcceptIfPossible(trigger: "duplicate .call") return } // Different peer trying to call — send busy. @@ -306,7 +417,26 @@ final class CallManager: NSObject, ObservableObject { ) return } + let cachedCallId = callId + let cachedJoinToken = joinToken beginCallSession(peerPublicKey: incomingPeer, title: "", username: "") + mergeLegacySessionMetadata( + callId: packet.callId, + joinToken: packet.joinToken, + source: "signal.call post-reset" + ) + if !hasLegacySessionMetadata { + mergeLegacySessionMetadata( + callId: cachedCallId, + joinToken: cachedJoinToken, + source: "signal.call cached metadata" + ) + } + if hasLegacySessionMetadata { + setSignalingMode(.legacy, reason: "incoming .call session start") + } else { + setSignalingMode(.createRoom, reason: "incoming .call session start") + } role = .callee uiState.phase = .incoming ProtocolManager.shared.beginCallBackgroundTask() @@ -335,12 +465,50 @@ final class CallManager: NSObject, ObservableObject { 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)") } + completePendingIncomingAcceptIfPossible(trigger: "incoming .call") + case .accept: + mergeLegacySessionMetadata( + callId: packet.callId, + joinToken: packet.joinToken, + source: "signal.accept" + ) + guard role == .caller else { return } + if signalingMode == .undecided { + setSignalingMode(.legacy, reason: "caller received ACCEPT") + } else if signalingMode == .createRoom { + callLogger.notice("[Call] ACCEPT ignored — mode already createRoom") + return + } + ensureLocalSessionKeys() + guard !localPublicKeyHex.isEmpty else { return } + ProtocolManager.shared.sendCallSignal( + signalType: .keyExchange, + src: ownPublicKey, + dst: uiState.peerPublicKey, + sharedPublic: localPublicKeyHex + ) + uiState.phase = .keyExchange + uiState.statusText = "Exchanging keys..." + callLogger.notice("[Call] legacy path: ACCEPT -> KEY_EXCHANGE") case .keyExchange: + if role == .caller, signalingMode == .undecided { + setSignalingMode(.createRoom, reason: "caller received KEY_EXCHANGE before ACCEPT") + } handleKeyExchange(packet) case .createRoom: let incomingRoomId = packet.roomId.trimmingCharacters(in: .whitespacesAndNewlines) - guard incomingRoomId.isEmpty == false else { return } - roomId = incomingRoomId + if incomingRoomId.isEmpty { + if signalingMode == .undecided { + setSignalingMode(.legacy, reason: "signal 4 without roomId") + } + callLogger.notice("[Call] signal 4 handled as ACTIVE (legacy)") + } else { + roomId = incomingRoomId + if signalingMode == .undecided { + setSignalingMode(.createRoom, reason: "signal 4 with roomId") + } + callLogger.notice("[Call] signal 4 handled as CREATE_ROOM roomId=\(incomingRoomId.prefix(16), privacy: .public)") + } uiState.phase = .webRtcExchange uiState.statusText = "Connecting..." if audioSessionActivated { @@ -373,7 +541,7 @@ final class CallManager: NSObject, ObservableObject { } case .activeCall: break - case .endCall, .endCallBecausePeerDisconnected, .endCallBecauseBusy: + case .ringingTimeout, .endCall, .endCallBecausePeerDisconnected, .endCallBecauseBusy: break } } @@ -406,22 +574,66 @@ final class CallManager: NSObject, ObservableObject { switch role { case .caller: - ProtocolManager.shared.sendCallSignal( - signalType: .keyExchange, - src: ownPublicKey, - dst: uiState.peerPublicKey, - sharedPublic: localPublicKeyHex - ) - ProtocolManager.shared.sendCallSignal( - signalType: .createRoom, - src: ownPublicKey, - dst: uiState.peerPublicKey - ) - uiState.phase = .webRtcExchange - uiState.statusText = "Creating room..." + switch signalingMode { + case .legacy: + ProtocolManager.shared.sendCallSignal( + signalType: .createRoom, + src: ownPublicKey, + dst: uiState.peerPublicKey + ) + uiState.phase = .keyExchange + uiState.statusText = "Finalizing call..." + callLogger.notice("[Call] legacy path: KEY_EXCHANGE complete -> ACTIVE") + case .createRoom: + ProtocolManager.shared.sendCallSignal( + signalType: .keyExchange, + src: ownPublicKey, + dst: uiState.peerPublicKey, + sharedPublic: localPublicKeyHex + ) + ProtocolManager.shared.sendCallSignal( + signalType: .createRoom, + src: ownPublicKey, + dst: uiState.peerPublicKey, + roomId: roomId + ) + uiState.phase = .webRtcExchange + uiState.statusText = "Creating room..." + callLogger.notice("[Call] create-room path: KEY_EXCHANGE complete -> CREATE_ROOM") + case .undecided: + setSignalingMode(.createRoom, reason: "fallback on caller key exchange") + ProtocolManager.shared.sendCallSignal( + signalType: .keyExchange, + src: ownPublicKey, + dst: uiState.peerPublicKey, + sharedPublic: localPublicKeyHex + ) + ProtocolManager.shared.sendCallSignal( + signalType: .createRoom, + src: ownPublicKey, + dst: uiState.peerPublicKey, + roomId: roomId + ) + uiState.phase = .webRtcExchange + uiState.statusText = "Creating room..." + } case .callee: - uiState.phase = .keyExchange - uiState.statusText = "Waiting for room..." + switch signalingMode { + case .legacy: + ProtocolManager.shared.sendCallSignal( + signalType: .keyExchange, + src: ownPublicKey, + dst: uiState.peerPublicKey, + sharedPublic: localPublicKeyHex + ) + uiState.phase = .keyExchange + uiState.statusText = "Waiting for active signal..." + callLogger.notice("[Call] legacy path: sent KEY_EXCHANGE, waiting for ACTIVE") + case .createRoom, .undecided: + uiState.phase = .keyExchange + uiState.statusText = "Waiting for room..." + callLogger.notice("[Call] create-room path: waiting for room signal") + } case .none: break } diff --git a/Rosetta/Core/Services/CallModels.swift b/Rosetta/Core/Services/CallModels.swift index cd35ad7..2ab0c62 100644 --- a/Rosetta/Core/Services/CallModels.swift +++ b/Rosetta/Core/Services/CallModels.swift @@ -15,6 +15,12 @@ enum CallRole: Sendable { case callee } +enum CallSignalingMode: String, Sendable { + case undecided + case legacy + case createRoom +} + enum CallActionResult: Sendable { case started case alreadyInCall diff --git a/Rosetta/Core/Utils/DarkMode+Helpers.swift b/Rosetta/Core/Utils/DarkMode+Helpers.swift index c1e20e5..9ca047d 100644 --- a/Rosetta/Core/Utils/DarkMode+Helpers.swift +++ b/Rosetta/Core/Utils/DarkMode+Helpers.swift @@ -17,7 +17,7 @@ struct DarkModeWrapper: View { content .onAppear { if overlayWindow == nil { - if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene { + if let windowScene = activeWindowScene { let overlayWindow = UIWindow(windowScene: windowScene) overlayWindow.tag = 0320 overlayWindow.isHidden = false @@ -27,31 +27,41 @@ struct DarkModeWrapper: View { } } .onChange(of: activateDarkMode, initial: true) { _, newValue in - if let keyWindow = (UIApplication.shared.connectedScenes.first as? UIWindowScene)?.windows.first(where: { $0.isKeyWindow }) { - keyWindow.overrideUserInterfaceStyle = newValue ? .dark : .light + if let windowScene = activeWindowScene { + let style: UIUserInterfaceStyle = newValue ? .dark : .light + for window in windowScene.windows { + window.overrideUserInterfaceStyle = style + } } } } + + private var activeWindowScene: UIWindowScene? { + let scenes = UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene } + return scenes.first(where: { $0.activationState == .foregroundActive }) ?? scenes.first + } } /// Theme toggle button with sun/moon icon and circular reveal animation. struct DarkModeButton: View { @State private var buttonRect: CGRect = .zero - @AppStorage("rosetta_dark_mode") private var toggleDarkMode: Bool = true + /// Local icon state — changes INSTANTLY on tap (no round-trip through UserDefaults). + @State private var showMoonIcon: Bool = true @AppStorage("rosetta_dark_mode") private var activateDarkMode: Bool = true var body: some View { Button(action: { - toggleDarkMode.toggle() + showMoonIcon.toggle() animateScheme() }, label: { - Image(systemName: toggleDarkMode ? "moon.fill" : "sun.max.fill") + Image(systemName: showMoonIcon ? "moon.fill" : "sun.max.fill") .font(.system(size: 16, weight: .medium)) .foregroundStyle(RosettaColors.Adaptive.text) - .symbolEffect(.bounce, value: toggleDarkMode) + .symbolEffect(.bounce, value: showMoonIcon) .frame(width: 44, height: 44) }) .buttonStyle(.plain) + .onAppear { showMoonIcon = activateDarkMode } .darkModeButtonRect { rect in buttonRect = rect } @@ -59,45 +69,52 @@ struct DarkModeButton: View { @MainActor func animateScheme() { + guard let windowScene = activeWindowScene, + let window = windowScene.windows.first(where: { $0.isKeyWindow }), + let overlayWindow = windowScene.windows.first(where: { $0.tag == 0320 }) + else { return } + + let targetDark = showMoonIcon + let frameSize = window.frame.size + + // 1. Capture old state SYNCHRONOUSLY — afterScreenUpdates:false is fast (~5ms). + let previousImage = window.darkModeSnapshotFast(frameSize) + + // 2. Show freeze frame IMMEDIATELY — user sees instant response. + // Overlay stays non-interactive so button taps always pass through. + let imageView = UIImageView(image: previousImage) + imageView.frame = window.frame + imageView.contentMode = .scaleAspectFit + overlayWindow.addSubview(imageView) + + // 3. Switch theme underneath the freeze frame (invisible to user). + activateDarkMode = targetDark + + // 4. Capture new state asynchronously after layout settles. Task { - if let windows = (UIApplication.shared.connectedScenes.first as? UIWindowScene)?.windows, - let window = windows.first(where: { $0.isKeyWindow }), - let overlayWindow = windows.first(where: { $0.tag == 0320 }) { + try? await Task.sleep(for: .seconds(0.06)) + window.layoutIfNeeded() + let currentImage = window.darkModeSnapshot(frameSize) - overlayWindow.isUserInteractionEnabled = true - let imageView = UIImageView() - imageView.frame = window.frame - imageView.image = window.darkModeSnapshot(window.frame.size) - imageView.contentMode = .scaleAspectFit - overlayWindow.addSubview(imageView) + let swiftUIView = DarkModeOverlayView( + buttonRect: buttonRect, + previousImage: previousImage, + currentImage: currentImage + ) - let frameSize = window.frame.size - // Capture old state - activateDarkMode = !toggleDarkMode - let previousImage = window.darkModeSnapshot(frameSize) - // Switch to new state - activateDarkMode = toggleDarkMode - // Allow layout to settle - try await Task.sleep(for: .seconds(0.01)) - let currentImage = window.darkModeSnapshot(frameSize) - - try await Task.sleep(for: .seconds(0.01)) - - let swiftUIView = DarkModeOverlayView( - buttonRect: buttonRect, - previousImage: previousImage, - currentImage: currentImage - ) - - let hostingController = UIHostingController(rootView: swiftUIView) - hostingController.view.backgroundColor = .clear - hostingController.view.frame = window.frame - hostingController.view.tag = 1009 - overlayWindow.addSubview(hostingController.view) - imageView.removeFromSuperview() - } + let hostingController = UIHostingController(rootView: swiftUIView) + hostingController.view.backgroundColor = .clear + hostingController.view.frame = window.frame + hostingController.view.tag = 1009 + overlayWindow.addSubview(hostingController.view) + imageView.removeFromSuperview() } } + + private var activeWindowScene: UIWindowScene? { + let scenes = UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene } + return scenes.first(where: { $0.activationState == .foregroundActive }) ?? scenes.first + } } // MARK: - Button Rect Tracking @@ -175,7 +192,6 @@ private struct DarkModeOverlayView: View { for view in window.subviews { view.removeFromSuperview() } - window.isUserInteractionEnabled = false } } } @@ -198,10 +214,20 @@ private struct DarkModeOverlayView: View { // MARK: - UIView Snapshot private extension UIView { + /// Full-fidelity snapshot — waits for pending layout. Use for the NEW state. func darkModeSnapshot(_ size: CGSize) -> UIImage { let renderer = UIGraphicsImageRenderer(size: size) return renderer.image { _ in drawHierarchy(in: .init(origin: .zero, size: size), afterScreenUpdates: true) } } + + /// Fast snapshot of CURRENT appearance — no layout wait (~5ms vs ~80ms). + /// Use for the OLD state (already on screen, nothing pending). + func darkModeSnapshotFast(_ size: CGSize) -> UIImage { + let renderer = UIGraphicsImageRenderer(size: size) + return renderer.image { _ in + drawHierarchy(in: .init(origin: .zero, size: size), afterScreenUpdates: false) + } + } } diff --git a/Rosetta/Features/Auth/ConfirmSeedPhraseView.swift b/Rosetta/Features/Auth/ConfirmSeedPhraseView.swift index 4265de6..6165b82 100644 --- a/Rosetta/Features/Auth/ConfirmSeedPhraseView.swift +++ b/Rosetta/Features/Auth/ConfirmSeedPhraseView.swift @@ -54,11 +54,11 @@ private extension ConfirmSeedPhraseView { VStack(spacing: 12) { Text("Confirm Backup") .font(.system(size: 28, weight: .bold)) - .foregroundStyle(.white) + .foregroundStyle(RosettaColors.Adaptive.text) Text("Enter words #2, #5, #9, #12 to confirm\nyou've backed up your phrase.") .font(.system(size: 15)) - .foregroundStyle(Color.white.opacity(0.7)) + .foregroundStyle(RosettaColors.Adaptive.textSecondary) .multilineTextAlignment(.center) .lineSpacing(3) } @@ -130,7 +130,7 @@ private extension ConfirmSeedPhraseView { TextField("enter", text: $confirmationInputs[inputIndex]) .font(.system(size: 17, weight: .semibold, design: .monospaced)) - .foregroundStyle(.white) + .foregroundStyle(RosettaColors.Adaptive.text) .autocorrectionDisabled() .textInputAutocapitalization(.never) .focused($focusedInputIndex, equals: inputIndex) diff --git a/Rosetta/Features/Auth/ImportSeedPhraseView.swift b/Rosetta/Features/Auth/ImportSeedPhraseView.swift index ab60be0..51229e0 100644 --- a/Rosetta/Features/Auth/ImportSeedPhraseView.swift +++ b/Rosetta/Features/Auth/ImportSeedPhraseView.swift @@ -53,11 +53,11 @@ private extension ImportSeedPhraseView { VStack(spacing: 12) { Text("Import Account") .font(.system(size: 28, weight: .bold)) - .foregroundStyle(.white) + .foregroundStyle(RosettaColors.Adaptive.text) Text("Enter your 12-word recovery phrase\nto restore your account.") .font(.system(size: 15)) - .foregroundStyle(Color.white.opacity(0.7)) + .foregroundStyle(RosettaColors.Adaptive.textSecondary) .multilineTextAlignment(.center) .lineSpacing(3) } @@ -140,7 +140,7 @@ private extension ImportSeedPhraseView { TextField("word", text: $importedWords[index]) .font(.system(size: 17, weight: .semibold, design: .monospaced)) - .foregroundStyle(hasContent ? color : .white) + .foregroundStyle(hasContent ? color : RosettaColors.Adaptive.text) .autocorrectionDisabled() .textInputAutocapitalization(.never) .focused($focusedWordIndex, equals: index) diff --git a/Rosetta/Features/Auth/PasswordStrengthView.swift b/Rosetta/Features/Auth/PasswordStrengthView.swift index 013939a..19ab017 100644 --- a/Rosetta/Features/Auth/PasswordStrengthView.swift +++ b/Rosetta/Features/Auth/PasswordStrengthView.swift @@ -52,7 +52,7 @@ struct PasswordStrengthIndicator: View { HStack(spacing: 4) { ForEach(0..<3, id: \.self) { index in RoundedRectangle(cornerRadius: 2) - .fill(index <= strength.rawValue ? strength.color : Color.white.opacity(0.1)) + .fill(index <= strength.rawValue ? strength.color : RosettaColors.Adaptive.text.opacity(0.1)) .frame(height: 4) .animation(.easeInOut(duration: 0.25), value: strength) } @@ -107,7 +107,7 @@ struct WeakPasswordWarning: View { Text("Your password is too weak. Consider using at least 6 characters for better security.") .font(.system(size: 13)) - .foregroundStyle(Color.white.opacity(0.7)) + .foregroundStyle(RosettaColors.Adaptive.textSecondary) .lineSpacing(2) } .padding(14) diff --git a/Rosetta/Features/Auth/SeedPhraseView.swift b/Rosetta/Features/Auth/SeedPhraseView.swift index 79310f2..ca709b1 100644 --- a/Rosetta/Features/Auth/SeedPhraseView.swift +++ b/Rosetta/Features/Auth/SeedPhraseView.swift @@ -48,13 +48,13 @@ private extension SeedPhraseView { VStack(spacing: 12) { Text("Your Recovery Phrase") .font(.system(size: 28, weight: .bold)) - .foregroundStyle(.white) + .foregroundStyle(RosettaColors.Adaptive.text) .opacity(isContentVisible ? 1.0 : 0.0) .animation(.easeOut(duration: 0.3), value: isContentVisible) Text("Write down these 12 words in order.\nYou'll need them to restore your account.") .font(.system(size: 15)) - .foregroundStyle(Color.white.opacity(0.7)) + .foregroundStyle(RosettaColors.Adaptive.textSecondary) .multilineTextAlignment(.center) .lineSpacing(3) .opacity(isContentVisible ? 1.0 : 0.0) diff --git a/Rosetta/Features/Auth/SetPasswordView.swift b/Rosetta/Features/Auth/SetPasswordView.swift index 2ec8253..2434e8b 100644 --- a/Rosetta/Features/Auth/SetPasswordView.swift +++ b/Rosetta/Features/Auth/SetPasswordView.swift @@ -111,13 +111,13 @@ private extension SetPasswordView { VStack(spacing: 8) { Text(isImportMode ? "Recover Account" : "Protect Your Account") .font(.system(size: 24, weight: .bold)) - .foregroundStyle(.white) + .foregroundStyle(RosettaColors.Adaptive.text) Text(isImportMode ? "Set a password to protect your recovered account.\nYou'll need it to unlock Rosetta." : "This password encrypts your keys locally.\nYou'll need it to unlock Rosetta.") .font(.system(size: 14)) - .foregroundStyle(Color.white.opacity(0.7)) + .foregroundStyle(RosettaColors.Adaptive.textSecondary) .multilineTextAlignment(.center) .lineSpacing(2) } @@ -178,7 +178,7 @@ private extension SetPasswordView { Text("Your password is never stored or sent anywhere. It's only used to encrypt your keys locally.") .font(.system(size: 13)) - .foregroundStyle(Color.white.opacity(0.7)) + .foregroundStyle(RosettaColors.Adaptive.textSecondary) .lineSpacing(2) } .padding(16) @@ -291,7 +291,7 @@ private struct SecureToggleField: UIViewRepresentable { tf.isSecureTextEntry = true tf.font = .systemFont(ofSize: 16) - tf.textColor = .white + tf.textColor = .label tf.tintColor = UIColor(RosettaColors.primaryBlue) tf.autocapitalizationType = .none tf.autocorrectionType = .no @@ -318,7 +318,7 @@ private struct SecureToggleField: UIViewRepresentable { let config = UIImage.SymbolConfiguration(pointSize: 14, weight: .thin) let eyeButton = UIButton(type: .system) eyeButton.setImage(UIImage(systemName: "eye", withConfiguration: config), for: .normal) - eyeButton.tintColor = UIColor.white.withAlphaComponent(0.5) + eyeButton.tintColor = UIColor.secondaryLabel eyeButton.addTarget( context.coordinator, action: #selector(Coordinator.toggleSecure), diff --git a/Rosetta/Features/Auth/WelcomeView.swift b/Rosetta/Features/Auth/WelcomeView.swift index 0dd2ee0..ae9a7cf 100644 --- a/Rosetta/Features/Auth/WelcomeView.swift +++ b/Rosetta/Features/Auth/WelcomeView.swift @@ -80,7 +80,7 @@ private extension WelcomeView { var titleSection: some View { Text("Your Keys,\nYour Messages") .font(.system(size: 32, weight: .bold)) - .foregroundStyle(.white) + .foregroundStyle(RosettaColors.Adaptive.text) .multilineTextAlignment(.center) .opacity(isVisible ? 1.0 : 0.0) .offset(y: isVisible ? 0 : 16) @@ -90,7 +90,7 @@ private extension WelcomeView { var subtitleSection: some View { Text("Secure messaging with\ncryptographic keys") .font(.system(size: 16)) - .foregroundStyle(Color.white.opacity(0.7)) + .foregroundStyle(RosettaColors.Adaptive.textSecondary) .multilineTextAlignment(.center) .opacity(isVisible ? 1.0 : 0.0) .offset(y: isVisible ? 0 : 12) @@ -122,7 +122,7 @@ private extension WelcomeView { Text(label) .font(.system(size: 13, weight: .medium)) - .foregroundStyle(Color.white.opacity(0.7)) + .foregroundStyle(RosettaColors.Adaptive.textSecondary) } .accessibilityElement(children: .combine) .accessibilityLabel(label) diff --git a/Rosetta/Features/Chats/ChatDetail/AttachmentPanelView.swift b/Rosetta/Features/Chats/ChatDetail/AttachmentPanelView.swift index 4b41b6e..f377a99 100644 --- a/Rosetta/Features/Chats/ChatDetail/AttachmentPanelView.swift +++ b/Rosetta/Features/Chats/ChatDetail/AttachmentPanelView.swift @@ -132,7 +132,7 @@ struct AttachmentPanelView: View { VStack(spacing: 0) { // Custom drag indicator (replaces system .presentationDragIndicator) Capsule() - .fill(Color.white.opacity(0.3)) + .fill(RosettaColors.Adaptive.text.opacity(0.3)) .frame(width: 36, height: 5) .padding(.top, 5) .padding(.bottom, 14) @@ -144,7 +144,7 @@ struct AttachmentPanelView: View { dismiss() } label: { CloseIconShape() - .fill(.white) + .fill(RosettaColors.Adaptive.text) .frame(width: 14, height: 14) .frame(width: 44, height: 44) } @@ -157,10 +157,10 @@ struct AttachmentPanelView: View { HStack(spacing: 5) { Text(tabTitle) .font(.system(size: 20, weight: .semibold)) - .foregroundStyle(.white) + .foregroundStyle(RosettaColors.Adaptive.text) Image(systemName: "chevron.down") .font(.system(size: 13, weight: .bold)) - .foregroundStyle(.white.opacity(0.45)) + .foregroundStyle(RosettaColors.Adaptive.text.opacity(0.45)) } Spacer() @@ -195,9 +195,9 @@ struct AttachmentPanelView: View { Text("\(selectedAssets.count)") .font(.system(size: 12, weight: .bold)) } - .foregroundStyle(.white) + .foregroundStyle(.white) // White on blue badge is always correct .frame(width: 44, height: 28) - .background(Color(hex: 0x008BFF), in: Capsule()) + .background(RosettaColors.primaryBlue, in: Capsule()) } /// Glass circle background matching GlassBackButton (ButtonStyles.swift lines 22–34). @@ -233,12 +233,12 @@ struct AttachmentPanelView: View { // Title Text("Send Avatar") .font(.system(size: 20, weight: .semibold)) - .foregroundStyle(.white) + .foregroundStyle(RosettaColors.Adaptive.text) // Subtitle Text("Share your profile avatar\nwith this contact") .font(.system(size: 14)) - .foregroundStyle(.white.opacity(0.5)) + .foregroundStyle(RosettaColors.Adaptive.textSecondary) .multilineTextAlignment(.center) // Send button (capsule style matching File tab's "Browse Files" button) @@ -253,10 +253,10 @@ struct AttachmentPanelView: View { } label: { Text(hasAvatar ? "Send Avatar" : "Set Avatar") .font(.system(size: 15, weight: .medium)) - .foregroundStyle(.white) + .foregroundStyle(.white) // White on blue button is always correct .padding(.horizontal, 24) .padding(.vertical, 10) - .background(Color(hex: 0x008BFF), in: Capsule()) + .background(RosettaColors.primaryBlue, in: Capsule()) } Spacer() @@ -284,12 +284,12 @@ struct AttachmentPanelView: View { // Title Text("Send File") .font(.system(size: 20, weight: .semibold)) - .foregroundStyle(.white) + .foregroundStyle(RosettaColors.Adaptive.text) // Subtitle Text("Select a file to send") .font(.system(size: 14)) - .foregroundStyle(.white.opacity(0.5)) + .foregroundStyle(RosettaColors.Adaptive.textSecondary) .multilineTextAlignment(.center) // Browse button (capsule style matching avatar tab) @@ -298,10 +298,10 @@ struct AttachmentPanelView: View { } label: { Text("Browse Files") .font(.system(size: 15, weight: .medium)) - .foregroundStyle(.white) + .foregroundStyle(.white) // White on blue button is always correct .padding(.horizontal, 24) .padding(.vertical, 10) - .background(Color(hex: 0x008BFF), in: Capsule()) + .background(RosettaColors.primaryBlue, in: Capsule()) } Spacer() @@ -337,14 +337,14 @@ struct AttachmentPanelView: View { } .animation(.easeInOut(duration: 0.25), value: hasSelection) .background { - // iOS < 26: gradient fade behind tab bar (dark theme). + // iOS < 26: gradient fade behind tab bar. // iOS 26+: no gradient — Liquid Glass pill is self-contained. if #unavailable(iOS 26) { LinearGradient( stops: [ .init(color: .clear, location: 0), - .init(color: .black.opacity(0.6), location: 0.3), - .init(color: .black, location: 0.8), + .init(color: RosettaColors.Adaptive.surface.opacity(0.85), location: 0.3), + .init(color: RosettaColors.Adaptive.surface, location: 0.8), ], startPoint: .top, endPoint: .bottom @@ -364,8 +364,8 @@ struct AttachmentPanelView: View { // Caption text field TextField("Add a caption...", text: $captionText) .font(.system(size: 16)) - .foregroundStyle(.white) - .tint(Color(hex: 0x008BFF)) + .foregroundStyle(RosettaColors.Adaptive.text) + .tint(RosettaColors.primaryBlue) .padding(.leading, 6) // Emoji icon (exact ChatDetail match: TelegramVectorIcon emojiMoon) @@ -389,7 +389,7 @@ struct AttachmentPanelView: View { ) .frame(width: 22, height: 19) .frame(width: 38, height: 36) - .background { Capsule().fill(Color(hex: 0x008BFF)) } + .background { Capsule().fill(RosettaColors.primaryBlue) } } } .padding(3) @@ -457,7 +457,7 @@ struct AttachmentPanelView: View { private func legacyTabButton(_ tab: AttachmentTab, icon: String, unselectedIcon: String, label: String) -> some View { let isSelected = selectedTab == tab - let tint = isSelected ? Color(hex: 0x008BFF) : .white + let tint = isSelected ? RosettaColors.primaryBlue : RosettaColors.Adaptive.text return Button { UIImpactFeedbackGenerator(style: .light).impactOccurred() diff --git a/Rosetta/Features/Chats/ChatDetail/ComposerView.swift b/Rosetta/Features/Chats/ChatDetail/ComposerView.swift index 5c3c160..f71ae14 100644 --- a/Rosetta/Features/Chats/ChatDetail/ComposerView.swift +++ b/Rosetta/Features/Chats/ChatDetail/ComposerView.swift @@ -66,6 +66,10 @@ final class ComposerView: UIView, UITextViewDelegate { // Mic button (glass circle, 42×42) private let micButton = UIButton(type: .system) private let micGlass = TelegramGlassUIView(frame: .zero) + private var attachIconLayer: CAShapeLayer? + private var emojiIconLayer: CAShapeLayer? + private var sendIconLayer: CAShapeLayer? + private var micIconLayer: CAShapeLayer? // MARK: - Layout Constants @@ -113,6 +117,7 @@ final class ComposerView: UIView, UITextViewDelegate { color: .label ) attachButton.layer.addSublayer(attachIcon) + attachIconLayer = attachIcon attachButton.tag = 1 // for icon centering in layoutSubviews attachButton.addTarget(self, action: #selector(attachTapped), for: .touchUpInside) addSubview(attachButton) @@ -188,6 +193,7 @@ final class ComposerView: UIView, UITextViewDelegate { color: .secondaryLabel ) emojiButton.layer.addSublayer(emojiIcon) + emojiIconLayer = emojiIcon emojiButton.tag = 2 emojiButton.addTarget(self, action: #selector(emojiTapped), for: .touchUpInside) inputContainer.addSubview(emojiButton) @@ -204,6 +210,7 @@ final class ComposerView: UIView, UITextViewDelegate { color: .white ) sendButton.layer.addSublayer(sendIcon) + sendIconLayer = sendIcon sendButton.tag = 3 sendButton.addTarget(self, action: #selector(sendTapped), for: .touchUpInside) sendButton.alpha = 0 @@ -221,9 +228,19 @@ final class ComposerView: UIView, UITextViewDelegate { color: .label ) micButton.layer.addSublayer(micIcon) + micIconLayer = micIcon micButton.tag = 4 micButton.addTarget(self, action: #selector(micTapped), for: .touchUpInside) addSubview(micButton) + + updateThemeColors() + + if #available(iOS 17.0, *) { + registerForTraitChanges([UITraitUserInterfaceStyle.self]) { (self: ComposerView, previousTraitCollection: UITraitCollection) in + guard self.traitCollection.userInterfaceStyle != previousTraitCollection.userInterfaceStyle else { return } + self.updateThemeColors() + } + } } // MARK: - Public API @@ -426,6 +443,19 @@ final class ComposerView: UIView, UITextViewDelegate { } } + private func updateThemeColors() { + attachIconLayer?.fillColor = UIColor.label.cgColor + emojiIconLayer?.fillColor = UIColor.secondaryLabel.cgColor + sendIconLayer?.fillColor = UIColor.white.cgColor + micIconLayer?.fillColor = UIColor.label.cgColor + + replyTitleLabel.textColor = .label + replyPreviewLabel.textColor = .label + replyCancelButton.tintColor = .secondaryLabel + textView.textColor = .label + textView.placeholderLabel.textColor = .placeholderText + } + // MARK: - Text Height private func recalculateTextHeight() { @@ -498,6 +528,12 @@ final class ComposerView: UIView, UITextViewDelegate { } } + override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + if #available(iOS 17.0, *) { return } + guard traitCollection.userInterfaceStyle != previousTraitCollection?.userInterfaceStyle else { return } + updateThemeColors() + } + // MARK: - UITextViewDelegate func textViewShouldBeginEditing(_ textView: UITextView) -> Bool { diff --git a/Rosetta/Features/Chats/ChatDetail/NativeMessageCell.swift b/Rosetta/Features/Chats/ChatDetail/NativeMessageCell.swift index 62eab65..af3976f 100644 --- a/Rosetta/Features/Chats/ChatDetail/NativeMessageCell.swift +++ b/Rosetta/Features/Chats/ChatDetail/NativeMessageCell.swift @@ -66,13 +66,46 @@ final class NativeMessageCell: UICollectionViewCell { ctx.fillPath() let image = UIGraphicsGetImageFromCurrentImageContext() ?? UIImage() UIGraphicsEndImageContext() - return image.withRenderingMode(.alwaysOriginal) + return image.withRenderingMode(.alwaysTemplate) }() - // Telegram-exact stretchable bubble images (raster, not vector — only way to get exact tail) - private static let bubbleImages = BubbleImageFactory.generate( + // Telegram-exact stretchable bubble images (raster, not vector — only way to get exact tail). + // `var` so they can be regenerated on theme switch (colors baked into raster at generation time). + private static var bubbleImages = BubbleImageFactory.generate( outgoingColor: outgoingColor, incomingColor: incomingColor ) + private static var bubbleImagesStyle: UIUserInterfaceStyle = .unspecified + + /// Regenerate cached bubble images after theme change. + /// Must be called on main thread. `performAsCurrent` ensures dynamic + /// `incomingColor` resolves with the correct light/dark traits. + static func regenerateBubbleImages(with traitCollection: UITraitCollection) { + traitCollection.performAsCurrent { + bubbleImages = BubbleImageFactory.generate( + outgoingColor: outgoingColor, + incomingColor: incomingColor + ) + } + bubbleImagesStyle = normalizedInterfaceStyle(from: traitCollection) + } + + /// Ensure bubble image cache matches the current interface style. + /// Covers cases where the theme was changed while chat screen was not mounted. + static func ensureBubbleImages(for traitCollection: UITraitCollection) { + let style = normalizedInterfaceStyle(from: traitCollection) + guard bubbleImagesStyle != style else { return } + regenerateBubbleImages(with: traitCollection) + } + + private static func normalizedInterfaceStyle(from traitCollection: UITraitCollection) -> UIUserInterfaceStyle { + switch traitCollection.userInterfaceStyle { + case .dark, .light: + return traitCollection.userInterfaceStyle + default: + let prefersDark = UserDefaults.standard.object(forKey: "rosetta_dark_mode") as? Bool ?? true + return prefersDark ? .dark : .light + } + } private static let blurHashCache: NSCache = { let cache = NSCache() cache.countLimit = 200 @@ -456,13 +489,20 @@ final class NativeMessageCell: UICollectionViewCell { bubbleView.addSubview(highlightOverlay) // Swipe reply icon — circle + Telegram-exact arrow (same vector as SwiftUI SwipeToReplyModifier) - replyCircleView.backgroundColor = UIColor.white.withAlphaComponent(0.12) + replyCircleView.backgroundColor = UIColor { traits in + traits.userInterfaceStyle == .dark + ? UIColor.white.withAlphaComponent(0.12) + : UIColor.black.withAlphaComponent(0.12) + } replyCircleView.layer.cornerRadius = 17 // 34pt / 2 replyCircleView.alpha = 0 contentView.addSubview(replyCircleView) replyIconView.image = Self.telegramReplyArrowImage replyIconView.contentMode = .scaleAspectFit + replyIconView.tintColor = UIColor { traits in + traits.userInterfaceStyle == .dark ? .white : .black + } replyIconView.alpha = 0 contentView.addSubview(replyIconView) @@ -513,16 +553,18 @@ final class NativeMessageCell: UICollectionViewCell { // Same CTTypesetter pipeline → identical line breaks, zero recomputation. textLabel.textLayout = textLayout - // Timestamp + // Timestamp — dynamic UIColor for incoming so theme changes resolve instantly timestampLabel.text = timestamp if isMediaStatus { timestampLabel.textColor = .white + } else if isOutgoing { + timestampLabel.textColor = UIColor.white.withAlphaComponent(0.55) } else { - let isDark = traitCollection.userInterfaceStyle == .dark - let tsAlpha: CGFloat = isOutgoing ? 0.55 : 0.6 - timestampLabel.textColor = (isOutgoing || isDark) - ? UIColor.white.withAlphaComponent(tsAlpha) - : UIColor.black.withAlphaComponent(0.45) + timestampLabel.textColor = UIColor { traits in + traits.userInterfaceStyle == .dark + ? UIColor.white.withAlphaComponent(0.6) + : UIColor.black.withAlphaComponent(0.45) + } } // Delivery checkmarks (Telegram two-node pattern: checkSent + checkRead) @@ -566,7 +608,6 @@ final class NativeMessageCell: UICollectionViewCell { // Reply quote — Telegram parity colors if let replyName { replyContainer.isHidden = false - let isDark = traitCollection.userInterfaceStyle == .dark replyContainer.backgroundColor = isOutgoing ? UIColor.white.withAlphaComponent(0.12) : Self.outgoingColor.withAlphaComponent(0.12) @@ -574,7 +615,11 @@ final class NativeMessageCell: UICollectionViewCell { replyNameLabel.text = replyName replyNameLabel.textColor = isOutgoing ? .white : Self.outgoingColor replyTextLabel.text = replyText ?? "" - replyTextLabel.textColor = (isOutgoing || isDark) ? .white : .darkGray + replyTextLabel.textColor = isOutgoing + ? .white + : UIColor { traits in + traits.userInterfaceStyle == .dark ? .white : .darkGray + } } else { replyContainer.isHidden = true } @@ -786,6 +831,7 @@ final class NativeMessageCell: UICollectionViewCell { override func layoutSubviews() { super.layoutSubviews() guard let layout = currentLayout else { return } + Self.ensureBubbleImages(for: traitCollection) let cellW = contentView.bounds.width let tailProtrusion = Self.bubbleMetrics.tailProtrusion diff --git a/Rosetta/Features/Chats/ChatDetail/NativeMessageList.swift b/Rosetta/Features/Chats/ChatDetail/NativeMessageList.swift index c322fc8..603ed57 100644 --- a/Rosetta/Features/Chats/ChatDetail/NativeMessageList.swift +++ b/Rosetta/Features/Chats/ChatDetail/NativeMessageList.swift @@ -183,6 +183,15 @@ final class NativeMessageListController: UIViewController { self, selector: #selector(handleAvatarDidUpdate), name: Notification.Name("avatarDidUpdate"), object: nil ) + // Regenerate bubble images + full cell refresh on theme switch. + registerForTraitChanges([UITraitUserInterfaceStyle.self]) { (self: NativeMessageListController, previousTraitCollection: UITraitCollection) in + let oldStyle = previousTraitCollection.userInterfaceStyle + let newStyle = self.traitCollection.userInterfaceStyle + guard oldStyle != newStyle else { return } + NativeMessageCell.regenerateBubbleImages(with: self.traitCollection) + self.calculateLayouts() + self.refreshAllMessageCells() + } } @objc private func handleAvatarDidUpdate() { @@ -483,6 +492,9 @@ final class NativeMessageListController: UIViewController { let imageView = UIImageView(image: Self.makeTelegramDownButtonImage()) imageView.contentMode = .center imageView.frame = rect + imageView.tintColor = UIColor { traits in + traits.userInterfaceStyle == .dark ? .white : .black + } button.addSubview(imageView) let badgeView = UIView(frame: .zero) @@ -585,7 +597,7 @@ final class NativeMessageListController: UIViewController { gc.addLine(to: CGPoint(x: cx, y: cy + 4.5)) // bottom-center gc.addLine(to: CGPoint(x: cx + 9.0, y: cy - 4.5)) // top-right gc.strokePath() - }.withRenderingMode(.alwaysOriginal) + }.withRenderingMode(.alwaysTemplate) } private static func compactUnreadCountString(_ count: Int) -> String { @@ -614,7 +626,9 @@ final class NativeMessageListController: UIViewController { c.addSubview(glass) let l = UILabel() l.font = UIFont.systemFont(ofSize: 12, weight: .medium) - l.textColor = .white + l.textColor = UIColor { traits in + traits.userInterfaceStyle == .dark ? .white : .black + } l.textAlignment = .center c.addSubview(l) return (c, l) @@ -1071,6 +1085,18 @@ final class NativeMessageListController: UIViewController { dataSource.apply(snapshot, animatingDifferences: false) } + /// Reconfigure all message cells and force a layout pass. + /// Used for deterministic theme refresh when trait style changes. + private func refreshAllMessageCells() { + var snapshot = dataSource.snapshot() + let allIds = snapshot.itemIdentifiers + guard !allIds.isEmpty else { return } + snapshot.reconfigureItems(allIds) + dataSource.apply(snapshot, animatingDifferences: false) + collectionView.collectionViewLayout.invalidateLayout() + collectionView.layoutIfNeeded() + } + // MARK: - Bubble Position private func bubblePosition(for message: ChatMessage, at reversedIndex: Int) -> BubblePosition { diff --git a/Rosetta/Features/Chats/ChatDetail/TelegramContextMenuCardView.swift b/Rosetta/Features/Chats/ChatDetail/TelegramContextMenuCardView.swift index 60656f5..336ecc1 100644 --- a/Rosetta/Features/Chats/ChatDetail/TelegramContextMenuCardView.swift +++ b/Rosetta/Features/Chats/ChatDetail/TelegramContextMenuCardView.swift @@ -18,13 +18,25 @@ final class TelegramContextMenuCardView: UIView { private static let iconSize: CGFloat = 24 private static let screenPixel = 1.0 / max(UIScreen.main.scale, 1) - // MARK: - Colors (Telegram dark theme) + // MARK: - Colors (adaptive light/dark) - private static let tintBg = UIColor(red: 0x25/255, green: 0x25/255, blue: 0x25/255, alpha: 0.78) - private static let textColor = UIColor.white + private static let tintBg = UIColor { traits in + traits.userInterfaceStyle == .dark + ? UIColor(red: 0x25/255, green: 0x25/255, blue: 0x25/255, alpha: 0.78) + : UIColor(red: 0xF5/255, green: 0xF5/255, blue: 0xF7/255, alpha: 0.78) + } + private static let textColor = UIColor.label private static let destructiveColor = UIColor(red: 0xEB/255, green: 0x55/255, blue: 0x45/255, alpha: 1) - private static let separatorColor = UIColor(white: 1, alpha: 0.15) - private static let highlightColor = UIColor(white: 1, alpha: 0.15) + private static let separatorColor = UIColor { traits in + traits.userInterfaceStyle == .dark + ? UIColor(white: 1, alpha: 0.15) + : UIColor(white: 0, alpha: 0.1) + } + private static let highlightColor = UIColor { traits in + traits.userInterfaceStyle == .dark + ? UIColor(white: 1, alpha: 0.15) + : UIColor(white: 0, alpha: 0.08) + } // MARK: - Public @@ -33,7 +45,7 @@ final class TelegramContextMenuCardView: UIView { // MARK: - Views - private let blurView = UIVisualEffectView(effect: UIBlurEffect(style: .systemMaterialDark)) + private let blurView = UIVisualEffectView(effect: UIBlurEffect(style: .systemMaterial)) private let tintView = UIView() private let items: [TelegramContextMenuItem] diff --git a/Rosetta/RosettaApp.swift b/Rosetta/RosettaApp.swift index 0de65e2..924dfca 100644 --- a/Rosetta/RosettaApp.swift +++ b/Rosetta/RosettaApp.swift @@ -530,12 +530,13 @@ extension AppDelegate: PKPushRegistryDelegate { } let data = payload.dictionaryPayload Logger.voip.info("VoIP push received: \(data.description, privacy: .public)") - // Server sends: { "type": "CALL", "from": "", "callId": "" } + // Server sends: { "type": "CALL", "from": "", "callId": "", "joinToken": "" } // 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 + let joinToken = data["joinToken"] as? String // Resolve caller display name from multiple sources. let callerName: String = { // 1. Push payload (if server sends title) @@ -554,7 +555,7 @@ extension AppDelegate: PKPushRegistryDelegate { } return "Rosetta" }() - Logger.voip.info("VoIP resolved: key=\(callerKey.prefix(16), privacy: .public) name=\(callerName, privacy: .public) callId=\(callId ?? "nil", privacy: .public)") + Logger.voip.info("VoIP resolved: key=\(callerKey.prefix(16), privacy: .public) name=\(callerName, privacy: .public) callId=\(callId ?? "nil", privacy: .public) joinTokenPresent=\((joinToken?.isEmpty == false).description, privacy: .public)") // Apple REQUIREMENT: reportNewIncomingCall MUST be called SYNCHRONOUSLY. // Using Task { @MainActor } would introduce an async hop that may be @@ -597,7 +598,9 @@ extension AppDelegate: PKPushRegistryDelegate { } CallManager.shared.setupIncomingCallFromPush( callerKey: callerKey, - callerName: callerName + callerName: callerName, + callId: callId, + joinToken: joinToken ) } @@ -671,7 +674,6 @@ struct RosettaApp: App { @AppStorage("hasCompletedOnboarding") private var hasCompletedOnboarding = false @AppStorage("isLoggedIn") private var isLoggedIn = false @AppStorage("hasLaunchedBefore") private var hasLaunchedBefore = false - @AppStorage("rosetta_dark_mode") private var isDarkMode: Bool = true @State private var appState: AppState? @State private var transitionOverlay: Bool = false @@ -695,7 +697,11 @@ struct RosettaApp: App { .animation(.easeInOut(duration: 0.035), value: transitionOverlay) } } - .preferredColorScheme(isDarkMode ? .dark : .light) + // NOTE: preferredColorScheme removed — DarkModeWrapper is the single + // source of truth via window.overrideUserInterfaceStyle. Having both + // caused snapshot races where the hosting controller's stale + // preferredColorScheme(.dark) blocked the window's .light override, + // making dark→light circular reveal animation invisible. .onAppear { if appState == nil { appState = initialState() diff --git a/RosettaTests/CallPacketParityTests.swift b/RosettaTests/CallPacketParityTests.swift index 21a4171..ec3d5b8 100644 --- a/RosettaTests/CallPacketParityTests.swift +++ b/RosettaTests/CallPacketParityTests.swift @@ -2,12 +2,23 @@ import XCTest @testable import Rosetta final class CallPacketParityTests: XCTestCase { - func testSignalPeerRoundTripForCallKeyExchangeAndCreateRoom() throws { + func testSignalPeerRoundTripForLegacyCallAcceptEndCallAndKeyExchange() throws { let call = PacketSignalPeer( src: "02caller", dst: "02callee", sharedPublic: "", signalType: .call, + callId: "call-123", + joinToken: "join-123", + roomId: "" + ) + let accept = PacketSignalPeer( + src: "02callee", + dst: "02caller", + sharedPublic: "", + signalType: .accept, + callId: "call-123", + joinToken: "join-123", roomId: "" ) let keyExchange = PacketSignalPeer( @@ -15,14 +26,18 @@ final class CallPacketParityTests: XCTestCase { dst: "02caller", sharedPublic: "abcdef012345", signalType: .keyExchange, + callId: "", + joinToken: "", roomId: "" ) - let createRoom = PacketSignalPeer( + let endCall = PacketSignalPeer( src: "02caller", dst: "02callee", sharedPublic: "", - signalType: .createRoom, - roomId: "room-42" + signalType: .endCall, + callId: "call-123", + joinToken: "join-123", + roomId: "" ) let decodedCall = try decodeSignal(call) @@ -30,29 +45,67 @@ final class CallPacketParityTests: XCTestCase { XCTAssertEqual(decodedCall.src, "02caller") XCTAssertEqual(decodedCall.dst, "02callee") XCTAssertEqual(decodedCall.sharedPublic, "") + XCTAssertEqual(decodedCall.callId, "call-123") + XCTAssertEqual(decodedCall.joinToken, "join-123") XCTAssertEqual(decodedCall.roomId, "") + let decodedAccept = try decodeSignal(accept) + XCTAssertEqual(decodedAccept.signalType, .accept) + XCTAssertEqual(decodedAccept.callId, "call-123") + XCTAssertEqual(decodedAccept.joinToken, "join-123") + let decodedKeyExchange = try decodeSignal(keyExchange) XCTAssertEqual(decodedKeyExchange.signalType, .keyExchange) XCTAssertEqual(decodedKeyExchange.src, "02callee") XCTAssertEqual(decodedKeyExchange.dst, "02caller") XCTAssertEqual(decodedKeyExchange.sharedPublic, "abcdef012345") + XCTAssertEqual(decodedKeyExchange.callId, "") + XCTAssertEqual(decodedKeyExchange.joinToken, "") XCTAssertEqual(decodedKeyExchange.roomId, "") + let decodedEndCall = try decodeSignal(endCall) + XCTAssertEqual(decodedEndCall.signalType, .endCall) + XCTAssertEqual(decodedEndCall.callId, "call-123") + XCTAssertEqual(decodedEndCall.joinToken, "join-123") + } + + func testSignalCodeFourRoundTripForLegacyActiveAndCreateRoomFallback() throws { + let legacyActive = PacketSignalPeer( + src: "02caller", + dst: "02callee", + sharedPublic: "", + signalType: .createRoom, + callId: "", + joinToken: "", + roomId: "" + ) + let createRoom = PacketSignalPeer( + src: "02caller", + dst: "02callee", + sharedPublic: "", + signalType: .createRoom, + callId: "", + joinToken: "", + roomId: "room-42" + ) + + let decodedLegacyActive = try decodeSignal(legacyActive) + XCTAssertEqual(decodedLegacyActive.signalType, .createRoom) + XCTAssertEqual(decodedLegacyActive.roomId, "") + let decodedCreateRoom = try decodeSignal(createRoom) XCTAssertEqual(decodedCreateRoom.signalType, .createRoom) - XCTAssertEqual(decodedCreateRoom.src, "02caller") - XCTAssertEqual(decodedCreateRoom.dst, "02callee") - XCTAssertEqual(decodedCreateRoom.sharedPublic, "") XCTAssertEqual(decodedCreateRoom.roomId, "room-42") } - func testSignalPeerRoundTripForBusyAndPeerDisconnectedShortFormat() throws { + func testSignalPeerRoundTripForBusyPeerDisconnectedAndRingingTimeoutShortFormat() throws { let busy = PacketSignalPeer( src: "02should-not-be-sent", dst: "02should-not-be-sent", sharedPublic: "ignored", signalType: .endCallBecauseBusy, + callId: "ignored", + joinToken: "ignored", roomId: "ignored-room" ) let disconnected = PacketSignalPeer( @@ -60,6 +113,17 @@ final class CallPacketParityTests: XCTestCase { dst: "02should-not-be-sent", sharedPublic: "ignored", signalType: .endCallBecausePeerDisconnected, + callId: "ignored", + joinToken: "ignored", + roomId: "ignored-room" + ) + let ringingTimeout = PacketSignalPeer( + src: "02should-not-be-sent", + dst: "02should-not-be-sent", + sharedPublic: "ignored", + signalType: .ringingTimeout, + callId: "ignored", + joinToken: "ignored", roomId: "ignored-room" ) @@ -68,6 +132,8 @@ final class CallPacketParityTests: XCTestCase { XCTAssertEqual(decodedBusy.src, "") XCTAssertEqual(decodedBusy.dst, "") XCTAssertEqual(decodedBusy.sharedPublic, "") + XCTAssertEqual(decodedBusy.callId, "") + XCTAssertEqual(decodedBusy.joinToken, "") XCTAssertEqual(decodedBusy.roomId, "") let decodedDisconnected = try decodeSignal(disconnected) @@ -75,7 +141,18 @@ final class CallPacketParityTests: XCTestCase { XCTAssertEqual(decodedDisconnected.src, "") XCTAssertEqual(decodedDisconnected.dst, "") XCTAssertEqual(decodedDisconnected.sharedPublic, "") + XCTAssertEqual(decodedDisconnected.callId, "") + XCTAssertEqual(decodedDisconnected.joinToken, "") XCTAssertEqual(decodedDisconnected.roomId, "") + + let decodedTimeout = try decodeSignal(ringingTimeout) + XCTAssertEqual(decodedTimeout.signalType, .ringingTimeout) + XCTAssertEqual(decodedTimeout.src, "") + XCTAssertEqual(decodedTimeout.dst, "") + XCTAssertEqual(decodedTimeout.sharedPublic, "") + XCTAssertEqual(decodedTimeout.callId, "") + XCTAssertEqual(decodedTimeout.joinToken, "") + XCTAssertEqual(decodedTimeout.roomId, "") } func testWebRtcRoundTripForOfferAnswerAndIceCandidate() throws { diff --git a/RosettaTests/CallPushIntegrationTests.swift b/RosettaTests/CallPushIntegrationTests.swift index b955cf8..0df0332 100644 --- a/RosettaTests/CallPushIntegrationTests.swift +++ b/RosettaTests/CallPushIntegrationTests.swift @@ -97,12 +97,14 @@ struct SignalPeerCallFlowTests { let caller = "02a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2" let callee = "03f0e1d2c3b4a5968778695a4b3c2d1e0f9e8d7c6b5a49382716051a2b3c4d5e6f" let packet = PacketSignalPeer(src: caller, dst: callee, sharedPublic: "", - signalType: .call, roomId: "") + signalType: .call, callId: "call-1", joinToken: "join-1", roomId: "") let decoded = try decode(packet) #expect(decoded.signalType == .call) #expect(decoded.src == caller) #expect(decoded.dst == callee) #expect(decoded.sharedPublic == "") + #expect(decoded.callId == "call-1") + #expect(decoded.joinToken == "join-1") #expect(decoded.roomId == "") } @@ -153,6 +155,19 @@ struct SignalPeerCallFlowTests { #expect(decoded.signalType == .endCallBecausePeerDisconnected) } + @Test("RINGING_TIMEOUT short format — 3 bytes wire size") + func ringingTimeoutShortFormat() throws { + let packet = PacketSignalPeer(src: "ignored", dst: "ignored", sharedPublic: "ignored", + signalType: .ringingTimeout, roomId: "ignored") + let data = PacketRegistry.encode(packet) + #expect(data.count == 3) + + let decoded = try decode(packet) + #expect(decoded.signalType == .ringingTimeout) + #expect(decoded.src == "") + #expect(decoded.dst == "") + } + private func decode(_ packet: PacketSignalPeer) throws -> PacketSignalPeer { let data = PacketRegistry.encode(packet) guard let result = PacketRegistry.decode(from: data), @@ -171,7 +186,8 @@ struct CallPushEnumParityTests { arguments: [ (SignalType.call, 0), (SignalType.keyExchange, 1), (SignalType.activeCall, 2), (SignalType.endCall, 3), (SignalType.createRoom, 4), - (SignalType.endCallBecausePeerDisconnected, 5), (SignalType.endCallBecauseBusy, 6) + (SignalType.endCallBecausePeerDisconnected, 5), (SignalType.endCallBecauseBusy, 6), + (SignalType.accept, 7), (SignalType.ringingTimeout, 8) ]) func signalTypeEnumValues(pair: (SignalType, Int)) { #expect(pair.0.rawValue == pair.1) @@ -222,12 +238,12 @@ struct CallPushWireFormatTests { #expect(data[14] == 0x00); #expect(data[15] == 0x42) } - @Test("SignalPeer call byte layout: signalType→src→dst") + @Test("SignalPeer call byte layout: signalType→src→dst→callId→joinToken") func signalPeerCallByteLayout() { let packet = PacketSignalPeer(src: "S", dst: "D", sharedPublic: "", signalType: .call, roomId: "") let data = PacketRegistry.encode(packet) - #expect(data.count == 15) + #expect(data.count == 23) // packetId = 0x001A #expect(data[0] == 0x00); #expect(data[1] == 0x1A) @@ -241,6 +257,12 @@ struct CallPushWireFormatTests { #expect(data[9] == 0x00); #expect(data[10] == 0x00) #expect(data[11] == 0x00); #expect(data[12] == 0x01) #expect(data[13] == 0x00); #expect(data[14] == 0x44) + // callId "": length=0 + #expect(data[15] == 0x00); #expect(data[16] == 0x00) + #expect(data[17] == 0x00); #expect(data[18] == 0x00) + // joinToken "": length=0 + #expect(data[19] == 0x00); #expect(data[20] == 0x00) + #expect(data[21] == 0x00); #expect(data[22] == 0x00) } } diff --git a/RosettaTests/CallRoutingTests.swift b/RosettaTests/CallRoutingTests.swift index 28f85f1..2457779 100644 --- a/RosettaTests/CallRoutingTests.swift +++ b/RosettaTests/CallRoutingTests.swift @@ -1,3 +1,4 @@ +import CryptoKit import XCTest @testable import Rosetta @@ -101,4 +102,127 @@ final class CallRoutingTests: XCTestCase { XCTAssertEqual(CallManager.shared.uiState.peerPublicKey, peerA) XCTAssertEqual(CallManager.shared.uiState.statusText, "Calling...") } + + func testLegacyOutgoingFlowCallAcceptKeyExchangeActive() { + let start = CallManager.shared.startOutgoingCall( + toPublicKey: peerA, + title: "Peer A", + username: "peer_a" + ) + XCTAssertEqual(start, .started) + + let accept = PacketSignalPeer( + src: peerA, + dst: ownKey, + sharedPublic: "", + signalType: .accept, + callId: "call-legacy-1", + joinToken: "join-legacy-1", + roomId: "" + ) + CallManager.shared.testHandleSignalPacket(accept) + + XCTAssertEqual(CallManager.shared.signalingMode, .legacy) + XCTAssertEqual(CallManager.shared.callId, "call-legacy-1") + XCTAssertEqual(CallManager.shared.joinToken, "join-legacy-1") + XCTAssertEqual(CallManager.shared.uiState.phase, .keyExchange) + + let keyExchange = PacketSignalPeer( + src: peerA, + dst: ownKey, + sharedPublic: makePeerPublicHex(), + signalType: .keyExchange, + roomId: "" + ) + CallManager.shared.testHandleSignalPacket(keyExchange) + XCTAssertEqual(CallManager.shared.uiState.phase, .keyExchange) + + // Legacy ACTIVE arrives as signal code 4 with empty roomId. + let active = PacketSignalPeer( + src: peerA, + dst: ownKey, + sharedPublic: "", + signalType: .createRoom, + roomId: "" + ) + CallManager.shared.testHandleSignalPacket(active) + XCTAssertEqual(CallManager.shared.uiState.phase, .webRtcExchange) + } + + func testCreateRoomFallbackOutgoingFlowKeyExchangeBeforeAccept() { + let start = CallManager.shared.startOutgoingCall( + toPublicKey: peerA, + title: "Peer A", + username: "peer_a" + ) + XCTAssertEqual(start, .started) + + let keyExchange = PacketSignalPeer( + src: peerA, + dst: ownKey, + sharedPublic: makePeerPublicHex(), + signalType: .keyExchange, + roomId: "" + ) + CallManager.shared.testHandleSignalPacket(keyExchange) + + XCTAssertEqual(CallManager.shared.signalingMode, .createRoom) + XCTAssertEqual(CallManager.shared.uiState.phase, .webRtcExchange) + } + + func testDeferredAcceptCompletesWhenMetadataArrivesLater() { + CallManager.shared.setupIncomingCallFromPush( + callerKey: peerA, + callerName: "Peer A" + ) + XCTAssertEqual(CallManager.shared.uiState.phase, .incoming) + + let acceptResult = CallManager.shared.acceptIncomingCall() + XCTAssertEqual(acceptResult, .started) + XCTAssertTrue(CallManager.shared.pendingIncomingAccept) + XCTAssertEqual(CallManager.shared.uiState.phase, .keyExchange) + XCTAssertEqual(CallManager.shared.signalingMode, .undecided) + + let delayedCall = PacketSignalPeer( + src: peerA, + dst: ownKey, + sharedPublic: "", + signalType: .call, + callId: "call-delayed-1", + joinToken: "join-delayed-1", + roomId: "" + ) + CallManager.shared.testHandleSignalPacket(delayedCall) + + XCTAssertEqual(CallManager.shared.signalingMode, .legacy) + XCTAssertFalse(CallManager.shared.pendingIncomingAccept) + XCTAssertEqual(CallManager.shared.callId, "call-delayed-1") + XCTAssertEqual(CallManager.shared.joinToken, "join-delayed-1") + XCTAssertEqual(CallManager.shared.uiState.phase, .keyExchange) + } + + func testRingingTimeoutSignalTearsDownCall() { + let start = CallManager.shared.startOutgoingCall( + toPublicKey: peerA, + title: "Peer A", + username: "peer_a" + ) + XCTAssertEqual(start, .started) + + let timeoutPacket = PacketSignalPeer( + src: "", + dst: "", + sharedPublic: "", + signalType: .ringingTimeout, + roomId: "" + ) + CallManager.shared.testHandleSignalPacket(timeoutPacket) + + XCTAssertEqual(CallManager.shared.uiState.phase, .idle) + XCTAssertEqual(CallManager.shared.uiState.statusText, "No answer") + } + + private func makePeerPublicHex() -> String { + Curve25519.KeyAgreement.PrivateKey().publicKey.rawRepresentation.hexString + } }