Files
mobile-ios/Rosetta/RosettaApp.swift

928 lines
40 KiB
Swift

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": "<pubkey>", "callId": "<uuid>", "joinToken": "<token>" }
// 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 darklight 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")
}