Files
mobile-ios/Rosetta/RosettaApp.swift

679 lines
27 KiB
Swift

import FirebaseCore
import FirebaseCrashlytics
import FirebaseMessaging
import Intents
import PushKit
import SwiftUI
import UserNotifications
// 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 ?? ""
// 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))
}
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` push: shows incoming call notification when app is backgrounded.
/// Server sends this as a wake-up when the recipient's WebSocket is not connected.
/// No badge increment (calls don't affect unread count).
/// No dedup (calls are urgent always show notification).
/// No mute check (Android parity: calls bypass mute).
private func handleCallPush(
userInfo: [AnyHashable: Any],
completionHandler: @escaping (UIBackgroundFetchResult) -> Void
) {
let callerKey = userInfo["dialog"] as? String
?? Self.extractSenderKey(from: userInfo)
guard !callerKey.isEmpty else {
completionHandler(.noData)
return
}
let shared = UserDefaults(suiteName: "group.com.rosetta.dev")
let contactNames = shared?.dictionary(forKey: "contact_display_names") as? [String: String] ?? [:]
let callerName = contactNames[callerKey]
?? Self.firstNonBlank(userInfo, keys: ["sender_name", "from_title", "sender", "title", "name"])
?? "Rosetta"
let content = UNMutableNotificationContent()
content.title = callerName
content.body = "Incoming call"
content.sound = .default
content.categoryIdentifier = "call"
content.userInfo = ["sender_public_key": callerKey, "sender_name": callerName, "type": "call"]
content.interruptionLevel = .timeSensitive
let request = UNNotificationRequest(
identifier: "call_\(callerKey)_\(Int(Date().timeIntervalSince1970))",
content: content,
trigger: nil
)
UNUserNotificationCenter.current().add(request) { _ in
completionHandler(.newData)
}
}
// 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.).
private 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 suppress ALL when app is in foreground.
/// Android parity: messages arrive via WebSocket in real-time, push is background-only.
func userNotificationCenter(
_ center: UNUserNotificationCenter,
willPresent notification: UNNotification,
withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) ->
Void
) {
completionHandler([])
}
/// Handle notification tap navigate to the sender's chat.
/// 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
// 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
let callerKey = data["dialog"] as? String ?? ""
let callerName = data["title"] as? String ?? "Rosetta"
// Apple REQUIREMENT: reportNewIncomingCall MUST be called SYNCHRONOUSLY.
// Using Task { @MainActor } would introduce an async hop that may be
// delayed if the main actor is busy, causing Apple to terminate the app.
CallKitManager.shared.reportIncomingCallSynchronously(
callerKey: callerKey.isEmpty ? "unknown" : callerKey,
callerName: callerName
) { error in
completion()
// If callerKey is empty/invalid, immediately end the orphaned call.
// Apple still required us to call reportNewIncomingCall, but we can't
// connect a call without a valid peer key.
if callerKey.isEmpty || error != nil {
return
}
// Trigger WebSocket reconnection so the actual .call signal packet
// arrives and CallManager can handle the call. Without this, the app
// wakes from killed state but CallManager stays idle Accept does nothing.
Task { @MainActor in
if ProtocolManager.shared.connectionState != .authenticated {
ProtocolManager.shared.forceReconnectOnForeground()
}
}
}
}
func pushRegistry(
_ registry: PKPushRegistry,
didInvalidatePushTokenFor type: PKPushType
) {
guard type == .voIP else { return }
// Notify server to unsubscribe the stale VoIP token before clearing it.
let oldToken = UserDefaults.standard.string(forKey: "voip_push_token")
if let oldToken, !oldToken.isEmpty {
Task { @MainActor in
SessionManager.shared.unsubscribeVoIPToken(oldToken)
}
}
UserDefaults.standard.removeObject(forKey: "voip_push_token")
}
}
// MARK: - App State
private enum AppState {
case onboarding
case auth
case unlock
case main
}
// MARK: - RosettaApp
@main
struct RosettaApp: App {
@UIApplicationDelegateAdaptor(AppDelegate.self) var delegate
init() {
UIWindow.appearance().backgroundColor = .black
// 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 {
ZStack {
RosettaColors.Dark.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)
}
.preferredColorScheme(.dark)
.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")
}