# ActivityKit Patterns

Worked patterns for Live Activities. Everything here uses async/await and
`ActivityContent`, so it needs iOS 16.2 or later unless a heading names a
newer release.

## Contents

- [A complete attributes model](#a-complete-attributes-model)
- [Starting, with every parameter](#starting-with-every-parameter)
- [Updating from the app](#updating-from-the-app)
- [Server payloads](#server-payloads)
- [Channel pushes (iOS 18+)](#channel-pushes-ios-18)
- [Ending by outcome](#ending-by-outcome)
- [Dynamic Island, all regions](#dynamic-island-all-regions)
- [Lock Screen view with timer and progress](#lock-screen-view-with-timer-and-progress)
- [Several activities at once](#several-activities-at-once)
- [Observing state and content](#observing-state-and-content)
- [Tokens](#tokens)
- [Authorization](#authorization)
- [Errors](#errors)
- [Background behaviour](#background-behaviour)
- [Testing](#testing)
- [Info.plist keys](#infoplist-keys)
- [Apple documentation](#apple-documentation)

## A complete attributes model

A field-service visit: a technician drives to the customer and does the job.

```swift
import ActivityKit

struct ServiceVisitAttributes: ActivityAttributes {
    struct ContentState: Codable, Hashable {
        var technicianName: String
        var avatarSymbol: String          // SF Symbol or asset catalog name
        var vanDescription: String
        var arrivalStartEpoch: Int        // seconds since 1970
        var arrivalEndEpoch: Int
        var status: VisitStatus
        var kilometresAway: Double
    }

    var customerFirstName: String
    var serviceAddress: String
    var jobSummary: String
}

extension ServiceVisitAttributes.ContentState {
    var arrivalWindow: ClosedRange<Date> {
        Date(timeIntervalSince1970: TimeInterval(arrivalStartEpoch))
            ...
            Date(timeIntervalSince1970: TimeInterval(arrivalEndEpoch))
    }
}

enum VisitStatus: String, Codable, Hashable {
    case scheduled, enRoute, nearby, onSite, finishing, completed, cancelled, failed
}
```

Why the epoch integers: a server has to produce `content-state` JSON that
decodes into this type. Plain scalars have one obvious encoding, so the Swift
side and the server cannot drift. The view-friendly `ClosedRange<Date>` is
derived in an extension. The alternative is a custom `Codable` implementation
whose keys and value formats are written down and agreed with the server.

Size budget: the attributes and the content state together must fit in the
4 KB ActivityKit allows. Leave images, long strings and deep nesting out of
`ContentState`.

## Starting, with every parameter

The token loops below use a small helper:

```swift
extension Data {
    var hexEncoded: String { reduce(into: "") { $0 += String(format: "%02x", $1) } }
}
```

```swift
enum VisitActivityError: Error { case liveActivitiesOff }

@MainActor
func startVisitActivity(
    attributes: ServiceVisitAttributes,
    initial: ServiceVisitAttributes.ContentState
) async throws -> Activity<ServiceVisitAttributes> {
    guard ActivityAuthorizationInfo().areActivitiesEnabled else {
        throw VisitActivityError.liveActivitiesOff
    }

    let opening = ActivityContent(
        state: initial,
        staleDate: .now.addingTimeInterval(120),
        relevanceScore: 80
    )
    let activity = try Activity.request(
        attributes: attributes, content: opening, pushType: .token
    )

    Task {
        for await tokenData in activity.pushTokenUpdates {
            try? await VisitBackend.shared.register(
                updateToken: tokenData.hexEncoded, activityID: activity.id
            )
        }
    }

    Task {
        for await lifecycle in activity.activityStateUpdates where lifecycle == .dismissed {
            VisitCache.shared.purge(activityID: activity.id)
        }
    }

    return activity
}
```

### Scheduled start (iOS 26+)

```swift
let activity = try Activity.request(
    attributes: visit,
    content: opening,
    pushType: .token,
    style: .standard,
    alertConfiguration: AlertConfiguration(
        title: "Technician on the way",
        body: "Your visit window has started.",
        sound: .default
    ),
    start: visitWindowOpens
)
```

### Choosing a style (iOS 18+)

```swift
let persistent = try Activity.request(
    attributes: visit, content: opening, pushType: .token, style: .standard
)
```

A `.standard` activity stays until the app, a push, the user or the system
duration limit ends it. `.transient` is the wrong choice for anything meant to
persist; reserve it for brief expanded Dynamic Island moments.

## Updating from the app

Copy the fields that did not change from the current state, and convert dates
back to the agreed scalar form.

```swift
func applyStatus(
    _ status: VisitStatus,
    newWindow: ClosedRange<Date>,
    kilometres: Double,
    to activity: Activity<ServiceVisitAttributes>,
    notify: Bool
) async {
    let current = activity.content.state
    let state = ServiceVisitAttributes.ContentState(
        technicianName: current.technicianName,
        avatarSymbol: current.avatarSymbol,
        vanDescription: current.vanDescription,
        arrivalStartEpoch: Int(newWindow.lowerBound.timeIntervalSince1970),
        arrivalEndEpoch: Int(newWindow.upperBound.timeIntervalSince1970),
        status: status,
        kilometresAway: kilometres
    )
    let content = ActivityContent(
        state: state,
        staleDate: .now.addingTimeInterval(120),
        relevanceScore: status == .nearby ? 100 : 80
    )

    guard notify else {
        await activity.update(content)
        return
    }
    let message: LocalizedStringResource = switch status {
    case .nearby: "Your technician is a few minutes away."
    case .onSite: "Your technician has arrived."
    default: "Your visit has an update."
    }
    await activity.update(
        content,
        alertConfiguration: AlertConfiguration(title: "Service visit", body: message, sound: .default)
    )
}
```

`relevanceScore` ranks this activity against the app's other activities when
the system has to choose what to show; raise it for the moment that matters
most.

## Server payloads

Keys in `content-state` are the `ContentState` property names; enums travel as
their raw values; numbers stay numbers.

Update:

```json
{
  "aps": {
    "timestamp": 1790000000,
    "event": "update",
    "content-state": {
      "technicianName": "Sam",
      "avatarSymbol": "person.crop.circle",
      "vanDescription": "White van, blue stripe",
      "arrivalStartEpoch": 1790000300,
      "arrivalEndEpoch": 1790000900,
      "status": "nearby",
      "kilometresAway": 1.2
    },
    "stale-date": 1790000600,
    "relevance-score": 100,
    "alert": {
      "title": "Service visit",
      "body": "Your technician is a few minutes away.",
      "sound": "default"
    }
  }
}
```

End:

```json
{
  "aps": {
    "timestamp": 1790004000,
    "event": "end",
    "dismissal-date": 1790007600,
    "content-state": {
      "technicianName": "Sam",
      "avatarSymbol": "person.crop.circle",
      "vanDescription": "White van, blue stripe",
      "arrivalStartEpoch": 1790000300,
      "arrivalEndEpoch": 1790000300,
      "status": "completed",
      "kilometresAway": 0
    }
  }
}
```

Push-to-start (iOS 17.2+), addressed to the token from `pushToStartTokenUpdates`. `attributes-type`
is the Swift type name, `attributes` holds the static fields, and `alert` must
be present:

```json
{
  "aps": {
    "timestamp": 1789990000,
    "event": "start",
    "attributes-type": "ServiceVisitAttributes",
    "attributes": {
      "customerFirstName": "Alex",
      "serviceAddress": "12 Elm Row",
      "jobSummary": "Boiler check"
    },
    "content-state": {
      "technicianName": "Sam",
      "avatarSymbol": "person.crop.circle",
      "vanDescription": "White van, blue stripe",
      "arrivalStartEpoch": 1790000300,
      "arrivalEndEpoch": 1790000900,
      "status": "scheduled",
      "kilometresAway": 14.0
    },
    "alert": {
      "title": "Visit booked",
      "body": "Follow your technician live."
    }
  }
}
```

### HTTP headers

| Header | Value |
|---|---|
| `apns-push-type` | `liveactivity` |
| `apns-topic` | `BUNDLE_ID.push-type.liveactivity` |
| `apns-priority` | `5` (low) or `10` (immediate, counts against the budget) |
| `authorization` | `bearer JWT` with token-based auth; omit when using certificate auth |

A type mismatch, for example `"kilometresAway": "1.2"` for a `Double`, can be
enough for the update to be dropped.

## Channel pushes (iOS 18+)

Use a channel only with a valid channel ID that APNs created and your server
handed to the app:

```swift
let broadcastVisit = try Activity.request(
    attributes: visit,
    content: opening,
    pushType: .channel(channelID)
)
```

Channel pushes update or end; they never start an activity. They are sent with
`apns-channel-id` and an expiration rather than the device-token `apns-topic`.

## Ending by outcome

```swift
func finishVisit(_ activity: Activity<ServiceVisitAttributes>, outcome: VisitStatus) async {
    var last = activity.content.state
    last.status = outcome
    let final = ActivityContent(state: last, staleDate: nil, relevanceScore: 0)

    let policy: ActivityUIDismissalPolicy = switch outcome {
    case .completed: .after(.now.addingTimeInterval(3600))
    case .cancelled: .immediate
    default: .default
    }
    await activity.end(final, dismissalPolicy: policy)
}
```

A completed visit keeps its card for an hour so the customer can glance back
at it; a cancelled one has nothing worth showing and leaves at once.

Duration limits, stated separately: up to 8 hours active; after a
system-imposed end, up to 4 more hours on the Lock Screen (12 in total); after
an app-initiated end with `.default`, up to 4 hours of lingering.

### Terminal server failure

```swift
func failVisit(_ activity: Activity<ServiceVisitAttributes>, reason: String) async {
    var last = activity.content.state
    last.status = .failed
    last.vanDescription = reason   // a text field the view shows for failed visits
    await activity.end(
        ActivityContent(state: last, staleDate: nil, relevanceScore: 0),
        dismissalPolicy: .immediate
    )
}
```

### Sign-out

```swift
func endAllVisits() async {
    for running in Activity<ServiceVisitAttributes>.activities {
        await running.end(nil, dismissalPolicy: .immediate)
    }
}
```

Passing `nil` content ends the activity with its last state.

## Dynamic Island, all regions

```swift
DynamicIsland {
    DynamicIslandExpandedRegion(.leading) {
        Label {
            Text(context.state.technicianName).lineLimit(1)
        } icon: {
            Image(systemName: context.state.avatarSymbol)
        }
    }
    DynamicIslandExpandedRegion(.trailing) {
        VStack(alignment: .trailing) {
            Text(timerInterval: context.state.arrivalWindow, countsDown: true)
                .monospacedDigit()
            Text(context.state.kilometresAway, format: .number.precision(.fractionLength(1)))
                .font(.caption)
        }
    }
    DynamicIslandExpandedRegion(.center) {
        Text(context.state.status.rawValue.capitalized)
    }
    DynamicIslandExpandedRegion(.bottom) {
        VStack(spacing: 6) {
            ProgressView(value: max(0, 15 - context.state.kilometresAway), total: 15)
                .tint(.orange)
            HStack {
                Label(context.attributes.jobSummary, systemImage: "wrench.and.screwdriver")
                Spacer()
                Label(context.attributes.serviceAddress, systemImage: "house")
            }
            .font(.caption)
            .lineLimit(1)
        }
    }
} compactLeading: {
    Image(systemName: "wrench.and.screwdriver")
} compactTrailing: {
    Text(timerInterval: context.state.arrivalWindow, countsDown: true)
        .monospacedDigit()
        .frame(width: 44)
} minimal: {
    Image(systemName: "wrench.and.screwdriver").foregroundStyle(.orange)
}
.keylineTint(.orange)
```

## Lock Screen view with timer and progress

```swift
struct VisitLockScreenView: View {
    let context: ActivityViewContext<ServiceVisitAttributes>

    var body: some View {
        VStack(alignment: .leading, spacing: 8) {
            HStack {
                Text(context.state.technicianName).font(.headline)
                Spacer()
                if context.isStale {
                    Label("Waiting for fresh data", systemImage: "arrow.trianglehead.2.clockwise")
                        .font(.caption)
                        .foregroundStyle(.secondary)
                } else {
                    Text(timerInterval: context.state.arrivalWindow, countsDown: true)
                        .monospacedDigit()
                }
            }
            ProgressView(value: max(0, 15 - context.state.kilometresAway), total: 15)
            HStack {
                Text(context.attributes.jobSummary)
                Spacer()
                Text(context.attributes.serviceAddress)
            }
            .font(.caption)
            .lineLimit(1)
        }
        .padding()
    }
}
```

`Text(timerInterval:countsDown:)` ticks by itself; no update is needed to keep
the countdown moving.

## Several activities at once

An app may run more than one Live Activity, up to a system limit. Keep
references yourself or ask `Activity<T>.activities`.

```swift
@preconcurrency import ActivityKit
import Observation

@Observable
@MainActor
final class ChargingActivityCenter {
    private(set) var bySession: [String: Activity<ChargingAttributes>] = [:]

    func start(session: String, attributes: ChargingAttributes, state: ChargingAttributes.ContentState) throws {
        let activity = try Activity.request(
            attributes: attributes,
            content: ActivityContent(state: state, staleDate: nil),
            pushType: .token
        )
        bySession[session] = activity

        Task { [weak self] in
            for await tokenData in activity.pushTokenUpdates {
                try? await ChargingBackend.shared.register(tokenData.hexEncoded, session: session)
            }
            self?.bySession[session] = nil
        }
    }

    func update(session: String, to state: ChargingAttributes.ContentState) async {
        await bySession[session]?.update(ActivityContent(state: state, staleDate: nil))
    }

    func end(session: String) async {
        await bySession[session]?.end(nil, dismissalPolicy: .default)
        bySession[session] = nil
    }

    func reconcile() {
        for activity in Activity<ChargingAttributes>.activities
        where !bySession.values.contains(where: { $0.id == activity.id }) {
            bySession[activity.id] = activity   // session unknown after relaunch; key by activity ID
        }
    }
}
```

Call `reconcile()` at launch so activities the system still holds are tracked
again after the process was killed.

`Activity` is not `Sendable`, and its `update` and `end` are `nonisolated`
async methods, so calling them on an activity held by a `@MainActor` type fails
in Swift 6 with "sending value of non-Sendable type 'Activity<...>' risks
causing data races". `@preconcurrency import ActivityKit` accepts that until
the SDK annotates the type; the alternative is to keep only activity IDs on
the main actor and do the calls from `nonisolated` code.

## Observing state and content

```swift
Task {
    for await lifecycle in activity.activityStateUpdates {
        switch lifecycle {
        case .active: break                 // on screen and running
        case .pending: break                // iOS 26+: scheduled, not shown yet
        case .stale: await refresh(activity) // staleDate passed with no update
        case .ended: break                  // over, may still be on the Lock Screen
        case .dismissed: cleanUp(activity.id) // gone; release resources here
        @unknown default: break
        }
    }
}

Task {
    for await snapshot in activity.contentUpdates {
        render(snapshot.state)   // changes that arrived by push or from another process
    }
}
```

`.stale` exists from iOS 16.2; `.pending` from iOS 26.

## Tokens

The update token is how the server reaches the activity, so a failed
registration deserves retries with exponential backoff rather than a silent
`try?`:

```swift
func register(_ hex: String, activityID: String) async {
    var backoff: Duration = .seconds(1)
    for _ in 0..<5 {
        do {
            try await VisitBackend.shared.register(updateToken: hex, activityID: activityID)
            return
        } catch {
            try? await Task.sleep(for: backoff)
            backoff *= 2
        }
    }
}
```

Push-to-start tokens (iOS 17.2+) are separate from both the device token and
the per-activity update tokens:

```swift
Task {
    for await tokenData in Activity<ServiceVisitAttributes>.pushToStartTokenUpdates {
        try? await VisitBackend.shared.register(startToken: tokenData.hexEncoded)
    }
}
```

## Authorization

The user can turn Live Activities off in Settings at any time, so check before
every start.

```swift
let info = ActivityAuthorizationInfo()
let allowed = info.areActivitiesEnabled
let highCadence = info.frequentPushesEnabled

Task {
    for await enabled in info.activityEnablementUpdates {
        if enabled {
            observePushToStartTokens()
        } else {
            await VisitBackend.shared.stopLiveActivityPushes()
        }
    }
}

Task {
    for await enabled in info.frequentPushEnablementUpdates {
        await VisitBackend.shared.setFrequentPushes(enabled)
    }
}
```

## Errors

`Activity.request` throws `ActivityAuthorizationError`:

```swift
do {
    _ = try Activity.request(attributes: visit, content: opening, pushType: .token)
} catch let error as ActivityAuthorizationError {
    switch error {
    case .denied:
        showLiveActivitiesOffHint()        // user switched them off
    case .globalMaximumExceeded:
        logger.notice("Device-wide Live Activity limit reached")
    case .targetMaximumExceeded:
        await endOldestVisit()             // this app has too many; free one first
    default:
        logger.error("Live Activity not started: \(error.localizedDescription)")
    }
} catch {
    logger.error("Unexpected request failure: \(error.localizedDescription)")
}
```

## Background behaviour

- Backgrounding or suspending the app does not remove the card.
- Live Activity views run in the widget extension's sandbox. They cannot hit
  the network or receive location updates.
- Pushes are the main way to change an activity while the app is not running;
  prefer them to Background App Refresh.
- A suspended app still receives these pushes, yet delivery, priority, budget
  and throttling still apply. Combine `staleDate` with a reconcile pass when
  the app returns to the foreground:

```swift
func reconcileOnForeground() async {
    for running in Activity<ServiceVisitAttributes>.activities {
        switch running.activityState {
        case .active: await refreshFromServer(running)   // a push may have been missed
        case .stale: await refreshFromServer(running)    // update right away
        case .ended, .dismissed: cleanUp(running.id)
        default: break
        }
    }
}
```

## Testing

The simulator shows Live Activities on its Lock Screen and, on models that
have one, in the Dynamic Island. Xcode previews cover each presentation:

```swift
extension ServiceVisitAttributes {
    static var preview: ServiceVisitAttributes {
        ServiceVisitAttributes(customerFirstName: "Alex", serviceAddress: "12 Elm Row", jobSummary: "Boiler check")
    }
}

extension ServiceVisitAttributes.ContentState {
    static let enRoute = Self(technicianName: "Sam", avatarSymbol: "person.crop.circle",
        vanDescription: "White van", arrivalStartEpoch: 1790000300, arrivalEndEpoch: 1790000900,
        status: .enRoute, kilometresAway: 9)
    static let onSite = Self(technicianName: "Sam", avatarSymbol: "person.crop.circle",
        vanDescription: "White van", arrivalStartEpoch: 1790000300, arrivalEndEpoch: 1790000300,
        status: .onSite, kilometresAway: 0)
}

#Preview("Visit card", as: .content, using: ServiceVisitAttributes.preview) {
    ServiceVisitLiveActivity()
} contentStates: {
    ServiceVisitAttributes.ContentState.enRoute
    ServiceVisitAttributes.ContentState.onSite
}

#Preview("Expanded", as: .dynamicIsland(.expanded), using: ServiceVisitAttributes.preview) {
    ServiceVisitLiveActivity()
} contentStates: {
    ServiceVisitAttributes.ContentState.enRoute
}
```

Use `.dynamicIsland(.compact)` and `.dynamicIsland(.minimal)` the same way.
Listing several states in `contentStates:` lets the preview step through the
transitions between them.

The simulator has no APNs, so push-to-update is tested on a physical device.
Send a test push with a push testing tool or with curl. Development builds use
the sandbox host `api.sandbox.push.apple.com`; production builds use
`api.push.apple.com`:

```bash
curl --http2 \
  -H "apns-push-type: liveactivity" \
  -H "apns-topic: com.example.fieldservice.push-type.liveactivity" \
  -H "apns-priority: 10" \
  -H "authorization: bearer $JWT_TOKEN" \
  -d @update.json \
  "https://api.sandbox.push.apple.com/3/device/$ACTIVITY_PUSH_TOKEN"
```

### Debugging

- A start that fails usually explains itself in Console.app under the
  `ActivityKit` subsystem.
- Compare the `content-state` keys with the synthesized `Codable` shape, or
  with the agreed `CodingKeys`.
- In the debugger, `Activity<T>.activities` lists what is running.
- A breakpoint inside the `pushTokenUpdates` loop shows whether tokens arrive.
- If nothing ever appears, confirm that the main app target (not just the
  extension) declares `NSSupportsLiveActivities = YES`.

## Info.plist keys

| Key | Value | Effect |
|---|---|---|
| `NSSupportsLiveActivities` | `YES` | Required for the app to start Live Activities |
| `NSSupportsLiveActivitiesFrequentUpdates` | `YES` | Raises the system-managed push budget |

Both keys are read from the main app target; adding them to the extension has
no effect.

## Apple documentation

- [ActivityKit](https://developer.apple.com/documentation/activitykit)
- [ActivityAttributes](https://developer.apple.com/documentation/activitykit/activityattributes)
- [Activity](https://developer.apple.com/documentation/activitykit/activity)
- [ActivityContent](https://developer.apple.com/documentation/activitykit/activitycontent)
- [ActivityAuthorizationInfo](https://developer.apple.com/documentation/activitykit/activityauthorizationinfo)
- [DynamicIsland](https://developer.apple.com/documentation/widgetkit/dynamicisland)
- [ActivityConfiguration](https://developer.apple.com/documentation/widgetkit/activityconfiguration)
- [Live Activity push notifications guide](https://developer.apple.com/documentation/activitykit/starting-and-updating-live-activities-with-activitykit-push-notifications)
- [Broadcast push requests to APNs](https://developer.apple.com/documentation/usernotifications/sending-broadcast-push-notification-requests-to-apns)
