This snapshot establishes the camera-to-result recognition flow and related tests while checking in the project skill/docs assets required for the configured local tooling.
838 lines
23 KiB
Markdown
838 lines
23 KiB
Markdown
---
|
|
name: axiom-push-notifications-ref
|
|
description: Use when needing APNs HTTP/2 transport details, JWT authentication setup, payload key reference, UNUserNotificationCenter API, notification category/action registration, service extension lifecycle, local notification triggers, Live Activity push headers, or broadcast push format. Covers complete push notification API surface.
|
|
license: MIT
|
|
---
|
|
|
|
# Push Notifications API Reference
|
|
|
|
Comprehensive API reference for APNs HTTP/2 transport, UserNotifications framework, and push-driven features including Live Activities and broadcast push.
|
|
|
|
## Quick Reference
|
|
|
|
```swift
|
|
// AppDelegate — minimal remote notification setup
|
|
class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDelegate {
|
|
func application(_ application: UIApplication,
|
|
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
|
|
UNUserNotificationCenter.current().delegate = self
|
|
return true
|
|
}
|
|
|
|
func application(_ application: UIApplication,
|
|
didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
|
|
let token = deviceToken.map { String(format: "%02x", $0) }.joined()
|
|
sendTokenToServer(token)
|
|
}
|
|
|
|
func application(_ application: UIApplication,
|
|
didFailToRegisterForRemoteNotificationsWithError error: Error) {
|
|
print("Registration failed: \(error)")
|
|
}
|
|
|
|
// Show notifications when app is in foreground
|
|
func userNotificationCenter(_ center: UNUserNotificationCenter,
|
|
willPresent notification: UNNotification) async -> UNNotificationPresentationOptions {
|
|
return [.banner, .sound, .badge]
|
|
}
|
|
|
|
// Handle notification tap / action response
|
|
func userNotificationCenter(_ center: UNUserNotificationCenter,
|
|
didReceive response: UNNotificationResponse) async {
|
|
let userInfo = response.notification.request.content.userInfo
|
|
// Route to appropriate screen based on userInfo
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## APNs Transport Reference
|
|
|
|
### Endpoints
|
|
|
|
| Environment | Host | Port |
|
|
|-------------|------|------|
|
|
| Development | api.sandbox.push.apple.com | 443 or 2197 |
|
|
| Production | api.push.apple.com | 443 or 2197 |
|
|
|
|
### Request Format
|
|
|
|
```
|
|
POST /3/device/{device_token}
|
|
Host: api.push.apple.com
|
|
Authorization: bearer {jwt_token}
|
|
apns-topic: {bundle_id}
|
|
apns-push-type: alert
|
|
Content-Type: application/json
|
|
```
|
|
|
|
### APNs Headers
|
|
|
|
| Header | Required | Values | Notes |
|
|
|--------|----------|--------|-------|
|
|
| apns-push-type | Yes | alert, background, liveactivity, voip, complication, fileprovider, mdm, location | Must match payload content |
|
|
| apns-topic | Yes | Bundle ID (or .push-type.liveactivity suffix) | Required for token-based auth |
|
|
| apns-priority | No | 10 (immediate), 5 (power-conscious), 1 (low) | Default: 10 for alert, 5 for background |
|
|
| apns-expiration | No | UNIX timestamp or 0 | 0 = deliver once, don't store |
|
|
| apns-collapse-id | No | String ≤64 bytes | Replaces matching notification on device |
|
|
| apns-id | No | UUID (lowercase) | Returned by APNs for tracking |
|
|
| authorization | Token auth | bearer {JWT} | Not needed for certificate auth |
|
|
| apns-unique-id | Response only | UUID | Use with Push Notifications Console delivery log |
|
|
|
|
### Response Codes
|
|
|
|
| Status | Meaning | Common Cause |
|
|
|--------|---------|--------------|
|
|
| 200 | Success | |
|
|
| 400 | Bad request | Malformed JSON, missing required header |
|
|
| 403 | Forbidden | Expired JWT, wrong team/key, topic mismatch |
|
|
| 404 | Not found | Invalid device token path |
|
|
| 405 | Method not allowed | Not using POST |
|
|
| 410 | Unregistered | Device token no longer active (app uninstalled) |
|
|
| 413 | Payload too large | Exceeds 4KB (5KB for VoIP) |
|
|
| 429 | Too many requests | Rate limited by APNs |
|
|
| 500 | Internal server error | APNs issue, retry |
|
|
| 503 | Service unavailable | APNs overloaded, retry with backoff |
|
|
|
|
---
|
|
|
|
## JWT Authentication Reference
|
|
|
|
### JWT Header
|
|
|
|
```json
|
|
{ "alg": "ES256", "kid": "{10-char Key ID}" }
|
|
```
|
|
|
|
### JWT Claims
|
|
|
|
```json
|
|
{ "iss": "{10-char Team ID}", "iat": {unix_timestamp} }
|
|
```
|
|
|
|
### Rules
|
|
|
|
| Rule | Detail |
|
|
|------|--------|
|
|
| Algorithm | ES256 (P-256 curve) |
|
|
| Signing key | APNs auth key (.p8 from developer portal) |
|
|
| Token lifetime | Max 1 hour (403 ExpiredProviderToken if older) |
|
|
| Refresh interval | Between 20 and 60 minutes |
|
|
| Scope | One key works for all apps in team, both environments |
|
|
|
|
### Authorization Header Format
|
|
|
|
```
|
|
authorization: bearer eyAia2lkIjog...
|
|
```
|
|
|
|
---
|
|
|
|
## Payload Reference
|
|
|
|
### `aps` Dictionary Keys
|
|
|
|
| Key | Type | Purpose | Since |
|
|
|-----|------|---------|-------|
|
|
| alert | Dict/String | Alert content | iOS 10 |
|
|
| badge | Number | App icon badge (0 removes) | iOS 10 |
|
|
| sound | String/Dict | Audio playback | iOS 10 |
|
|
| thread-id | String | Notification grouping | iOS 10 |
|
|
| category | String | Actionable notification type | iOS 10 |
|
|
| content-available | Number (1) | Silent background push | iOS 10 |
|
|
| mutable-content | Number (1) | Triggers service extension | iOS 10 |
|
|
| target-content-id | String | Window/content identifier | iOS 13 |
|
|
| interruption-level | String | passive/active/time-sensitive/critical | iOS 15 |
|
|
| relevance-score | Number 0-1 | Notification summary sorting | iOS 15 |
|
|
| filter-criteria | String | Focus filter matching | iOS 15 |
|
|
| stale-date | Number | UNIX timestamp (Live Activity) | iOS 16.1 |
|
|
| content-state | Dict | Live Activity content update | iOS 16.1 |
|
|
| timestamp | Number | UNIX timestamp (Live Activity) | iOS 16.1 |
|
|
| event | String | start/update/end (Live Activity) | iOS 16.1 |
|
|
| dismissal-date | Number | UNIX timestamp (Live Activity) | iOS 16.1 |
|
|
| attributes-type | String | Live Activity struct name | iOS 17 |
|
|
| attributes | Dict | Live Activity init data | iOS 17 |
|
|
|
|
### Alert Dictionary Keys
|
|
|
|
| Key | Type | Purpose |
|
|
|-----|------|---------|
|
|
| title | String | Short title |
|
|
| subtitle | String | Secondary description |
|
|
| body | String | Full message |
|
|
| launch-image | String | Launch screen filename |
|
|
| title-loc-key | String | Localization key for title |
|
|
| title-loc-args | [String] | Title format arguments |
|
|
| subtitle-loc-key | String | Localization key for subtitle |
|
|
| subtitle-loc-args | [String] | Subtitle format arguments |
|
|
| loc-key | String | Localization key for body |
|
|
| loc-args | [String] | Body format arguments |
|
|
|
|
### Sound Dictionary (Critical Alerts)
|
|
|
|
```json
|
|
{ "critical": 1, "name": "alarm.aiff", "volume": 0.8 }
|
|
```
|
|
|
|
### Interruption Level Values
|
|
|
|
| Value | Behavior | Requires |
|
|
|-------|----------|----------|
|
|
| passive | No sound/wake. Notification summary only. | Nothing |
|
|
| active | Default. Sound + banner. | Nothing |
|
|
| time-sensitive | Breaks scheduled delivery. Banner persists. | Time Sensitive capability |
|
|
| critical | Overrides DND and ringer switch. | Apple approval + entitlement |
|
|
|
|
### Example Payloads
|
|
|
|
#### Basic Alert
|
|
|
|
```json
|
|
{
|
|
"aps": {
|
|
"alert": {
|
|
"title": "New Message",
|
|
"subtitle": "From Alice",
|
|
"body": "Hey, are you free for lunch?"
|
|
},
|
|
"badge": 3,
|
|
"sound": "default"
|
|
}
|
|
}
|
|
```
|
|
|
|
#### Localized with loc-key/loc-args
|
|
|
|
```json
|
|
{
|
|
"aps": {
|
|
"alert": {
|
|
"title-loc-key": "MESSAGE_TITLE",
|
|
"title-loc-args": ["Alice"],
|
|
"loc-key": "MESSAGE_BODY",
|
|
"loc-args": ["Alice", "lunch"]
|
|
},
|
|
"sound": "default"
|
|
}
|
|
}
|
|
```
|
|
|
|
#### Silent Background Push
|
|
|
|
```json
|
|
{
|
|
"aps": {
|
|
"content-available": 1
|
|
},
|
|
"custom-key": "sync-update"
|
|
}
|
|
```
|
|
|
|
#### Rich Notification (Service Extension)
|
|
|
|
```json
|
|
{
|
|
"aps": {
|
|
"alert": {
|
|
"title": "Photo shared",
|
|
"body": "Alice shared a photo with you"
|
|
},
|
|
"mutable-content": 1,
|
|
"sound": "default"
|
|
},
|
|
"image-url": "https://example.com/photo.jpg"
|
|
}
|
|
```
|
|
|
|
#### Critical Alert
|
|
|
|
```json
|
|
{
|
|
"aps": {
|
|
"alert": {
|
|
"title": "Server Down",
|
|
"body": "Production database is unreachable"
|
|
},
|
|
"sound": { "critical": 1, "name": "default", "volume": 1.0 },
|
|
"interruption-level": "critical"
|
|
}
|
|
}
|
|
```
|
|
|
|
#### Time-Sensitive with Category
|
|
|
|
```json
|
|
{
|
|
"aps": {
|
|
"alert": {
|
|
"title": "Package Delivered",
|
|
"body": "Your order has been delivered to the front door"
|
|
},
|
|
"interruption-level": "time-sensitive",
|
|
"category": "DELIVERY",
|
|
"sound": "default"
|
|
},
|
|
"order-id": "12345"
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## UNUserNotificationCenter API Reference
|
|
|
|
### Key Methods
|
|
|
|
| Method | Purpose |
|
|
|--------|---------|
|
|
| requestAuthorization(options:) | Request permission |
|
|
| notificationSettings() | Check current status |
|
|
| add(_:) | Schedule notification request |
|
|
| getPendingNotificationRequests() | List scheduled |
|
|
| removePendingNotificationRequests(withIdentifiers:) | Cancel scheduled |
|
|
| getDeliveredNotifications() | List in notification center |
|
|
| removeDeliveredNotifications(withIdentifiers:) | Remove from center |
|
|
| setNotificationCategories(_:) | Register actionable types |
|
|
| setBadgeCount(_:) | Update badge (iOS 16+) |
|
|
| supportsContentExtensions | Check content extension support |
|
|
|
|
### UNAuthorizationOptions
|
|
|
|
| Option | Purpose |
|
|
|--------|---------|
|
|
| .alert | Display alerts |
|
|
| .badge | Update badge count |
|
|
| .sound | Play sounds |
|
|
| .carPlay | Show in CarPlay |
|
|
| .criticalAlert | Critical alerts (requires entitlement) |
|
|
| .provisional | Trial delivery without prompting |
|
|
| .providesAppNotificationSettings | "Configure in App" button in Settings |
|
|
| .announcement | Siri announcement (deprecated iOS 15+) |
|
|
|
|
### UNAuthorizationStatus
|
|
|
|
| Value | Meaning |
|
|
|-------|---------|
|
|
| .notDetermined | No prompt shown yet |
|
|
| .denied | User denied or disabled in Settings |
|
|
| .authorized | User explicitly granted |
|
|
| .provisional | Provisional trial delivery |
|
|
| .ephemeral | App Clip temporary |
|
|
|
|
### Request Authorization
|
|
|
|
```swift
|
|
let center = UNUserNotificationCenter.current()
|
|
|
|
let granted = try await center.requestAuthorization(options: [.alert, .sound, .badge])
|
|
if granted {
|
|
await MainActor.run {
|
|
UIApplication.shared.registerForRemoteNotifications()
|
|
}
|
|
}
|
|
```
|
|
|
|
### Check Settings
|
|
|
|
```swift
|
|
let settings = await center.notificationSettings()
|
|
|
|
switch settings.authorizationStatus {
|
|
case .authorized: break
|
|
case .denied:
|
|
// Direct user to Settings
|
|
case .provisional:
|
|
// Upgrade to full authorization
|
|
case .notDetermined:
|
|
// Request authorization
|
|
case .ephemeral:
|
|
// App Clip — temporary
|
|
@unknown default: break
|
|
}
|
|
```
|
|
|
|
### Delegate Methods
|
|
|
|
```swift
|
|
// Foreground presentation — called when notification arrives while app is active
|
|
func userNotificationCenter(_ center: UNUserNotificationCenter,
|
|
willPresent notification: UNNotification) async
|
|
-> UNNotificationPresentationOptions {
|
|
return [.banner, .sound, .badge]
|
|
}
|
|
|
|
// Action response — called when user taps notification or action button
|
|
func userNotificationCenter(_ center: UNUserNotificationCenter,
|
|
didReceive response: UNNotificationResponse) async {
|
|
let actionIdentifier = response.actionIdentifier
|
|
let userInfo = response.notification.request.content.userInfo
|
|
|
|
switch actionIdentifier {
|
|
case UNNotificationDefaultActionIdentifier:
|
|
// User tapped notification body
|
|
break
|
|
case UNNotificationDismissActionIdentifier:
|
|
// User dismissed (requires .customDismissAction on category)
|
|
break
|
|
default:
|
|
// Custom action
|
|
break
|
|
}
|
|
}
|
|
|
|
// Settings — called when user taps "Configure in App" from notification settings
|
|
func userNotificationCenter(_ center: UNUserNotificationCenter,
|
|
openSettingsFor notification: UNNotification?) {
|
|
// Navigate to in-app notification settings
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## UNNotificationCategory and UNNotificationAction API
|
|
|
|
### Category Registration
|
|
|
|
```swift
|
|
let likeAction = UNNotificationAction(
|
|
identifier: "LIKE",
|
|
title: "Like",
|
|
options: []
|
|
)
|
|
|
|
let replyAction = UNTextInputNotificationAction(
|
|
identifier: "REPLY",
|
|
title: "Reply",
|
|
options: [],
|
|
textInputButtonTitle: "Send",
|
|
textInputPlaceholder: "Type a message..."
|
|
)
|
|
|
|
let deleteAction = UNNotificationAction(
|
|
identifier: "DELETE",
|
|
title: "Delete",
|
|
options: [.destructive, .authenticationRequired]
|
|
)
|
|
|
|
let messageCategory = UNNotificationCategory(
|
|
identifier: "MESSAGE",
|
|
actions: [likeAction, replyAction, deleteAction],
|
|
intentIdentifiers: [],
|
|
hiddenPreviewsBodyPlaceholder: "New message",
|
|
categorySummaryFormat: "%u more messages",
|
|
options: [.customDismissAction]
|
|
)
|
|
|
|
UNUserNotificationCenter.current().setNotificationCategories([messageCategory])
|
|
```
|
|
|
|
### Action Options
|
|
|
|
| Option | Effect |
|
|
|--------|--------|
|
|
| .authenticationRequired | Requires device unlock |
|
|
| .destructive | Red text display |
|
|
| .foreground | Launches app to foreground |
|
|
|
|
### Category Options
|
|
|
|
| Option | Effect |
|
|
|--------|--------|
|
|
| .customDismissAction | Fires delegate on dismiss |
|
|
| .allowInCarPlay | Show actions in CarPlay |
|
|
| .hiddenPreviewsShowTitle | Show title when previews hidden |
|
|
| .hiddenPreviewsShowSubtitle | Show subtitle when previews hidden |
|
|
| .allowAnnouncement | Siri can announce (deprecated iOS 15+) |
|
|
|
|
### UNNotificationActionIcon (iOS 15+)
|
|
|
|
```swift
|
|
let icon = UNNotificationActionIcon(systemImageName: "hand.thumbsup")
|
|
let action = UNNotificationAction(
|
|
identifier: "LIKE",
|
|
title: "Like",
|
|
options: [],
|
|
icon: icon
|
|
)
|
|
```
|
|
|
|
---
|
|
|
|
## UNNotificationServiceExtension API
|
|
|
|
Modifies notification content before display. Runs in a separate extension process.
|
|
|
|
### Lifecycle
|
|
|
|
| Method | Window | Purpose |
|
|
|--------|--------|---------|
|
|
| didReceive(_:withContentHandler:) | ~30 seconds | Modify notification content |
|
|
| serviceExtensionTimeWillExpire() | Called at deadline | Deliver best attempt immediately |
|
|
|
|
### Implementation
|
|
|
|
```swift
|
|
class NotificationService: UNNotificationServiceExtension {
|
|
var contentHandler: ((UNNotificationContent) -> Void)?
|
|
var bestAttemptContent: UNMutableNotificationContent?
|
|
|
|
override func didReceive(_ request: UNNotificationRequest,
|
|
withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) {
|
|
self.contentHandler = contentHandler
|
|
bestAttemptContent = (request.content.mutableCopy() as? UNMutableNotificationContent)
|
|
|
|
guard let content = bestAttemptContent,
|
|
let imageURLString = content.userInfo["image-url"] as? String,
|
|
let imageURL = URL(string: imageURLString) else {
|
|
contentHandler(request.content)
|
|
return
|
|
}
|
|
|
|
// Download and attach image
|
|
let task = URLSession.shared.downloadTask(with: imageURL) { url, _, error in
|
|
defer { contentHandler(content) }
|
|
guard let url = url, error == nil else { return }
|
|
|
|
let attachment = try? UNNotificationAttachment(
|
|
identifier: "image",
|
|
url: url,
|
|
options: [UNNotificationAttachmentOptionsTypeHintKey: "public.jpeg"]
|
|
)
|
|
if let attachment = attachment {
|
|
content.attachments = [attachment]
|
|
}
|
|
}
|
|
task.resume()
|
|
}
|
|
|
|
override func serviceExtensionTimeWillExpire() {
|
|
if let content = bestAttemptContent {
|
|
contentHandler?(content)
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
### Supported Attachment Types
|
|
|
|
| Type | Extensions | Max Size |
|
|
|------|-----------|----------|
|
|
| Image | .jpg, .gif, .png | 10 MB |
|
|
| Audio | .aif, .wav, .mp3 | 5 MB |
|
|
| Video | .mp4, .mpeg | 50 MB |
|
|
|
|
### Payload Requirement
|
|
|
|
The notification payload must include `"mutable-content": 1` in the `aps` dictionary for the service extension to fire.
|
|
|
|
---
|
|
|
|
## Local Notifications API
|
|
|
|
### Trigger Types
|
|
|
|
| Trigger | Use Case | Repeating |
|
|
|---------|----------|-----------|
|
|
| UNTimeIntervalNotificationTrigger | After N seconds | Yes (≥60s) |
|
|
| UNCalendarNotificationTrigger | Specific date/time | Yes |
|
|
| UNLocationNotificationTrigger | Enter/exit region | Yes |
|
|
|
|
### Time Interval Trigger
|
|
|
|
```swift
|
|
let content = UNMutableNotificationContent()
|
|
content.title = "Reminder"
|
|
content.body = "Time to take a break"
|
|
content.sound = .default
|
|
|
|
let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 300, repeats: false)
|
|
|
|
let request = UNNotificationRequest(
|
|
identifier: "break-reminder",
|
|
content: content,
|
|
trigger: trigger
|
|
)
|
|
|
|
try await UNUserNotificationCenter.current().add(request)
|
|
```
|
|
|
|
### Calendar Trigger
|
|
|
|
```swift
|
|
var dateComponents = DateComponents()
|
|
dateComponents.hour = 9
|
|
dateComponents.minute = 0
|
|
|
|
let trigger = UNCalendarNotificationTrigger(dateMatching: dateComponents, repeats: true)
|
|
|
|
let request = UNNotificationRequest(
|
|
identifier: "daily-9am",
|
|
content: content,
|
|
trigger: trigger
|
|
)
|
|
|
|
try await UNUserNotificationCenter.current().add(request)
|
|
```
|
|
|
|
### Location Trigger
|
|
|
|
```swift
|
|
import CoreLocation
|
|
|
|
let center = CLLocationCoordinate2D(latitude: 37.3349, longitude: -122.0090)
|
|
let region = CLCircularRegion(center: center, radius: 100, identifier: "apple-park")
|
|
region.notifyOnEntry = true
|
|
region.notifyOnExit = false
|
|
|
|
let trigger = UNLocationNotificationTrigger(region: region, repeats: false)
|
|
|
|
let request = UNNotificationRequest(
|
|
identifier: "arrived-at-office",
|
|
content: content,
|
|
trigger: trigger
|
|
)
|
|
|
|
try await UNUserNotificationCenter.current().add(request)
|
|
```
|
|
|
|
### Limitations
|
|
|
|
| Limitation | Detail |
|
|
|-----------|--------|
|
|
| Minimum repeat interval | 60 seconds for UNTimeIntervalNotificationTrigger |
|
|
| Location authorization | Location trigger requires When In Use or Always authorization |
|
|
| No service extensions | Local notifications do not trigger UNNotificationServiceExtension |
|
|
| No background wake | Local notifications cannot use content-available for background processing |
|
|
| App extensions | Local notifications cannot be scheduled from app extensions (use app group + main app) |
|
|
| Pending limit | 64 pending notification requests per app |
|
|
|
|
---
|
|
|
|
## Live Activity Push Headers
|
|
|
|
### Required Headers
|
|
|
|
| Header | Value |
|
|
|--------|-------|
|
|
| apns-push-type | liveactivity |
|
|
| apns-topic | {bundleID}.push-type.liveactivity |
|
|
| apns-priority | 5 (routine) or 10 (time-sensitive) |
|
|
|
|
### Event Types
|
|
|
|
| Event | Purpose | Required Fields |
|
|
|-------|---------|----------------|
|
|
| start | Start Live Activity remotely | attributes-type, attributes, content-state, timestamp |
|
|
| update | Update content | content-state, timestamp |
|
|
| end | End Live Activity | timestamp (content-state optional) |
|
|
|
|
### Update Payload
|
|
|
|
```json
|
|
{
|
|
"aps": {
|
|
"timestamp": 1709913600,
|
|
"event": "update",
|
|
"content-state": {
|
|
"homeScore": 2,
|
|
"awayScore": 1,
|
|
"inning": "Top 7"
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
### Start Payload (Push-to-Start Token)
|
|
|
|
```json
|
|
{
|
|
"aps": {
|
|
"timestamp": 1709913600,
|
|
"event": "start",
|
|
"content-state": {
|
|
"homeScore": 0,
|
|
"awayScore": 0,
|
|
"inning": "Top 1"
|
|
},
|
|
"attributes-type": "GameAttributes",
|
|
"attributes": {
|
|
"homeTeam": "Giants",
|
|
"awayTeam": "Dodgers"
|
|
},
|
|
"alert": {
|
|
"title": "Game Starting",
|
|
"body": "Giants vs Dodgers is about to begin"
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
### Start Payload (Channel-Based)
|
|
|
|
```json
|
|
{
|
|
"aps": {
|
|
"timestamp": 1709913600,
|
|
"event": "start",
|
|
"content-state": {
|
|
"homeScore": 0,
|
|
"awayScore": 0,
|
|
"inning": "Top 1"
|
|
},
|
|
"attributes-type": "GameAttributes",
|
|
"attributes": {
|
|
"homeTeam": "Giants",
|
|
"awayTeam": "Dodgers"
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
### End Payload
|
|
|
|
```json
|
|
{
|
|
"aps": {
|
|
"timestamp": 1709913600,
|
|
"event": "end",
|
|
"dismissal-date": 1709917200,
|
|
"content-state": {
|
|
"homeScore": 5,
|
|
"awayScore": 3,
|
|
"inning": "Final"
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
### Push-to-Start Token
|
|
|
|
```swift
|
|
// Observe push-to-start tokens (iOS 17.2+)
|
|
for await token in Activity<GameAttributes>.pushToStartTokenUpdates {
|
|
let tokenString = token.map { String(format: "%02x", $0) }.joined()
|
|
sendPushToStartTokenToServer(tokenString)
|
|
}
|
|
```
|
|
|
|
### Activity Push Token
|
|
|
|
```swift
|
|
// Observe activity-specific push tokens
|
|
for await tokenData in activity.pushTokenUpdates {
|
|
let token = tokenData.map { String(format: "%02x", $0) }.joined()
|
|
sendActivityTokenToServer(token, activityId: activity.id)
|
|
}
|
|
```
|
|
|
|
Content-state encoding rule: the system always uses default JSONDecoder — do not use custom encoding strategies in your ActivityAttributes.ContentState.
|
|
|
|
---
|
|
|
|
## Broadcast Push API (iOS 18+)
|
|
|
|
Server-to-many push for Live Activities without tracking individual device tokens.
|
|
|
|
### Endpoint
|
|
|
|
```
|
|
POST /4/broadcasts/apps/{TOPIC}
|
|
```
|
|
|
|
### Headers
|
|
|
|
| Header | Value |
|
|
|--------|-------|
|
|
| apns-push-type | liveactivity |
|
|
| apns-channel-id | {channelID} |
|
|
| authorization | bearer {JWT} |
|
|
|
|
### Subscribe via Channel
|
|
|
|
```swift
|
|
try Activity.request(
|
|
attributes: attributes,
|
|
content: .init(state: initialState, staleDate: nil),
|
|
pushType: .channel(channelId)
|
|
)
|
|
```
|
|
|
|
### Channel Storage Policies
|
|
|
|
| Policy | Behavior | Budget |
|
|
|--------|----------|--------|
|
|
| No Storage | Deliver only to connected devices | Higher |
|
|
| Most Recent Message | Store latest for offline devices | Lower |
|
|
|
|
---
|
|
|
|
## Command-Line Testing
|
|
|
|
### JWT Generation
|
|
|
|
```bash
|
|
JWT_ISSUE_TIME=$(date +%s)
|
|
JWT_HEADER=$(printf '{ "alg": "ES256", "kid": "%s" }' "${AUTH_KEY_ID}" | openssl base64 -e -A | tr -- '+/' '-_' | tr -d =)
|
|
JWT_CLAIMS=$(printf '{ "iss": "%s", "iat": %d }' "${TEAM_ID}" "${JWT_ISSUE_TIME}" | openssl base64 -e -A | tr -- '+/' '-_' | tr -d =)
|
|
JWT_HEADER_CLAIMS="${JWT_HEADER}.${JWT_CLAIMS}"
|
|
JWT_SIGNED_HEADER_CLAIMS=$(printf "${JWT_HEADER_CLAIMS}" | openssl dgst -binary -sha256 -sign "${TOKEN_KEY_FILE_NAME}" | openssl base64 -e -A | tr -- '+/' '-_' | tr -d =)
|
|
AUTHENTICATION_TOKEN="${JWT_HEADER}.${JWT_CLAIMS}.${JWT_SIGNED_HEADER_CLAIMS}"
|
|
```
|
|
|
|
### Send Alert Push
|
|
|
|
```bash
|
|
curl -v \
|
|
--header "apns-topic: $TOPIC" \
|
|
--header "apns-push-type: alert" \
|
|
--header "authorization: bearer $AUTHENTICATION_TOKEN" \
|
|
--data '{"aps":{"alert":"test"}}' \
|
|
--http2 https://${APNS_HOST_NAME}/3/device/${DEVICE_TOKEN}
|
|
```
|
|
|
|
### Send Live Activity Push
|
|
|
|
```bash
|
|
curl \
|
|
--header "apns-topic: com.example.app.push-type.liveactivity" \
|
|
--header "apns-push-type: liveactivity" \
|
|
--header "apns-priority: 10" \
|
|
--header "authorization: bearer $AUTHENTICATION_TOKEN" \
|
|
--data '{
|
|
"aps": {
|
|
"timestamp": '$(date +%s)',
|
|
"event": "update",
|
|
"content-state": { "score": "2-1" }
|
|
}
|
|
}' \
|
|
--http2 https://api.sandbox.push.apple.com/3/device/$ACTIVITY_PUSH_TOKEN
|
|
```
|
|
|
|
### Simulator Push
|
|
|
|
```bash
|
|
xcrun simctl push booted com.example.app payload.json
|
|
```
|
|
|
|
### Simulator Payload File
|
|
|
|
```json
|
|
{
|
|
"Simulator Target Bundle": "com.example.app",
|
|
"aps": {
|
|
"alert": { "title": "Test", "body": "Hello" },
|
|
"sound": "default"
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Resources
|
|
|
|
**WWDC**: 2021-10091, 2023-10025, 2023-10185, 2024-10069
|
|
|
|
**Docs**: /usernotifications, /usernotifications/sending-notification-requests-to-apns, /usernotifications/generating-a-remote-notification, /activitykit
|
|
|
|
**Skills**: axiom-push-notifications, axiom-push-notifications-diag, axiom-extensions-widgets
|