# Notification Patterns

Working code for the app side of notifications: wiring at launch, the center
delegate, routing a tap to a screen, silent pushes, a small scheduler, the
token lifecycle, category setup, testing, badges and moving a provisional user
to full permission.

## Contents

- [App and AppDelegate Wiring](#app-and-appdelegate-wiring)
- [The Center Delegate](#the-center-delegate)
- [Routing a Tap to a Screen](#routing-a-tap-to-a-screen)
- [Handling a Silent Push](#handling-a-silent-push)
- [A Local Scheduler](#a-local-scheduler)
- [Token Lifecycle](#token-lifecycle)
- [Registering Categories](#registering-categories)
- [Testing and Debugging Delivery](#testing-and-debugging-delivery)
- [Badges](#badges)
- [From Provisional to Full Permission](#from-provisional-to-full-permission)

## App and AppDelegate Wiring

SwiftUI calls the app's `init` ahead of the delegate's launch callback
(`application(_:didFinishLaunchingWithOptions:)`), so the center delegate is
installed there, as early as it can be.

```swift
@main
struct GardenApp: App {
    @UIApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
    @State private var router = NotificationRouter()

    init() {
        UNUserNotificationCenter.current().delegate = GardenNotificationDelegate.shared
    }

    var body: some Scene {
        WindowGroup {
            RootView()
                .environment(router)
                .task { await startNotifications() }
        }
    }

    @MainActor
    private func startNotifications() async {
        GardenNotificationDelegate.shared.router = router
        NotificationCategories.registerAll()
        let center = UNUserNotificationCenter.current()
        if await center.notificationSettings().authorizationStatus == .notDetermined {
            _ = try? await center.requestAuthorization(options: [.badge, .alert, .sound])
        }
        // Register whatever the answer was: a user who declined alerts
        // still receives remote notifications, only silently.
        UIApplication.shared.registerForRemoteNotifications()
    }
}
```

### The AppDelegate

```swift
final class AppDelegate: NSObject, UIApplicationDelegate {
    func application(_ application: UIApplication,
                     didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
        let hex = deviceToken.reduce(into: "") { $0 += String(format: "%02x", $1) }
        // Upload on every call: the token may differ from the last launch.
        Task { @MainActor in
            await PushTokenService.shared.upload(hex)
        }
    }

    func application(_ application: UIApplication,
                     didFailToRegisterForRemoteNotificationsWithError error: Error) {
        #if targetEnvironment(simulator)
        // Always happens here; use .apns files or simctl push to test.
        #else
        Diagnostics.record("apns_registration_failed", error: error)
        #endif
    }

    func application(_ application: UIApplication,
                     didReceiveRemoteNotification userInfo: [AnyHashable: Any]) async
        -> UIBackgroundFetchResult {
        await SilentPushHandler().handle(job: userInfo["job"] as? String)
    }
}
```

## The Center Delegate

The protocol methods are `nonisolated`: the system may call them off the main
thread and the notification objects are not `Sendable`. Each one copies the
plain values it needs, then hands them to main-actor code.

```swift
struct TapSummary: Sendable {
    let category: String
    let action: String
    let threadID: String?
    let plantID: String?
    let typed: String?

    init(_ response: UNNotificationResponse) {
        let content = response.notification.request.content
        category = content.categoryIdentifier
        action = response.actionIdentifier
        threadID = content.userInfo["threadID"] as? String
        plantID = content.userInfo["plantID"] as? String
        typed = (response as? UNTextInputNotificationResponse)?.userText
    }
}

@MainActor
final class GardenNotificationDelegate: NSObject, UNUserNotificationCenterDelegate {
    static let shared = GardenNotificationDelegate()
    weak var router: NotificationRouter?
    var visibleThreadID: String?

    private override init() { super.init() }

    nonisolated func userNotificationCenter(_ center: UNUserNotificationCenter,
                                            willPresent notification: UNNotification) async
        -> UNNotificationPresentationOptions {
        let thread = notification.request.content.userInfo["threadID"] as? String
        let onScreen = await visibleThreadID
        if let thread, thread == onScreen {
            // The user is already looking at this conversation: skip the banner.
            return [.badge, .list, .sound]
        }
        return [.banner, .sound, .badge, .list]
    }

    nonisolated func userNotificationCenter(_ center: UNUserNotificationCenter,
                                            didReceive response: UNNotificationResponse) async {
        await handle(TapSummary(response))
    }

    private func handle(_ tap: TapSummary) async {
        switch tap.action {
        case UNNotificationDefaultActionIdentifier:
            openScreen(for: tap)
        case UNNotificationDismissActionIdentifier:
            // Only delivered when the category has .customDismissAction.
            await ChatService.shared.markSeen(tap.threadID)
        default:
            await perform(tap)
        }
    }

    private func openScreen(for tap: TapSummary) {
        switch tap.category {
        case "CHAT":
            if let id = tap.threadID { router?.navigate(to: .thread(id: id)) }
        case "WATERING":
            if let id = tap.plantID { router?.navigate(to: .plant(id: id)) }
        default:
            break   // unknown category: the app simply opens where it was
        }
    }

    private func perform(_ tap: TapSummary) async {
        switch (tap.category, tap.action) {
        case ("CHAT", "REPLY"):
            guard let text = tap.typed, let id = tap.threadID else { return }
            await ChatService.shared.send(text, to: id)
        case ("CHAT", "MARK_READ"):
            await ChatService.shared.markSeen(tap.threadID)
        case ("WATERING", "DONE"):
            if let id = tap.plantID { await PlantStore.shared.logWatering(id) }
        default:
            break
        }
    }
}
```

## Routing a Tap to a Screen

```swift
enum AppDestination: Hashable {
    case thread(id: String)
    case plant(id: String)
}

@Observable @MainActor
final class NotificationRouter {
    var pendingDestination: AppDestination?

    func navigate(to destination: AppDestination) {
        pendingDestination = destination
    }
}

enum AppTab: Hashable { case chats, garden }

struct RootView: View {
    @Environment(NotificationRouter.self) private var router
    @State private var tab: AppTab = .chats
    @State private var chatsPath = NavigationPath()
    @State private var gardenPath = NavigationPath()

    var body: some View {
        TabView(selection: $tab) {
            Tab("Chats", systemImage: "bubble.left.and.bubble.right", value: AppTab.chats) {
                NavigationStack(path: $chatsPath) {
                    ChatListView()
                        .navigationDestination(for: AppDestination.self) { DestinationView($0) }
                }
            }
            Tab("Garden", systemImage: "leaf", value: AppTab.garden) {
                NavigationStack(path: $gardenPath) {
                    GardenView()
                        .navigationDestination(for: AppDestination.self) { DestinationView($0) }
                }
            }
        }
        .onChange(of: router.pendingDestination) { _, target in
            guard let target else { return }
            switch target {
            case .thread:
                tab = .chats
                chatsPath = NavigationPath()
                chatsPath.append(target)
            case .plant:
                tab = .garden
                gardenPath = NavigationPath()
                gardenPath.append(target)
            }
            router.pendingDestination = nil
        }
    }
}
```

`Tab(_:systemImage:value:)` needs iOS 18; on older targets use `.tabItem` and
`.tag`.

## Handling a Silent Push

The app gets roughly 30 seconds of background time. Report the real outcome:
the system uses the results to learn when your data changes and to plan later
wake-ups. The delegate reads the `job` string out of `userInfo` before calling
the handler: the dictionary is not `Sendable`, so Swift 6 rejects handing it
from the main-actor delegate to a nonisolated async method.

```swift
struct SilentPushHandler {
    func handle(job: String?) async -> UIBackgroundFetchResult {
        switch job {
        case "refreshInbox":
            do {
                let changed = try await MailboxSync.shared.refresh()
                return changed ? .newData : .noData
            } catch {
                return .failed
            }
        case "purgeCache":
            await ImageCache.shared.purgeExpired()
            return .noData
        default:
            return .noData
        }
    }
}
```

Apple's guidance caps background pushes at roughly two or three per hour. Do not use them to poll a server, and never make something the user can
see depend on one arriving.

When reviewing a silent-push design, flag each of these if missing:
- It does not deliver instant freshness.
- APNs can throttle or drop the push.
- The Remote notifications background mode is on.
- The handler finishes quickly and its result matches what happened.

## A Local Scheduler

Wrapping the center in one type keeps call sites small and gives tests a seam.

```swift
@MainActor
final class NotificationScheduler {
    private let center: UNUserNotificationCenter

    init(center: UNUserNotificationCenter = .current()) {
        self.center = center
    }

    func remind(_ key: String, headline: String, text: String, on moment: Date,
                categoryID: String? = nil) async throws {
        let payload = makeContent(headline, text)
        if let categoryID { payload.categoryIdentifier = categoryID }
        let when = Calendar.current.dateComponents([.year, .month, .day, .hour, .minute], from: moment)
        try await enqueue(key, payload, UNCalendarNotificationTrigger(dateMatching: when, repeats: false))
    }

    func remindEveryDay(_ key: String, headline: String, text: String,
                        atHour hour: Int, minute: Int) async throws {
        var when = DateComponents()
        when.hour = hour
        when.minute = minute
        let payload = makeContent(headline, text)
        try await enqueue(key, payload, UNCalendarNotificationTrigger(dateMatching: when, repeats: true))
    }

    private func makeContent(_ headline: String, _ text: String) -> UNMutableNotificationContent {
        let payload = UNMutableNotificationContent()
        payload.title = headline
        payload.body = text
        payload.sound = .default
        return payload
    }

    private func enqueue(_ key: String, _ payload: UNNotificationContent,
                         _ trigger: UNNotificationTrigger) async throws {
        try await center.add(UNNotificationRequest(identifier: key, content: payload, trigger: trigger))
    }

    func cancel(_ key: String) {
        center.removePendingNotificationRequests(withIdentifiers: [key])
        center.removeDeliveredNotifications(withIdentifiers: [key])
    }

    func cancelAll() {
        center.removeAllPendingNotificationRequests()
        center.removeAllDeliveredNotifications()
    }

    func hasPending(_ key: String) async -> Bool {
        let queued = await center.pendingNotificationRequests()
        return queued.contains { $0.identifier == key }
    }
}
```

## Token Lifecycle

The token is short-lived data. Send whatever the latest callback delivered;
never check a value in `UserDefaults` to decide an upload can be skipped.

```swift
@MainActor @Observable
final class PushTokenService {
    static let shared = PushTokenService()
    private(set) var currentToken: String?

    func upload(_ hexToken: String) async {
        do {
            try await DeviceAPI.register(pushToken: hexToken)
            currentToken = hexToken
        } catch {
            // Nothing to retry by hand: the next launch delivers the token again.
        }
    }

    func invalidate() async {
        if let active = currentToken {
            try? await DeviceAPI.unregister(pushToken: active)
        }
        currentToken = nil
    }
}
```

Call `invalidate()` at sign-out. Without it the server keeps the token bound to
the old account and that account's pushes keep landing on this device.

## Registering Categories

Register at launch, before the first notification can arrive.
`setNotificationCategories` replaces the whole set, so pass every category in
one call.

```swift
enum NotificationCategories {
    static func registerAll() {
        let reply = UNTextInputNotificationAction(
            identifier: "REPLY", title: "Reply", options: [],
            textInputButtonTitle: "Send", textInputPlaceholder: "Write a reply"
        )
        let markRead = UNNotificationAction(identifier: "MARK_READ", title: "Mark as Read", options: [])
        let chat = UNNotificationCategory(
            identifier: "CHAT",
            actions: [reply, markRead],
            intentIdentifiers: [],
            hiddenPreviewsBodyPlaceholder: nil,
            categorySummaryFormat: "%u more messages",
            options: [.customDismissAction]
        )

        let done = UNNotificationAction(identifier: "DONE", title: "Watered", options: [.foreground])
        let skip = UNNotificationAction(identifier: "SKIP", title: "Skip Today", options: [.destructive])
        let watering = UNNotificationCategory(
            identifier: "WATERING", actions: [done, skip], intentIdentifiers: []
        )

        UNUserNotificationCenter.current().setNotificationCategories([chat, watering])
    }
}
```

## Testing and Debugging Delivery

### What Simulator can and cannot do

- It never receives pushes from APNs, and
  `didFailToRegisterForRemoteNotificationsWithError` fires every time.
- Local notifications behave normally.
- A `.apns` file dropped onto the Simulator window is shown as a remote
  notification.

### Simulating a remote notification

The file needs a top-level `Simulator Target Bundle` key alongside `aps` and
any custom keys:

```json
{
  "Simulator Target Bundle": "org.sample.garden",
  "aps": { "alert": { "title": "Basil", "body": "Time to water." }, "sound": "default" },
  "plantID": "basil-01"
}
```

```bash
xcrun simctl push booted org.sample.garden watering.apns
```

### On a device

Debug builds receive sandbox tokens; release and TestFlight builds talk to the
production APNs endpoint. The Push Notifications Console in the developer
account sends test pushes to development tokens and shows delivery logs; a
request generated there, or your own script, can also be replayed.

### When nothing arrives

- The provisioning profile includes the push entitlement and the app carries
  the `aps-environment` key.
- The provider sends the token as hex, with no assumption about its size.

| APNs status | Meaning |
|-------------|---------|
| 200 | Accepted |
| 400 | Bad request; check the payload format (4096 bytes at most) |
| 403 | Certificate or signing key is wrong or has expired |
| 405 | Wrong method; requests must be POST over HTTP/2 |
| 410 | Token is no longer valid; delete it on the server |
| 413 | Payload over 4096 bytes |
| 429 | Too many requests; back off |
| 500 | APNs internal error; retry later |

On a Mac with the device attached, Console.app filtered to `apsd`, `dasd` or
the app's bundle ID shows the system's side of delivery.

Frequent causes of missing notifications:
- Low Power Mode is deferring work.
- A Focus or Do Not Disturb mode does not allow the app.
- No network.
- A stale token (410).
- Silent pushes above the few-per-hour budget.
- A payload over 4096 bytes.
- An expired APNs certificate or key.

Silent pushes are handled more generously while debugging than in production,
where delays are normal. For background work that must run, use
`BGTaskScheduler` (see `background-processing`).

## Badges

Clear the badge when the app becomes active so an old count does not stay on
the icon. `setBadgeCount(_:)` is async and throws, available from iOS 16; add
an availability check when the deployment target is older. The two-parameter
`onChange(of:)` closure below is iOS 17; on iOS 16 use the single-value form.

```swift
struct BadgeResetModifier: ViewModifier {
    @Environment(\.scenePhase) private var phase

    func body(content wrapped: Content) -> some View {
        wrapped.onChange(of: phase) { _, newPhase in
            guard newPhase == .active else { return }
            Task {
                let center = UNUserNotificationCenter.current()
                try? await center.setBadgeCount(0)
            }
        }
    }
}
```

A UIKit scene can do the same in `sceneDidBecomeActive(_:)`.

## From Provisional to Full Permission

A provisional user can be asked again: requesting `[.alert, .sound, .badge]`
now shows the real system prompt.

```swift
@MainActor
func upgradeFromProvisional() async -> Bool {
    let center = UNUserNotificationCenter.current()
    let status = await center.notificationSettings().authorizationStatus
    guard status == .provisional else { return status == .authorized }
    let granted = try? await center.requestAuthorization(options: [.sound, .badge, .alert])
    return granted ?? false
}
```
