[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

@@ -2,6 +2,8 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>aps-environment</key>
<string>development</string>
<key>com.apple.developer.devicecheck.appattest-environment</key>
<string>development</string>
</dict>

View File

@@ -0,0 +1,31 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleDisplayName</key>
<string>NotificationService</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>XPC!</string>
<key>CFBundleShortVersionString</key>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>NSExtension</key>
<dict>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.usernotifications.service</string>
<key>NSExtensionPrincipalClass</key>
<string>$(PRODUCT_MODULE_NAME).NotificationService</string>
</dict>
</dict>
</plist>

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
}
}
}

View File

@@ -7,15 +7,28 @@
objects = {
/* Begin PBXBuildFile section */
002E82AE37F71856D2AD0524 /* NotificationServiceExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 588B094973FB7448596F3D09 /* NotificationServiceExtension.appex */; settings = {ATTRIBUTES = (RemoveHeaderOnCopy, ); }; };
1457598D2491B8729EEBB739 /* NotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E34FD2046E40CE1E302DB11 /* NotificationService.swift */; };
491F27822DA55B72004926EE /* PediFoods in Frameworks */ = {isa = PBXBuildFile; productRef = 491F27812DA55B72004926EE /* PediFoods */; };
491F27832DA55B72004926EE /* PediFoods in Embed Frameworks */ = {isa = PBXBuildFile; productRef = 491F27812DA55B72004926EE /* PediFoods */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; };
496BDBEE2B8A7E9C00C09264 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 496BDBED2B8A7E9C00C09264 /* Localizable.xcstrings */; };
499CD43B2AC5B799001AE8D8 /* Main.swift in Sources */ = {isa = PBXBuildFile; fileRef = 49F90C2B2A52156200F06D93 /* Main.swift */; };
499CD4402AC5B799001AE8D8 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 49F90C2F2A52156300F06D93 /* Assets.xcassets */; };
837DB5EB5FA4527A71F02C70 /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 78EA2CD32C41A0B833E06B89 /* Foundation.framework */; };
B13CABF22F36437300469FD6 /* LCEssentials in Frameworks */ = {isa = PBXBuildFile; productRef = B13CABF12F36437300469FD6 /* LCEssentials */; };
B185A1FD2FB67A12001C7771 /* Sources/LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = B185A1FC2FB67A12001C7771 /* Sources/LaunchScreen.storyboard */; };
B185A1FD2FB67A12001C7771 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = B185A1FC2FB67A12001C7771 /* LaunchScreen.storyboard */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
E60A9FE80EBD27A663AF17E0 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = 49F90C202A52156200F06D93 /* Project object */;
proxyType = 1;
remoteGlobalIDString = 4FB6BE053007051C3D26B06E;
remoteInfo = NotificationServiceExtension;
};
/* End PBXContainerItemProxy section */
/* Begin PBXCopyFilesBuildPhase section */
499CD44A2AC5B9C6001AE8D8 /* Embed Frameworks */ = {
isa = PBXCopyFilesBuildPhase;
@@ -28,6 +41,17 @@
name = "Embed Frameworks";
runOnlyForDeploymentPostprocessing = 0;
};
C28D30C677D0B233FE310699 /* Embed Foundation Extensions */ = {
isa = PBXCopyFilesBuildPhase;
buildActionMask = 2147483647;
dstPath = "";
dstSubfolderSpec = 13;
files = (
002E82AE37F71856D2AD0524 /* NotificationServiceExtension.appex in Embed Foundation Extensions */,
);
name = "Embed Foundation Extensions";
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */
@@ -41,7 +65,11 @@
49F90C2B2A52156200F06D93 /* Main.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = Main.swift; path = Sources/Main.swift; sourceTree = SOURCE_ROOT; };
49F90C2F2A52156300F06D93 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
49F90C312A52156300F06D93 /* Entitlements.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Entitlements.plist; sourceTree = "<group>"; };
B185A1FC2FB67A12001C7771 /* Sources/LaunchScreen.storyboard */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; path = Sources/LaunchScreen.storyboard; sourceTree = SOURCE_ROOT; };
4E34FD2046E40CE1E302DB11 /* NotificationService.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NotificationService.swift; sourceTree = "<group>"; };
588B094973FB7448596F3D09 /* NotificationServiceExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = NotificationServiceExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
78EA2CD32C41A0B833E06B89 /* Foundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Foundation.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS18.0.sdk/System/Library/Frameworks/Foundation.framework; sourceTree = DEVELOPER_DIR; };
B185A1FC2FB67A12001C7771 /* LaunchScreen.storyboard */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; path = Sources/LaunchScreen.storyboard; sourceTree = SOURCE_ROOT; };
ED678A4BFF090312B354648E /* Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
@@ -54,13 +82,32 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
CAFA309ED97AD448069C6F95 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
837DB5EB5FA4527A71F02C70 /* Foundation.framework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
39B295835451899F1689190A /* NotificationServiceExtension */ = {
isa = PBXGroup;
children = (
4E34FD2046E40CE1E302DB11 /* NotificationService.swift */,
ED678A4BFF090312B354648E /* Info.plist */,
);
name = NotificationServiceExtension;
path = NotificationServiceExtension;
sourceTree = "<group>";
};
496BDBEC2B89A47800C09264 /* Products */ = {
isa = PBXGroup;
children = (
496BDBEB2B89A47800C09264 /* PediFoods.app */,
588B094973FB7448596F3D09 /* NotificationServiceExtension.appex */,
);
name = Products;
sourceTree = "<group>";
@@ -75,6 +122,8 @@
493609562A6B7EAE00C401E2 /* PediFoods */,
49F90C2A2A52156200F06D93 /* App */,
496BDBEC2B89A47800C09264 /* Products */,
39B295835451899F1689190A /* NotificationServiceExtension */,
8F798D6B741A681CBA9CC824 /* Frameworks */,
);
sourceTree = "<group>";
};
@@ -82,7 +131,7 @@
isa = PBXGroup;
children = (
49F90C2B2A52156200F06D93 /* Main.swift */,
B185A1FC2FB67A12001C7771 /* Sources/LaunchScreen.storyboard */,
B185A1FC2FB67A12001C7771 /* LaunchScreen.storyboard */,
49F90C2F2A52156300F06D93 /* Assets.xcassets */,
49F90C312A52156300F06D93 /* Entitlements.plist */,
4900101C2BACEA710000DE33 /* Info.plist */,
@@ -90,6 +139,22 @@
name = App;
sourceTree = "<group>";
};
8F798D6B741A681CBA9CC824 /* Frameworks */ = {
isa = PBXGroup;
children = (
C127D57D66FAAE472B202C61 /* iOS */,
);
name = Frameworks;
sourceTree = "<group>";
};
C127D57D66FAAE472B202C61 /* iOS */ = {
isa = PBXGroup;
children = (
78EA2CD32C41A0B833E06B89 /* Foundation.framework */,
);
name = iOS;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
@@ -101,10 +166,12 @@
499CD43C2AC5B799001AE8D8 /* Frameworks */,
499CD43E2AC5B799001AE8D8 /* Resources */,
499CD44A2AC5B9C6001AE8D8 /* Embed Frameworks */,
C28D30C677D0B233FE310699 /* Embed Foundation Extensions */,
);
buildRules = (
);
dependencies = (
A3838B46B6FB8B79EE9B0F9F /* PBXTargetDependency */,
);
name = "PediFoods App";
packageProductDependencies = (
@@ -115,6 +182,23 @@
productReference = 496BDBEB2B89A47800C09264 /* PediFoods.app */;
productType = "com.apple.product-type.application";
};
4FB6BE053007051C3D26B06E /* NotificationServiceExtension */ = {
isa = PBXNativeTarget;
buildConfigurationList = A20C22A5541093DB9CFD38F4 /* Build configuration list for PBXNativeTarget "NotificationServiceExtension" */;
buildPhases = (
29CCE086EDBB10F49FF72DDA /* Sources */,
CAFA309ED97AD448069C6F95 /* Frameworks */,
7A8A5D1C5EC61B8ACACF33CF /* Resources */,
);
buildRules = (
);
dependencies = (
);
name = NotificationServiceExtension;
productName = NotificationServiceExtension;
productReference = 588B094973FB7448596F3D09 /* NotificationServiceExtension.appex */;
productType = "com.apple.product-type.app-extension";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
@@ -124,6 +208,15 @@
BuildIndependentTargetsInParallel = 1;
LastSwiftUpdateCheck = 1430;
LastUpgradeCheck = 1630;
TargetAttributes = {
499CD4382AC5B799001AE8D8 = {
SystemCapabilities = {
com.apple.Push = {
enabled = 1;
};
};
};
};
};
buildConfigurationList = 49F90C232A52156200F06D93 /* Build configuration list for PBXProject "PediFoods" */;
compatibilityVersion = "Xcode 14.0";
@@ -145,6 +238,7 @@
projectRoot = "";
targets = (
499CD4382AC5B799001AE8D8 /* PediFoods App */,
4FB6BE053007051C3D26B06E /* NotificationServiceExtension */,
);
};
/* End PBXProject section */
@@ -154,15 +248,30 @@
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
B185A1FD2FB67A12001C7771 /* Sources/LaunchScreen.storyboard in Resources */,
B185A1FD2FB67A12001C7771 /* LaunchScreen.storyboard in Resources */,
499CD4402AC5B799001AE8D8 /* Assets.xcassets in Resources */,
496BDBEE2B8A7E9C00C09264 /* Localizable.xcstrings in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
7A8A5D1C5EC61B8ACACF33CF /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
29CCE086EDBB10F49FF72DDA /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
1457598D2491B8729EEBB739 /* NotificationService.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
499CD43A2AC5B799001AE8D8 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
@@ -173,12 +282,24 @@
};
/* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */
A3838B46B6FB8B79EE9B0F9F /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
name = NotificationServiceExtension;
target = 4FB6BE053007051C3D26B06E /* NotificationServiceExtension */;
targetProxy = E60A9FE80EBD27A663AF17E0 /* PBXContainerItemProxy */;
};
/* End PBXTargetDependency section */
/* Begin XCBuildConfiguration section */
499CD4422AC5B799001AE8D8 /* Debug */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 496EB72F2A6AE4DE00C1253B /* PediFoods.xcconfig */;
buildSettings = {
DEVELOPMENT_TEAM = K4E5BZMM4V;
CODE_SIGN_ENTITLEMENTS = Entitlements.plist;
CODE_SIGN_STYLE = Manual;
DEVELOPMENT_TEAM = "";
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = K4E5BZMM4V;
ENABLE_PREVIEWS = YES;
INFOPLIST_KEY_CFBundleDisplayName = "Pedi Foods";
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO;
@@ -186,6 +307,8 @@
INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait;
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
PROVISIONING_PROFILE_SPECIFIER = "";
"PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "PediFoods Dev Prov Prof";
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
SUPPORTS_MACCATALYST = NO;
TARGETED_DEVICE_FAMILY = 1;
@@ -196,9 +319,12 @@
isa = XCBuildConfiguration;
baseConfigurationReference = 496EB72F2A6AE4DE00C1253B /* PediFoods.xcconfig */;
buildSettings = {
CODE_SIGN_ENTITLEMENTS = Entitlements.plist;
CODE_SIGN_IDENTITY = "Apple Distribution";
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution";
CODE_SIGN_STYLE = Manual;
DEVELOPMENT_TEAM = K4E5BZMM4V;
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = K4E5BZMM4V;
ENABLE_PREVIEWS = YES;
INFOPLIST_KEY_CFBundleDisplayName = "Pedi Foods";
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO;
@@ -207,6 +333,7 @@
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
PROVISIONING_PROFILE_SPECIFIER = "com.br.pedifoods.app AppStore";
"PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "PediFoods Dist XC Prov Prof";
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
SUPPORTS_MACCATALYST = NO;
TARGETED_DEVICE_FAMILY = 1;
@@ -249,6 +376,55 @@
};
name = Release;
};
602E0FA08B93FC360B6AF5D1 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
CLANG_ENABLE_OBJC_WEAK = NO;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = K4E5BZMM4V;
INFOPLIST_FILE = NotificationServiceExtension/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 0.0.1;
PRODUCT_BUNDLE_IDENTIFIER = com.br.pedifoods.app.NotificationService;
PRODUCT_NAME = NotificationService;
SDKROOT = iphoneos;
SKIP_INSTALL = YES;
SWIFT_VERSION = 6;
TARGETED_DEVICE_FAMILY = 1;
};
name = Debug;
};
F90A5B614A60B25B97F3541D /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
CLANG_ENABLE_OBJC_WEAK = NO;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = K4E5BZMM4V;
INFOPLIST_FILE = NotificationServiceExtension/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 0.0.1;
PRODUCT_BUNDLE_IDENTIFIER = com.br.pedifoods.app.NotificationService;
PRODUCT_NAME = NotificationService;
SDKROOT = iphoneos;
SKIP_INSTALL = YES;
SWIFT_VERSION = 6;
TARGETED_DEVICE_FAMILY = 1;
VALIDATE_PRODUCT = YES;
};
name = Release;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
@@ -270,6 +446,15 @@
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
A20C22A5541093DB9CFD38F4 /* Build configuration list for PBXNativeTarget "NotificationServiceExtension" */ = {
isa = XCConfigurationList;
buildConfigurations = (
F90A5B614A60B25B97F3541D /* Release */,
602E0FA08B93FC360B6AF5D1 /* Debug */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
/* Begin XCRemoteSwiftPackageReference section */