[push/opt-in-prompts] Implement push notifications client integration

Build the full client half of docs/api/push-notifications-integration-guide.md:
OS permission + APNs device-token registration and pipeline wiring, profile
notifications/biometric-login toggles on the Ver Perfil screen reflecting
server truth, order-tracking opt-in fallback prompt, profile-cache refresh
on every mutation, a Notification Service Extension for rich/image push,
the Push Notifications capability, targeting-attributes sync, campaign open
tracking, and tap-to-order deep linking with foreground notification display.
This commit is contained in:
Daniel Arantes Loverde
2026-08-04 11:33:56 -03:00
parent 32f12d6c0e
commit 7dd4cda1a4
20 changed files with 943 additions and 12 deletions

View File

@@ -0,0 +1,58 @@
import UserNotifications
/// 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
/// it does by default on any push carrying an `imageUrl`.
/// `@unchecked Sendable`: the extension process only ever runs one
/// `didReceive`/`serviceExtensionTimeWillExpire` cycle at a time there's no
/// real concurrent access to `contentHandler`/`bestAttemptContent` to guard
/// against, just a background download completion handing back to this
/// instance.
final class NotificationService: UNNotificationServiceExtension, @unchecked Sendable {
private var contentHandler: ((UNNotificationContent) -> Void)?
private var bestAttemptContent: UNMutableNotificationContent?
override func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) {
self.contentHandler = contentHandler
let mutableContent = (request.content.mutableCopy() as? UNMutableNotificationContent) ?? UNMutableNotificationContent()
bestAttemptContent = mutableContent
guard let imageURLString = request.content.userInfo["imageUrl"] as? String,
let imageURL = URL(string: imageURLString) else {
deliver()
return
}
URLSession.shared.downloadTask(with: imageURL) { [weak self] location, _, _ in
if let location, let attachment = Self.attachment(fromDownloadedFile: location) {
self?.bestAttemptContent?.attachments = [attachment]
}
self?.deliver()
}.resume()
}
/// The system calls this if `didReceive` doesn't finish within its time
/// budget must still deliver the best content built so far, since
/// `contentHandler` is contractually required to run exactly once.
override func serviceExtensionTimeWillExpire() {
deliver()
}
private func deliver() {
guard let bestAttemptContent else { return }
contentHandler?(bestAttemptContent)
contentHandler = nil
}
private static func attachment(fromDownloadedFile location: URL) -> UNNotificationAttachment? {
let tmpURL = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString)
.appendingPathExtension("jpg")
do {
try FileManager.default.moveItem(at: location, to: tmpURL)
return try UNNotificationAttachment(identifier: "image", url: tmpURL)
} catch {
return nil
}
}
}