Skip to content
Open
63 changes: 56 additions & 7 deletions SDK_PROPOSAL.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,13 @@ An SDK cannot provide a universal ActivityAttributes type because:
2. The widget extension must compile the type to render the UI.
3. Different apps have different Live Activity use cases.

ActivityAttributes have two parts:
- **Static properties** (on the struct itself) — set at creation, immutable for
the activity's lifetime. Example: `name: String`. Included in push-to-start
payloads but NOT in update payloads.
- **ContentState** (nested struct) — changes over time via updates. This is what
the server sends in the `content-state` push payload field.

An app can define **multiple** ActivityAttributes types. Each one gets:
- Its own push-to-start token (per-type, per-device)
- Its own per-instance activity push tokens
Expand Down Expand Up @@ -184,6 +191,41 @@ await Teak.endLiveActivity(activity, content: content, dismissalPolicy: .immedia
Token revocation on end is particularly important. Stale tokens cause APNs
delivery failures and waste the app's push notification budget.

## Verified Requirements for Push-to-Start

Discovered during testing with a live device and the Teak backend:

1. **`alert` is required for start events.** Without it, APNs returns 200 but
the system silently drops the push. The alert triggers the user-visible
notification that creates the activity. Update and end events do not require
an alert.

2. **`NSSupportsLiveActivitiesFrequentUpdates` must be `YES` in Info.plist.**
Without this flag, push-to-start delivery was inconsistent — sometimes
working, sometimes silently dropped. With the flag set, delivery is
reliable. This flag grants a higher push update budget; users can disable
it in Settings (which ends all ongoing Live Activities).

3. **Date encoding uses `deferredToDate` (Swift's default).** The `endDate`
field encodes as `timeIntervalSinceReferenceDate` (seconds since Jan 1,
2001 UTC), which is a plain number in JSON. This was verified working.
Example: `5.minutes.from_now.to_f - Time.utc(2001,1,1).to_f` in Ruby.

4. **`content-state` must include ALL ContentState fields.** If any field is
missing, the Live Activity shows a non-animating loading spinner instead
of the expected UI — effectively a crash. The system does not fall back to
defaults or show an error; it just hangs. This makes schema validation on
the server side critical.

### SDK Implications

- The SDK must document that apps need both `NSSupportsLiveActivities` and
`NSSupportsLiveActivitiesFrequentUpdates` in their Info.plist.
- Start payloads must always include an `alert` — the SDK/backend should
enforce this or provide a default.
- The backend must encode Date fields using `timeIntervalSinceReferenceDate`,
not ISO 8601 or Unix timestamps.

## Push Payload Reference

### Update Payload
Expand All @@ -194,7 +236,7 @@ delivery failures and waste the app's push notification budget.
"timestamp": 1705560370,
"event": "update",
"content-state": {
"endDate": "2025-01-18T12:06:10Z",
"endDate": 799497600.0,
"status": "Almost Done!"
},
"stale-date": 1705567570,
Expand All @@ -208,18 +250,25 @@ delivery failures and waste the app's push notification budget.

### Start Payload (push-to-start, iOS 17.2+)

**`alert` is REQUIRED for start events** — without it the push is silently dropped.

```json
{
"aps": {
"timestamp": 1705547770,
"event": "start",
"attributes-type": "TimerActivityAttributes",
"attributes": {},
"attributes-type": "CountdownActivityAttributes",
"attributes": {
"name": "My Countdown"
},
"content-state": {
"endDate": "2025-01-18T12:06:10Z",
"status": "In Progress"
"endDate": 799497600.0,
"phase": "Remotely Started"
},
"alert": { "title": "...", "body": "..." }
"alert": {
"title": "Countdown Started",
"body": "A countdown was started remotely"
}
}
}
```
Expand All @@ -232,7 +281,7 @@ delivery failures and waste the app's push notification budget.
"timestamp": 1705560370,
"event": "end",
"content-state": {
"endDate": "2025-01-18T12:06:10Z",
"endDate": 799497600.0,
"status": "Complete"
},
"dismissal-date": 1705567570
Expand Down
3 changes: 2 additions & 1 deletion Shared/CountdownActivityAttributes.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,5 +21,6 @@ struct CountdownActivityAttributes: ActivityAttributes {
var phase: String
}

// No static properties for this demo.
/// Static attributes — set at creation, immutable for the activity's lifetime.
var name: String
}
14 changes: 5 additions & 9 deletions Shared/TimerActivityAttributes.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,17 +14,13 @@ struct TimerActivityAttributes: ActivityAttributes {
/// JSON field in the APNs payload. The JSON keys must exactly match these
/// property names (or their CodingKeys if customized).
struct ContentState: Codable, Hashable {
/// The time when the timer expires. The widget uses this with
/// Text(timerInterval:) to render an auto-updating countdown.
var endDate: Date

/// A human-readable status string displayed alongside the timer.
var status: String
}

// No static (per-activity) properties for this demo.
// In a real app, you might have something like:
// var activityName: String
// var userId: String
// These are set once at activity creation and never change.
/// Static attributes are set at creation time and cannot change for the
/// lifetime of the activity. Both local starts and push-to-start payloads
/// provide these values. The widget extension can reference them alongside
/// the dynamic ContentState.
var name: String
}
5 changes: 3 additions & 2 deletions TeakLiveActivityWidget/CountdownLiveActivity.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import SwiftUI
struct CountdownLiveActivity: Widget {
var body: some WidgetConfiguration {
ActivityConfiguration(for: CountdownActivityAttributes.self) { context in
CountdownLockScreenView(state: context.state)
CountdownLockScreenView(name: context.attributes.name, state: context.state)
} dynamicIsland: { context in
DynamicIsland {
DynamicIslandExpandedRegion(.leading) {
Expand Down Expand Up @@ -42,12 +42,13 @@ struct CountdownLiveActivity: Widget {
/// Lock Screen view for countdown activities. Blue background visually
/// distinguishes this from timer activities (red).
struct CountdownLockScreenView: View {
let name: String
let state: CountdownActivityAttributes.ContentState

var body: some View {
HStack {
VStack(alignment: .leading, spacing: 4) {
Label("Countdown", systemImage: "hourglass")
Label(name, systemImage: "hourglass")
.font(.headline)
.foregroundStyle(.white)

Expand Down
19 changes: 6 additions & 13 deletions TeakLiveActivityWidget/TimerLiveActivity.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ struct TimerLiveActivity: Widget {
var body: some WidgetConfiguration {
ActivityConfiguration(for: TimerActivityAttributes.self) { context in
// Lock Screen / banner presentation
TimerLockScreenView(state: context.state)
TimerLockScreenView(name: context.attributes.name, state: context.state)
} dynamicIsland: { context in
// Dynamic Island is required by the API even on devices without
// Dynamic Island hardware. Provides fallback presentations.
Expand All @@ -21,22 +21,18 @@ struct TimerLiveActivity: Widget {
.font(.caption)
}
DynamicIslandExpandedRegion(.trailing) {
Text(timerInterval: Date.now...context.state.endDate, countsDown: true)
Text(context.state.status)
.font(.caption)
.monospacedDigit()
}
DynamicIslandExpandedRegion(.bottom) {
Text(context.state.status)
.font(.caption2)
EmptyView()
}
} compactLeading: {
Image(systemName: "timer")
.foregroundStyle(.red)
} compactTrailing: {
Text(timerInterval: Date.now...context.state.endDate, countsDown: true)
.monospacedDigit()
Text(context.state.status)
.font(.caption)
.frame(width: 44)
} minimal: {
Image(systemName: "timer")
.foregroundStyle(.red)
Expand All @@ -48,12 +44,13 @@ struct TimerLiveActivity: Widget {
/// Lock Screen view for timer activities. Red background visually
/// distinguishes this from countdown activities (blue).
struct TimerLockScreenView: View {
let name: String
let state: TimerActivityAttributes.ContentState

var body: some View {
HStack {
VStack(alignment: .leading, spacing: 4) {
Label("Timer", systemImage: "timer")
Label(name, systemImage: "timer")
.font(.headline)
.foregroundStyle(.white)

Expand All @@ -63,10 +60,6 @@ struct TimerLockScreenView: View {
}

Spacer()

Text(timerInterval: Date.now...state.endDate, countsDown: true)
.font(.system(size: 36, weight: .bold, design: .monospaced))
.foregroundStyle(.white)
}
.padding()
.background(.red.gradient)
Expand Down
Loading