import AVFAudio import CallKit import FirebaseCore import FirebaseCrashlytics import FirebaseMessaging import Intents import os import PushKit import SQLite3 import SwiftUI import UserNotifications private extension Logger { static let voip = Logger(subsystem: "com.rosetta.messenger", category: "VoIP") } // MARK: - Firebase AppDelegate final class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDelegate, MessagingDelegate { /// Pending chat route from notification tap — consumed by ChatListView. /// Handles terminated app (notification posted before ChatListView exists) /// and background app (didBecomeActiveNotification fallback). /// Timestamp prevents stale routes from being consumed on tab switches. static var pendingChatRoute: ChatRoute? static var pendingChatRouteTimestamp: Date? /// Max age (seconds) for a pending route to be considered fresh. static let pendingRouteExpirySeconds: TimeInterval = 3.0 /// Consume pending notification route only if it was set recently. /// Returns the route if fresh (< `pendingRouteExpirySeconds`), nil otherwise. /// Always clears both statics regardless of freshness. static func consumeFreshPendingRoute() -> ChatRoute? { defer { pendingChatRoute = nil pendingChatRouteTimestamp = nil } guard let route = pendingChatRoute, let ts = pendingChatRouteTimestamp, Date().timeIntervalSince(ts) < pendingRouteExpirySeconds else { return nil } return route } /// PushKit registry — must be retained for VoIP push token delivery. private var voipRegistry: PKPushRegistry? func application( _ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil ) -> Bool { FirebaseApp.configure() // Set delegates Messaging.messaging().delegate = self UNUserNotificationCenter.current().delegate = self // Register notification category with CarPlay support. let messageCategory = UNNotificationCategory( identifier: "message", actions: [], intentIdentifiers: [], options: [.allowInCarPlay] ) UNUserNotificationCenter.current().setNotificationCategories([messageCategory]) // Clear caches on memory pressure to prevent system from killing the app. NotificationCenter.default.addObserver( forName: UIApplication.didReceiveMemoryWarningNotification, object: nil, queue: .main ) { _ in Task { @MainActor in AvatarRepository.shared.clearCache() } } // Request notification permission (including CarPlay display). // .carPlay enables "Show in CarPlay" toggle in Settings > Notifications > Rosetta. UNUserNotificationCenter.current().requestAuthorization( options: [.alert, .badge, .sound, .carPlay] ) { granted, _ in if granted { DispatchQueue.main.async { application.registerForRemoteNotifications() } } } // 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 } // Forward APNs token to Firebase Messaging + SessionManager func application( _ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data ) { Messaging.messaging().apnsToken = deviceToken } // MARK: - Data-Only Push (Server parity: type/from/dialog fields) /// Server sends data-only push (`content-available: 1`) with custom fields: /// - `type`: `personal_message` | `group_message` | `read` /// - `from`: sender public key (personal) or group ID (group) /// - `dialog`: filled only for `type=read` — the dialog that was read on another device /// /// See `MessageDispatcher.java` in server for push payload construction. /// Android parity: 10-second dedup window per sender. private static var lastNotifTimestamps: [String: TimeInterval] = [:] private static let dedupWindowSeconds: TimeInterval = 10 func application( _ application: UIApplication, didReceiveRemoteNotification userInfo: [AnyHashable: Any], fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void ) { let pushType = userInfo["type"] as? String ?? "" let appState = application.applicationState Logger.voip.info("Push received: type=\(pushType, privacy: .public) appState=\(appState.rawValue, privacy: .public)") // MARK: type=read — clear notifications for dialog (read on another device). // Handle even in foreground: if user reads on Desktop, phone clears its notifications. if pushType == "read" { handleReadPush(userInfo: userInfo, completionHandler: completionHandler) return } // MARK: type=call — incoming call wake-up (high priority, no badge). // Server sends this when someone calls and the recipient's WebSocket is not connected. // In foreground: skip (CallManager handles calls via WebSocket protocol). // In background: show notification so user can tap to open app and receive the call. if pushType == "call" { guard application.applicationState != .active else { completionHandler(.noData) return } handleCallPush(userInfo: userInfo, completionHandler: completionHandler) return } // For message notifications, skip if foreground (WebSocket handles real-time). guard application.applicationState != .active else { completionHandler(.noData) return } let shared = UserDefaults(suiteName: "group.com.rosetta.dev") // MARK: Sender identification // Server sends `dialog` = sender public key (personal_message) or group ID (group_message). let senderKey = userInfo["dialog"] as? String ?? Self.extractSenderKey(from: userInfo) // Resolve sender display name from App Group cache (synced by DialogRepository). let contactNames = shared?.dictionary(forKey: "contact_display_names") as? [String: String] ?? [:] let senderName = contactNames[senderKey] ?? Self.firstNonBlank(userInfo, keys: ["sender_name", "from_title", "sender", "title", "name"]) ?? "Rosetta" // Android parity: 10-second dedup per sender. let dedupKey = senderKey.isEmpty ? "__no_sender__" : senderKey let now = Date().timeIntervalSince1970 if let lastTs = Self.lastNotifTimestamps[dedupKey], now - lastTs < Self.dedupWindowSeconds { completionHandler(.noData) return } Self.lastNotifTimestamps[dedupKey] = now // Check if the server already sent a visible alert (aps.alert exists). let aps = userInfo["aps"] as? [String: Any] let hasVisibleAlert = aps?["alert"] != nil // Don't notify for muted chats. let mutedKeys = shared?.stringArray(forKey: "muted_chats_keys") ?? UserDefaults.standard.stringArray(forKey: "muted_chats_keys") ?? [] let isMuted = !senderKey.isEmpty && mutedKeys.contains(senderKey) // If server sent visible alert, NSE handles sound+badge — don't double-count. // If muted, wake app but don't show notification (NSE also suppresses muted). if hasVisibleAlert || isMuted { completionHandler(.newData) return } // MARK: Increment badge + create local notification let currentBadge = shared?.integer(forKey: "app_badge_count") ?? 0 let newBadge = currentBadge + 1 shared?.set(newBadge, forKey: "app_badge_count") UserDefaults.standard.set(newBadge, forKey: "app_badge_count") UNUserNotificationCenter.current().setBadgeCount(newBadge) let messageText = Self.firstNonBlank(userInfo, keys: [ "message_preview", "message", "text", "body" ]) ?? "New message" let content = UNMutableNotificationContent() content.title = senderName content.body = messageText content.sound = .default content.badge = NSNumber(value: newBadge) content.categoryIdentifier = "message" content.userInfo = ["sender_public_key": senderKey, "sender_name": senderName] // Communication Notification via INSendMessageIntent (CarPlay + Focus parity). let handle = INPersonHandle(value: senderKey, type: .unknown) let sender = INPerson( personHandle: handle, nameComponents: nil, displayName: senderName, image: nil, contactIdentifier: nil, customIdentifier: senderKey ) let intent = INSendMessageIntent( recipients: nil, outgoingMessageType: .outgoingMessageText, content: messageText, speakableGroupName: nil, conversationIdentifier: senderKey, serviceName: "Rosetta", sender: sender, attachments: nil ) let interaction = INInteraction(intent: intent, response: nil) interaction.direction = .incoming interaction.donate(completion: nil) let finalContent: UNNotificationContent if let updated = try? content.updating(from: intent) { finalContent = updated } else { finalContent = content } let request = UNNotificationRequest( identifier: "msg_\(senderKey)_\(Int(now))", content: finalContent, trigger: nil ) UNUserNotificationCenter.current().add(request) { _ in completionHandler(.newData) } } // MARK: - Read Push Handler /// Handles `type=read` push: clears delivered notifications for the specified dialog. /// Server sends this to the READER's other devices when they read a dialog on Desktop/Android. /// `dialog` field = opponent public key (personal) or group ID (may have `#group:` prefix). private func handleReadPush( userInfo: [AnyHashable: Any], completionHandler: @escaping (UIBackgroundFetchResult) -> Void ) { guard var dialogKey = userInfo["dialog"] as? String, !dialogKey.isEmpty else { completionHandler(.noData) return } // Strip #group: prefix — notification userInfo stores raw group ID. if dialogKey.hasPrefix("#group:") { dialogKey = String(dialogKey.dropFirst("#group:".count)) } // Desktop-active suppression: mark this dialog as "recently read on another device". // NSE checks this flag — if a new message arrives for the same dialog within 30s, // it suppresses the notification (user is actively reading on Desktop). // NOTE: When server sends mutable-content:1 for READ, NSE also writes this flag. // Both writes are idempotent (same dialogKey → same timestamp). Badge decrement // is safe: NSE removes notifications first, AppDelegate finds 0 remaining → no double-decrement. if let shared = UserDefaults(suiteName: "group.com.rosetta.dev") { let now = Date().timeIntervalSince1970 var recentlyRead = shared.dictionary(forKey: "nse_recently_read_dialogs") as? [String: Double] ?? [:] recentlyRead[dialogKey] = now // Evict stale entries (> 60s) to prevent unbounded growth. recentlyRead = recentlyRead.filter { now - $0.value < 60 } shared.set(recentlyRead, forKey: "nse_recently_read_dialogs") } let center = UNUserNotificationCenter.current() center.getDeliveredNotifications { delivered in let idsToRemove = delivered .filter { notification in let key = notification.request.content.userInfo["sender_public_key"] as? String ?? "" return key == dialogKey } .map { $0.request.identifier } if !idsToRemove.isEmpty { center.removeDeliveredNotifications(withIdentifiers: idsToRemove) } // Decrement badge by the number of cleared notifications. let clearedCount = idsToRemove.count if clearedCount > 0 { let shared = UserDefaults(suiteName: "group.com.rosetta.dev") let current = shared?.integer(forKey: "app_badge_count") ?? 0 let newBadge = max(current - clearedCount, 0) shared?.set(newBadge, forKey: "app_badge_count") UserDefaults.standard.set(newBadge, forKey: "app_badge_count") UNUserNotificationCenter.current().setBadgeCount(newBadge) } completionHandler(.newData) } } // MARK: - Call Push Handler /// Handles `type=call` FCM push: triggers CallKit + sets up call state + reconnects WebSocket. /// Android parity: `handleIncomingCallPush()` in RosettaFirebaseMessagingService.kt. /// Previously just showed a local notification — now matches VoIP push behavior. private func handleCallPush( userInfo: [AnyHashable: Any], completionHandler: @escaping (UIBackgroundFetchResult) -> Void ) { // Dedup: if VoIP push already reported this call to CallKit, skip FCM path. // Server sends BOTH VoIP APNs AND FCM simultaneously. Processing both creates // two WebSocket connections → every signal delivered twice → audio breaks. if CallKitManager.shared.hasPendingCall() { Logger.voip.info("FCM call push: VoIP push already handled — skipping") completionHandler(.noData) return } let callerKey = userInfo["dialog"] as? String ?? Self.extractSenderKey(from: userInfo) guard !callerKey.isEmpty else { Logger.voip.warning("FCM call push: empty callerKey — ignoring") completionHandler(.noData) return } let callId = userInfo["callId"] as? String let joinToken = userInfo["joinToken"] as? String let shared = UserDefaults(suiteName: "group.com.rosetta.dev") let contactNames = shared?.dictionary(forKey: "contact_display_names") as? [String: String] ?? [:] let callerName: String = { if let cached = contactNames[callerKey], !cached.isEmpty { return cached } if let fromPush = Self.firstNonBlank(userInfo, keys: ["sender_name", "from_title", "sender", "title", "name"]) { return fromPush } if let creds = SessionCredentialsManager.shared.load(), let dbName = Self.resolveCallerNameFromDB(callerKey: callerKey, accountKey: creds.publicKey), !dbName.isEmpty { return dbName } return "Rosetta" }() Logger.voip.info("FCM call push: key=\(callerKey.prefix(16), privacy: .public) name=\(callerName, privacy: .public) callId=\(callId ?? "nil", privacy: .public)") // 1. Report to CallKit — shows native incoming call UI (same as VoIP push path). CallKitManager.shared.reportIncomingCallSynchronously( callerKey: callerKey, callerName: callerName, callId: callId ) { error in if let error { Logger.voip.error("FCM call push: CallKit report failed: \(error.localizedDescription, privacy: .public)") } completionHandler(error == nil ? .newData : .failed) } // 2. Set up call state + reconnect WebSocket (same as VoIP push path). Task { @MainActor in // Guard: only process calls for the active account. let activeKey = AccountManager.shared.activeAccountPublicKey ?? "" if !activeKey.isEmpty, let creds = SessionCredentialsManager.shared.load(), creds.publicKey != activeKey { Logger.voip.warning("FCM call push: ignoring — inactive account") CallKitManager.shared.reportCallEndedByRemote(reason: .unanswered) return } if CallManager.shared.ownPublicKey.isEmpty, let creds = SessionCredentialsManager.shared.load() { CallManager.shared.bindAccount(publicKey: creds.publicKey) } if !callerKey.isEmpty, CallManager.shared.uiState.phase == .idle { CallManager.shared.setupIncomingCallFromPush( callerKey: callerKey, callerName: callerName, callId: callId, joinToken: joinToken ) } // Restore WebSocket so call signaling can proceed. if ProtocolManager.shared.connectionState == .authenticated { return } if ProtocolManager.shared.publicKey == nil, let creds = SessionCredentialsManager.shared.load() { Logger.voip.info("FCM call push: restoring session from Keychain") ProtocolManager.shared.connect( publicKey: creds.publicKey, privateKeyHash: creds.privateKeyHash ) } else { ProtocolManager.shared.forceReconnectOnForeground() } } } // MARK: - Caller Name from SQLite (VoIP push fallback) /// Reads caller display name directly from SQLite when app is killed and /// UserDefaults hasn't been loaded yet. Database file persists on disk. static func resolveCallerNameFromDB(callerKey: String, accountKey: String) -> String? { let key = accountKey.trimmingCharacters(in: .whitespacesAndNewlines) guard !key.isEmpty else { return nil } let normalized = String(key.unicodeScalars.map { CharacterSet.alphanumerics.contains($0) ? Character($0) : "_" }) let baseURL = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first ?? URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) let dbPath = baseURL .appendingPathComponent("Rosetta/Database/rosetta_\(normalized).sqlite") .path guard FileManager.default.fileExists(atPath: dbPath) else { return nil } var db: OpaquePointer? guard sqlite3_open_v2(dbPath, &db, SQLITE_OPEN_READONLY, nil) == SQLITE_OK else { return nil } defer { sqlite3_close(db) } var stmt: OpaquePointer? let sql = "SELECT opponent_title, opponent_username FROM dialogs WHERE opponent_key = ? LIMIT 1" guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return nil } defer { sqlite3_finalize(stmt) } sqlite3_bind_text(stmt, 1, (callerKey as NSString).utf8String, -1, nil) guard sqlite3_step(stmt) == SQLITE_ROW else { return nil } let title = sqlite3_column_text(stmt, 0).map { String(cString: $0) } ?? "" let username = sqlite3_column_text(stmt, 1).map { String(cString: $0) } ?? "" return title.isEmpty ? (username.isEmpty ? nil : username) : title } // MARK: - Push Payload Helpers (Android parity) /// Android parity: extract sender public key from multiple possible key names. /// Server may use different key names across versions. /// Note: server currently sends `from` field — checked first in didReceiveRemoteNotification, /// this helper is a fallback for other contexts (notification tap, etc.). static func extractSenderKey(from userInfo: [AnyHashable: Any]) -> String { firstNonBlank(userInfo, keys: [ "dialog", "sender_public_key", "from_public_key", "fromPublicKey", "public_key", "publicKey" ]) ?? "" } /// Android parity: `firstNonBlank(data, ...)` — try multiple key names, return first non-empty. private static func firstNonBlank(_ dict: [AnyHashable: Any], keys: [String]) -> String? { for key in keys { if let value = dict[key] as? String, !value.trimmingCharacters(in: .whitespaces).isEmpty { return value } } return nil } // MARK: - MessagingDelegate /// Called when FCM token is received or refreshed. func messaging(_ messaging: Messaging, didReceiveRegistrationToken fcmToken: String?) { guard let token = fcmToken else { return } Task { @MainActor in SessionManager.shared.setAPNsToken(token) } } // MARK: - UNUserNotificationCenterDelegate /// Handle foreground notifications — show system banner unless chat is active or muted. func userNotificationCenter( _ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void ) { let userInfo = notification.request.content.userInfo let senderKey = userInfo["dialog"] as? String ?? Self.extractSenderKey(from: userInfo) if InAppNotificationManager.shouldSuppress(senderKey: senderKey) { completionHandler([]) return } completionHandler([.banner, .sound]) } /// Determines whether a foreground notification should be suppressed. /// Testable: used by unit tests to verify suppression logic. static func foregroundPresentationOptions( for userInfo: [AnyHashable: Any] ) -> UNNotificationPresentationOptions { let senderKey = userInfo["dialog"] as? String ?? extractSenderKey(from: userInfo) if InAppNotificationManager.shouldSuppress(senderKey: senderKey) { return [] } return [.banner, .sound] } /// Handle notification tap — navigate to the sender's chat or expand call. /// Android parity: extracts sender key with multi-key fallback. func userNotificationCenter( _ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void ) { let userInfo = response.notification.request.content.userInfo // Call notification tap → expand call overlay (not chat). if let pushType = userInfo["type"] as? String, pushType == "call" { Task { @MainActor in if CallManager.shared.uiState.phase != .idle { CallManager.shared.expandCall() } } completionHandler() return } // Android parity: try multiple key names for sender identification. let senderKey = Self.extractSenderKey(from: userInfo) if !senderKey.isEmpty { let senderName = Self.firstNonBlank(userInfo, keys: [ "sender_name", "from_title", "sender", "title", "name" ]) ?? "" let route = ChatRoute( publicKey: senderKey, title: senderName, username: "", verified: 0 ) // Store pending route BEFORE posting — handles terminated app case // where ChatListView doesn't exist yet, and background app case // where .onReceive might miss the synchronous post. Self.pendingChatRoute = route Self.pendingChatRouteTimestamp = Date() NotificationCenter.default.post( name: .openChatFromNotification, object: route ) // Clear all delivered notifications from this sender center.getDeliveredNotifications { delivered in let idsToRemove = delivered .filter { notification in let info = notification.request.content.userInfo let key = Self.extractSenderKey(from: info) return key == senderKey } .map { $0.request.identifier } if !idsToRemove.isEmpty { center.removeDeliveredNotifications(withIdentifiers: idsToRemove) } } } completionHandler() } } // 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 Logger.voip.info("VoIP push received: \(data.description, privacy: .public)") // 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) if let title = data["title"] as? String, !title.isEmpty { return title } // 2. UserDefaults (synced by DialogRepository.syncContactNamesToDefaults) for defaults in [UserDefaults(suiteName: "group.com.rosetta.dev"), UserDefaults.standard] { if let names = defaults?.dictionary(forKey: "contact_display_names") as? [String: String], let name = names[callerKey], !name.isEmpty { return name } } // 3. SQLite direct read (data persists on disk even when app was killed) if let creds = SessionCredentialsManager.shared.load() { let name = Self.resolveCallerNameFromDB(callerKey: callerKey, accountKey: creds.publicKey) if let name, !name.isEmpty { return name } } return "Rosetta" }() Logger.voip.info("VoIP resolved: key=\(callerKey.prefix(16), privacy: .public) name=\(callerName, privacy: .public) callId=\(callId ?? "nil", privacy: .public) joinTokenPresent=\((joinToken?.isEmpty == false).description, privacy: .public)") // Telegram parity: pre-configure RTCAudioSession BEFORE reporting to CallKit. // This tells the system what audio configuration we need (.playAndRecord, // .voiceChat mode). Without this, CallKit may fail to deliver didActivate // for background VoIP push calls because the audio session is in an // unknown state (.soloAmbient). Telegram: OngoingCallThreadLocalContext.mm // setupAudioSession() called before reportNewIncomingCall. do { let avSession = AVAudioSession.sharedInstance() let options: AVAudioSession.CategoryOptions = [.allowBluetooth, .defaultToSpeaker, .mixWithOthers] try avSession.setCategory(.playAndRecord, mode: .voiceChat, options: options) } catch { Logger.voip.error("Failed to pre-configure audio session: \(error.localizedDescription)") } // 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, callId: callId ) { 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. Without this, the CallKit // call stays visible → user taps Accept → pendingCallKitAccept stuck // forever → app in broken state until force-quit. if callerKey.isEmpty || error != nil { Task { @MainActor in CallKitManager.shared.reportCallEndedByRemote(reason: .failed) // Clear stale accept flag — user may have tapped Accept // on the orphaned CallKit UI before it was dismissed. // Without this, the flag persists and auto-accepts the NEXT call. CallManager.shared.pendingCallKitAccept = false } return } // Restore WebSocket connection so the .call signal packet arrives // and CallManager can handle the call. When app was killed, SessionManager // has no credentials in memory — load from Keychain (saved during startSession). Task { @MainActor in // Guard: only process calls for the ACTIVE account. // When multiple accounts exist, VoIP token may still be registered // for a passive account → server sends push for wrong account. let activeKey = AccountManager.shared.activeAccountPublicKey ?? "" if !activeKey.isEmpty, let creds = SessionCredentialsManager.shared.load(), creds.publicKey != activeKey { Logger.voip.warning("VoIP push: ignoring — push woke inactive account \(creds.publicKey.prefix(8), privacy: .public), active is \(activeKey.prefix(8), privacy: .public)") CallKitManager.shared.reportCallEndedByRemote(reason: .unanswered) return } // Set up incoming call state from push payload IMMEDIATELY. // Don't wait for WebSocket .call signal — it's fire-and-forget // and may have been sent before our WebSocket connected. if !callerKey.isEmpty, CallManager.shared.uiState.phase == .idle { // Ensure account is bound for acceptIncomingCall() if CallManager.shared.ownPublicKey.isEmpty, let creds = SessionCredentialsManager.shared.load() { CallManager.shared.bindAccount(publicKey: creds.publicKey) } CallManager.shared.setupIncomingCallFromPush( callerKey: callerKey, callerName: callerName, callId: callId, joinToken: joinToken ) } // Restore WebSocket so keyExchange can be sent when user accepts. if ProtocolManager.shared.connectionState == .authenticated { return } if ProtocolManager.shared.publicKey == nil, let creds = SessionCredentialsManager.shared.load() { Logger.voip.info("Restoring session from Keychain for VoIP wake-up") ProtocolManager.shared.connect( publicKey: creds.publicKey, privateKeyHash: creds.privateKeyHash ) } else { ProtocolManager.shared.forceReconnectOnForeground() } } } } 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 { case onboarding case auth case unlock case main } // MARK: - RosettaApp @main struct RosettaApp: App { @UIApplicationDelegateAdaptor(AppDelegate.self) var delegate init() { UIWindow.appearance().backgroundColor = .systemBackground // Detect fresh install: UserDefaults are wiped on uninstall, Keychain is not. // If this is the first launch after install, clear any stale Keychain data. if !UserDefaults.standard.bool(forKey: "hasLaunchedBefore") { try? AccountManager.shared.deleteAccount() try? KeychainManager.shared.delete(forKey: Account.KeychainKey.allAccounts) UserDefaults.standard.removeObject(forKey: Account.KeychainKey.activeAccountKey) UserDefaults.standard.set(true, forKey: "hasLaunchedBefore") } // Avoid heavy startup work on MainActor; Lottie assets load lazily on first use. #if DEBUG DebugPerformanceBenchmarks.runIfRequested() #endif } @AppStorage("hasCompletedOnboarding") private var hasCompletedOnboarding = false @AppStorage("isLoggedIn") private var isLoggedIn = false @AppStorage("hasLaunchedBefore") private var hasLaunchedBefore = false @State private var appState: AppState? @State private var transitionOverlay: Bool = false var body: some Scene { WindowGroup { DarkModeWrapper { ZStack { RosettaColors.Adaptive.background .ignoresSafeArea() if let appState { rootView(for: appState) } // Fade-through-black overlay for smooth screen transitions. // Avoids UIKit-hosted Lottie views fighting SwiftUI opacity transitions. Color.black .ignoresSafeArea() .opacity(transitionOverlay ? 1 : 0) .allowsHitTesting(transitionOverlay) .animation(.easeInOut(duration: 0.035), value: transitionOverlay) } } // 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() } } .onOpenURL { url in handleDeepLink(url) } } } @ViewBuilder private func rootView(for state: AppState) -> some View { switch state { case .onboarding: OnboardingView { hasCompletedOnboarding = true fadeTransition(to: .auth) } case .auth: AuthCoordinator( onAuthComplete: { isLoggedIn = true fadeTransition(to: .main) }, onBackToUnlock: AccountManager.shared.hasAccount ? { fadeTransition(to: .unlock) } : nil ) case .unlock: UnlockView( onUnlocked: { isLoggedIn = true fadeTransition(to: .main) }, onCreateNewAccount: { // Go to auth flow (Welcome screen with back button) // Does NOT delete the old account — Android keeps multiple accounts fadeTransition(to: .auth) } ) case .main: MainTabView( onLogout: { isLoggedIn = false // Desktop parity: if other accounts remain after deletion, go to unlock. // Only go to onboarding if no accounts left. if AccountManager.shared.hasAccount { fadeTransition(to: .unlock) } else { hasCompletedOnboarding = false fadeTransition(to: .onboarding) } }, ) // Force full view recreation on account switch. Without this, // SwiftUI may reuse the old MainTabView's @StateObject instances // (SettingsViewModel, ChatListViewModel) when appState cycles // .main → .unlock → .main, causing stale profile data to persist. .id(AccountManager.shared.currentAccount?.publicKey ?? "") } } /// Fade-through-black transition: overlay fades in → swap content → overlay fades out. /// Avoids UIKit-hosted views (Lottie, UIPageViewController) fighting SwiftUI transitions. private func handleDeepLink(_ url: URL) { guard url.scheme == "rosetta" else { return } if url.host == "call" && url.path == "/end" { print("[CallBar] Deep link rosetta://call/end → endCall()") CallManager.shared.endCall() } } private func fadeTransition(to newState: AppState) { guard !transitionOverlay else { return } transitionOverlay = true Task { @MainActor in try? await Task.sleep(nanoseconds: 35_000_000) // wait for overlay fade-in appState = newState try? await Task.sleep(nanoseconds: 10_000_000) // brief settle transitionOverlay = false } } private func initialState() -> AppState { if AccountManager.shared.hasAccount { return .unlock } else { hasCompletedOnboarding = false return .onboarding } } } // MARK: - Notification Names extension Notification.Name { /// Posted when user taps a push notification — carries a `ChatRoute` as `object`. static let openChatFromNotification = Notification.Name("openChatFromNotification") /// Posted when own profile (displayName/username) is updated from the server. static let profileDidUpdate = Notification.Name("profileDidUpdate") /// Posted when user taps an attachment in the bubble overlay — carries attachment ID (String) as `object`. /// MessageImageView / MessageFileView listen and trigger download/share. 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") }