928 lines
40 KiB
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 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")
|
|
}
|