Files
mobile-ios/Rosetta/RosettaApp.swift

435 lines
17 KiB
Swift

import FirebaseCore
import FirebaseCrashlytics
import FirebaseMessaging
import Intents
import SwiftUI
import UserNotifications
// MARK: - Firebase AppDelegate
final class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDelegate,
MessagingDelegate
{
/// Pending chat route from notification tap consumed by ChatListView on appear.
/// Handles both terminated app (notification posted before ChatListView exists)
/// and background app (fallback if .onReceive misses the synchronous post).
static var pendingChatRoute: ChatRoute?
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()
}
}
}
return true
}
// Forward APNs token to Firebase Messaging + SessionManager
func application(
_ application: UIApplication,
didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data
) {
Messaging.messaging().apnsToken = deviceToken
}
// MARK: - Background Push (Badge + Local Notification with Sound)
/// Called when a push notification arrives with `content-available: 1`.
/// Two scenarios:
/// 1. Server sends data-only push (no alert) we create a local notification with sound.
/// 2. Server sends visible push + content-available NSE handles sound/badge,
/// we only sync the badge count here.
/// Android parity: 10-second dedup window per sender.
/// Prevents duplicate push notifications from rapid server retries.
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
) {
// Foreground: WebSocket handles messages in real-time skip.
guard application.applicationState != .active else {
completionHandler(.noData)
return
}
let shared = UserDefaults(suiteName: "group.com.rosetta.dev")
// Background/inactive: increment badge from shared App Group storage.
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)
// Android parity: extract sender key with multi-key fallback.
// Server may send under different key names depending on version.
let senderKey = Self.extractSenderKey(from: userInfo)
let senderName = Self.firstNonBlank(userInfo, keys: [
"sender_name", "from_title", "sender", "title", "name"
]) ?? "New message"
let messageText = Self.firstNonBlank(userInfo, keys: [
"message_preview", "message", "text", "body"
]) ?? "New message"
// 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 isMuted: Bool = {
let mutedSet = UserDefaults.standard.stringArray(forKey: "muted_chats_keys") ?? []
return mutedSet.contains(senderKey)
}()
// If server sent visible alert, NSE handles sound+badge. Just sync badge.
guard !hasVisibleAlert && !isMuted else {
completionHandler(.newData)
return
}
let content = UNMutableNotificationContent()
content.title = senderName
content.body = messageText
content.sound = .default
content.badge = NSNumber(value: newBadge)
content.categoryIdentifier = "message"
// Always set sender_public_key in userInfo for notification tap navigation.
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.isEmpty ? "Rosetta" : 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: - Push Payload Helpers (Android parity)
/// Android parity: extract sender public key from multiple possible key names.
/// Server may use different key names across versions.
private static func extractSenderKey(from userInfo: [AnyHashable: Any]) -> String {
firstNonBlank(userInfo, keys: [
"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
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: - 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.
DebugPerformanceBenchmarks.runIfRequested()
}
@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()
}
}
}
}
@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 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")
}