679 lines
27 KiB
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")
|
|
}
|