migration
This commit is contained in:
220
PediFoods/Services/PushNotificationCoordinator.swift
Normal file
220
PediFoods/Services/PushNotificationCoordinator.swift
Normal file
@@ -0,0 +1,220 @@
|
||||
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])
|
||||
}
|
||||
}
|
||||
|
||||
/// `UNNotification.userInfo` is `[AnyHashable: Any]`, which the compiler
|
||||
/// can't prove `Sendable` — but it's an immutable payload handed to us
|
||||
/// once by the OS, so crossing the actor boundary with it is safe in
|
||||
/// practice.
|
||||
private struct UncheckedSendableBox<Value>: @unchecked Sendable {
|
||||
let value: Value
|
||||
}
|
||||
|
||||
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 = UncheckedSendableBox(value: response.notification.request.content.userInfo)
|
||||
Task { @MainActor in
|
||||
PushNotificationCoordinator.shared.handleTap(userInfo: userInfo.value)
|
||||
}
|
||||
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
|
||||
Reference in New Issue
Block a user