import Foundation /// Client-side half of docs/api/push-notifications-integration-guide.md. /// Owns OS permission state, APNs device-token registration, the /// "enable notifications" action shared by the profile toggle and the /// order-tracking fallback prompt (§2b), foreground/tap notification /// handling (§6), and campaign open tracking (§6a). enum PushAuthorizationState { case authorized case denied case notDetermined } /// Posted when a tapped push resolves to a navigable `DeepLinkDestination` /// (§6) so `ContentView` can route without this service depending on /// `AppState`. One name for every destination, present and future — see /// `DeepLinkDestination`. extension Notification.Name { static let pushDeepLinkReceived = Notification.Name("pushDeepLinkReceived") } #if os(iOS) import UIKit import UserNotifications @MainActor final class PushNotificationCoordinator: NSObject { static let shared = PushNotificationCoordinator() private var deviceTokenObserver: NSObjectProtocol? private var didBecomeDelegate = false private override init() {} /// Call once at app launch. Listens for the device token `PediFoodsAppDelegate` /// posts after `registerForRemoteNotifications()` resolves, and forwards it /// to Atomenta (§2). func startObservingDeviceToken() { guard deviceTokenObserver == nil else { return } deviceTokenObserver = NotificationCenter.default.addObserver( forName: NSNotification.Name("didRegisterForRemoteNotificationsWithDeviceToken"), object: nil, queue: .main ) { notification in guard let data = notification.userInfo?["deviceToken"] as? Data else { return } let hexToken = data.map { String(format: "%02x", $0) }.joined() Task { await PushNotificationCoordinator.shared.sendTokenToBackend(hexToken) } } } /// Call once at app launch, before the first notification could possibly /// arrive — makes this the `UNUserNotificationCenterDelegate` so foreground /// pushes actually display (§6) and taps get routed/tracked (§6, §6a). func becomeNotificationCenterDelegate() { guard didBecomeDelegate == false else { return } didBecomeDelegate = true UNUserNotificationCenter.current().delegate = self } /// Re-registers silently (no OS prompt) if the user already granted /// authorization in a previous session — tokens aren't guaranteed stable /// across launches (§2, §3.3/§4.2 of the guide). Safe to call before login. func refreshRegistrationIfAuthorized() async { guard await currentAuthorizationState() == .authorized else { return } UIApplication.shared.registerForRemoteNotifications() } func currentAuthorizationState() async -> PushAuthorizationState { let settings = await UNUserNotificationCenter.current().notificationSettings() switch settings.authorizationStatus { case .authorized, .provisional, .ephemeral: return .authorized case .denied: return .denied case .notDetermined: return .notDetermined @unknown default: return .notDetermined } } /// Shows the OS permission dialog only if it hasn't been answered yet. /// Always calls `registerForRemoteNotifications()` when authorized — /// including when authorization was already granted in a past session — /// so "enable" reliably produces a fresh device token this run instead of /// relying solely on the once-per-launch refresh. @discardableResult private func requestAuthorizationIfNeeded() async -> Bool { switch await currentAuthorizationState() { case .authorized: UIApplication.shared.registerForRemoteNotifications() return true case .denied: return false case .notDetermined: let granted = (try? await UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .badge, .sound])) ?? false if granted { UIApplication.shared.registerForRemoteNotifications() } return granted } } /// Shared body of §2b's two touchpoints: request OS permission (if /// undetermined), then flip Atomenta's `notificationsEnabled` flag. /// Returns the server's authoritative post-update profile — callers must /// reflect `result.notificationsEnabled` from this, not assume `true` /// just because the request succeeded. func enableNotifications() async -> CustomerProfile? { guard await requestAuthorizationIfNeeded() else { return nil } do { let response = try await ApiService().updateNotificationsEnabled(true) return response.error == false ? response.result : nil } catch { logger.error("Failed to enable push notifications: \(error.localizedDescription)") return nil } } /// See docs/api/push-notifications-integration-guide.md §2a. Best-effort, /// silent on failure — Campaign `appVersion`/`attributes` targeting just /// won't match this user until the next successful call. Call right after /// login and once per app launch (covers an app update since last launch). func syncCustomerAttributes() async { guard DefaultTokenStore().jwt != nil else { return } do { _ = try await ApiService().updateCustomerAttributes(appVersion: currentAppVersion(), attributes: nil) } catch { logger.error("Failed to sync customer attributes: \(error.localizedDescription)") } } private func currentAppVersion() -> String { (Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String)? .trimmingCharacters(in: .whitespacesAndNewlines) ?? "0.0.0" } private func sendTokenToBackend(_ hexToken: String) async { guard DefaultTokenStore().jwt != nil else { return } do { _ = try await ApiService().registerPushToken(hexToken, deviceId: GuestLocationStore.shared.deviceId, deviceOS: "ios") } catch { logger.error("Push token registration failed: \(error.localizedDescription)") } } /// §6a — fire on tap only, for `type: "campaign"` pushes. Idempotent /// server-side, so no client-side "already reported" guard needed. private func reportCampaignOpened(campaignId: String) async { guard DefaultTokenStore().jwt != nil else { return } do { _ = try await ApiService().reportPushCampaignOpened(campaignId: campaignId) } catch { logger.error("Failed to report campaign open: \(error.localizedDescription)") } } /// §6 — routes on the tapped push's `data` payload. Campaign-open /// reporting (§6a, a side effect, not a navigation target) is decided /// directly on `type` here; navigation is delegated to /// `PushDeepLinkParser` and forwarded to `ContentView` via /// `NotificationCenter` (this service has no `AppState` binding of its /// own). A push can do both — e.g. a future campaign that also sets /// `targetScreen` reports its open *and* navigates. fileprivate func handleTap(userInfo: [AnyHashable: Any]) { if let type = userInfo["type"] as? String, type == "campaign", let campaignId = userInfo["campaignId"] as? String { Task { await reportCampaignOpened(campaignId: campaignId) } } guard let destination = PushDeepLinkParser.parse(userInfo) else { return } NotificationCenter.default.post(name: .pushDeepLinkReceived, object: nil, userInfo: ["destination": destination]) } } extension PushNotificationCoordinator: UNUserNotificationCenterDelegate { /// Without a delegate, iOS silently drops push notifications while the /// app is foregrounded — this is what makes them display as a banner too. nonisolated func userNotificationCenter( _ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void ) { completionHandler([.banner, .list, .sound, .badge]) } nonisolated func userNotificationCenter( _ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void ) { let userInfo = response.notification.request.content.userInfo Task { @MainActor in PushNotificationCoordinator.shared.handleTap(userInfo: userInfo) } completionHandler() } } #else @MainActor final class PushNotificationCoordinator { static let shared = PushNotificationCoordinator() private init() {} func startObservingDeviceToken() {} func becomeNotificationCenterDelegate() {} func refreshRegistrationIfAuthorized() async {} func currentAuthorizationState() async -> PushAuthorizationState { .denied } func enableNotifications() async -> CustomerProfile? { nil } func syncCustomerAttributes() async {} } #endif