435 lines
17 KiB
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")
|
|
}
|