[push-deeplink-routing-contract] Generalize push tap routing to DeepLinkDestination

Replace order_status-only NotificationCenter path with a single
DeepLinkDestination enum + PushDeepLinkParser, decoded once in
PushNotificationCoordinator and dispatched via ContentView.route(to:).
Also fixes NotificationService reading userInfo["image"] instead of
the guide's stale "imageUrl" key.
This commit is contained in:
Daniel Arantes Loverde
2026-08-05 15:19:18 -03:00
parent 7dd4cda1a4
commit c4d6998595
4 changed files with 109 additions and 47 deletions

View File

@@ -2,7 +2,9 @@ import UserNotifications
/// Rich (image) push see docs/api/push-notifications-integration-guide.md §4.3. /// Rich (image) push see docs/api/push-notifications-integration-guide.md §4.3.
/// Only fires when Atomenta's push payload sets `"mutable-content": 1`, which /// Only fires when Atomenta's push payload sets `"mutable-content": 1`, which
/// it does by default on any push carrying an `imageUrl`. /// it does by default on any push carrying an image (real wire key is
/// `"image"`, not the guide's `"imageUrl"` verified against
/// `PushNotificationService.dispatchApns`/`dispatchApnsMany` in Atomenta).
/// `@unchecked Sendable`: the extension process only ever runs one /// `@unchecked Sendable`: the extension process only ever runs one
/// `didReceive`/`serviceExtensionTimeWillExpire` cycle at a time there's no /// `didReceive`/`serviceExtensionTimeWillExpire` cycle at a time there's no
/// real concurrent access to `contentHandler`/`bestAttemptContent` to guard /// real concurrent access to `contentHandler`/`bestAttemptContent` to guard
@@ -17,7 +19,7 @@ final class NotificationService: UNNotificationServiceExtension, @unchecked Send
let mutableContent = (request.content.mutableCopy() as? UNMutableNotificationContent) ?? UNMutableNotificationContent() let mutableContent = (request.content.mutableCopy() as? UNMutableNotificationContent) ?? UNMutableNotificationContent()
bestAttemptContent = mutableContent bestAttemptContent = mutableContent
guard let imageURLString = request.content.userInfo["imageUrl"] as? String, guard let imageURLString = request.content.userInfo["image"] as? String,
let imageURL = URL(string: imageURLString) else { let imageURL = URL(string: imageURLString) else {
deliver() deliver()
return return

View File

@@ -15,7 +15,7 @@ struct ContentView: View {
@State private var sessionExpiredObserver: NSObjectProtocol? @State private var sessionExpiredObserver: NSObjectProtocol?
@State var cartResetObserver: Any? @State var cartResetObserver: Any?
@State var appResumeObserver: Any? @State var appResumeObserver: Any?
@State var pushOrderTapObserver: Any? @State var pushDeepLinkObserver: Any?
@StateObject var snackbarCenter = SnackbarCenter.shared @StateObject var snackbarCenter = SnackbarCenter.shared
var body: some View { var body: some View {
@@ -91,13 +91,13 @@ struct ContentView: View {
attachCartResetObserverIfNeeded() attachCartResetObserverIfNeeded()
attachAppResumeObserverIfNeeded() attachAppResumeObserverIfNeeded()
attachSessionExpiredObserverIfNeeded() attachSessionExpiredObserverIfNeeded()
attachPushOrderTapObserverIfNeeded() attachPushDeepLinkObserverIfNeeded()
} }
.onDisappear { .onDisappear {
detachCartResetObserver() detachCartResetObserver()
detachAppResumeObserver() detachAppResumeObserver()
detachSessionExpiredObserver() detachSessionExpiredObserver()
detachPushOrderTapObserver() detachPushDeepLinkObserver()
} }
} }
@@ -244,36 +244,50 @@ struct ContentView: View {
self.appResumeObserver = nil self.appResumeObserver = nil
} }
/// §6 of the push notifications guide an `order_status` push tap /// §6 of the push notifications guide, generalized: any tapped push that
/// (reported by `PushNotificationCoordinator`) routes here to the /// resolves to a `DeepLinkDestination` (reported by
/// Profile tab's Orders list, pre-targeted at that order. /// `PushNotificationCoordinator`) lands here. Adding a future promo/coupon
private func attachPushOrderTapObserverIfNeeded() { /// screen means adding one case to `route(to:)` below this observer,
guard pushOrderTapObserver == nil else { return } /// the notification name, and the AppState plumbing for it stay put.
pushOrderTapObserver = NotificationCenter.default.addObserver( private func attachPushDeepLinkObserverIfNeeded() {
forName: .pushTappedOrderStatus, guard pushDeepLinkObserver == nil else { return }
pushDeepLinkObserver = NotificationCenter.default.addObserver(
forName: .pushDeepLinkReceived,
object: nil, object: nil,
queue: nil queue: nil
) { notification in ) { notification in
let orderId = notification.userInfo?["orderId"] as? String guard let destination = notification.userInfo?["destination"] as? DeepLinkDestination else { return }
let shortId = notification.userInfo?["shortId"] as? String
Task { @MainActor in Task { @MainActor in
guard root == .main, let orderId else { return } route(to: destination)
appState.pendingOrderDeepLink = OrderRouteContext(
orderId: orderId,
shortId: shortId,
paymentMethod: nil,
total: nil,
intent: .auto
)
selectedTab = .profile
} }
} }
} }
private func detachPushOrderTapObserver() { private func detachPushDeepLinkObserver() {
guard let pushOrderTapObserver else { return } guard let pushDeepLinkObserver else { return }
NotificationCenter.default.removeObserver(pushOrderTapObserver) NotificationCenter.default.removeObserver(pushDeepLinkObserver)
self.pushOrderTapObserver = nil self.pushDeepLinkObserver = nil
}
@MainActor
private func route(to destination: DeepLinkDestination) {
guard root == .main else { return }
switch destination {
case .orderTracking(let orderId, let shortId):
appState.pendingOrderDeepLink = OrderRouteContext(
orderId: orderId,
shortId: shortId,
paymentMethod: nil,
total: nil,
intent: .auto
)
selectedTab = .profile
case .screen(let name, let params):
// Promo/coupon screens (participating stores/products, §6's
// `targetScreen` convention) land here once they exist see
// decisions/2026-08-04-push-deeplink-routing-contract.md.
logger.debug("Unhandled deep-link screen: \(name, privacy: .public) params=\(params, privacy: .public)")
}
} }
@MainActor @MainActor

View File

@@ -0,0 +1,49 @@
import Foundation
/// Single decode target for anything that can deep-link into the app push
/// notification taps today (docs/api/push-notifications-integration-guide.md
/// §6), in-app promo/discount banner taps tomorrow (same shape: a string-keyed
/// dictionary naming a screen + its params). Adding a new destination means
/// adding one case here + one branch in `ContentView.route(to:)` never a new
/// `NotificationCenter` name, `AppState` field, or observer pair.
enum DeepLinkDestination: Equatable {
/// `type: "order_status"` the only push type Atomenta fixes today
/// (§6), so it gets its own case instead of routing through `targetScreen`.
case orderTracking(orderId: String, shortId: String?)
/// Everything else, keyed by `targetScreen` our own convention (§6
/// leaves this to us). Once a real screen exists for a given name (e.g.
/// future promo/coupon "list of participating stores/products"), give it
/// its own case; until then this carries the raw params so nothing is
/// silently dropped.
case screen(name: String, params: [String: String])
}
/// Resolves the *navigation* half of a tapped push's payload. Pure and
/// side-effect-free on purpose: easy to unit test, and reusable as-is for
/// in-app banner taps once those exist. Campaign-open reporting (§6a) is a
/// separate side effect, not a navigation target, and is not decided here
/// see `PushNotificationCoordinator.handleTap`.
struct PushDeepLinkParser {
static func parse(_ userInfo: [AnyHashable: Any]) -> DeepLinkDestination? {
if let type = userInfo["type"] as? String, type == "order_status" {
// Atomenta's real order-status push (PushNotificationService.
// notifyOrderStatusUpdate) sends only `shortId`, never `orderId`
// `GET /api/public/orders/:orderId` accepts either as the lookup
// key server-side, so `shortId` doubles as the id here too.
let shortId = userInfo["shortId"] as? String
guard let orderId = (userInfo["orderId"] as? String) ?? shortId else { return nil }
return .orderTracking(orderId: orderId, shortId: shortId)
}
guard let targetScreen = userInfo["targetScreen"] as? String else { return nil }
var params: [String: String] = [:]
for (key, value) in userInfo {
guard let key = key as? String, key != "type", key != "targetScreen" else { continue }
if let stringValue = value as? String {
params[key] = stringValue
}
}
return .screen(name: targetScreen, params: params)
}
}

View File

@@ -11,10 +11,12 @@ enum PushAuthorizationState {
case notDetermined case notDetermined
} }
/// Posted when the user taps an `order_status` push (§6) so `ContentView` /// Posted when a tapped push resolves to a navigable `DeepLinkDestination`
/// can route to that order without this service depending on `AppState`. /// (§6) so `ContentView` can route without this service depending on
/// `AppState`. One name for every destination, present and future see
/// `DeepLinkDestination`.
extension Notification.Name { extension Notification.Name {
static let pushTappedOrderStatus = Notification.Name("pushTappedOrderStatus") static let pushDeepLinkReceived = Notification.Name("pushDeepLinkReceived")
} }
#if os(iOS) #if os(iOS)
@@ -153,26 +155,21 @@ final class PushNotificationCoordinator: NSObject {
} }
} }
/// §6 routes on the tapped push's `data` payload. `order_status` gets /// §6 routes on the tapped push's `data` payload. Campaign-open
/// forwarded to `ContentView` via `NotificationCenter` (this service has /// reporting (§6a, a side effect, not a navigation target) is decided
/// no `AppState` binding of its own); `campaign` self-reports its open. /// 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]) { fileprivate func handleTap(userInfo: [AnyHashable: Any]) {
guard let type = userInfo["type"] as? String else { return } if let type = userInfo["type"] as? String, type == "campaign",
switch type { let campaignId = userInfo["campaignId"] as? String {
case "campaign":
guard let campaignId = userInfo["campaignId"] as? String else { return }
Task { await reportCampaignOpened(campaignId: campaignId) } Task { await reportCampaignOpened(campaignId: campaignId) }
case "order_status":
guard let orderId = userInfo["orderId"] as? String else { return }
let shortId = userInfo["shortId"] as? String
NotificationCenter.default.post(
name: .pushTappedOrderStatus,
object: nil,
userInfo: ["orderId": orderId, "shortId": shortId as Any]
)
default:
break
} }
guard let destination = PushDeepLinkParser.parse(userInfo) else { return }
NotificationCenter.default.post(name: .pushDeepLinkReceived, object: nil, userInfo: ["destination": destination])
} }
} }