|
| 1 | +# Live Activity Implementation Plan |
| 2 | + |
| 3 | +## Architecture Overview |
| 4 | + |
| 5 | +``` |
| 6 | +┌─────────────────────────────────────────────────────────────┐ |
| 7 | +│ iPhone Lock Screen / Dynamic Island │ |
| 8 | +│ ┌─────────────────────────────────────────────────────┐ │ |
| 9 | +│ │ Live Activity (expo-widgets) │ │ |
| 10 | +│ │ "Installing dependencies..." ● Working │ │ |
| 11 | +│ └─────────────────────────────────────────────────────┘ │ |
| 12 | +│ ▲ local update (foreground) ▲ APNs push (bg) │ |
| 13 | +│ │ │ │ |
| 14 | +│ ┌──────┴─────┐ │ │ |
| 15 | +│ │ SSE stream │ │ │ |
| 16 | +│ │ /event │ │ │ |
| 17 | +│ └──────┬─────┘ │ │ |
| 18 | +└─────────┼──────────────────────────────┼────────────────────┘ |
| 19 | + │ │ |
| 20 | + ▼ │ |
| 21 | +┌──────────────────┐ ┌────────┴─────────┐ |
| 22 | +│ OpenCode Server │──event──> │ APN Relay │ |
| 23 | +│ (push-relay.ts) │ │ /v1/activity/* │ |
| 24 | +│ GlobalBus │ │ apns.ts │ |
| 25 | +└──────────────────┘ └──────────────────┘ |
| 26 | +``` |
| 27 | + |
| 28 | +**Foreground**: App receives SSE events, updates the Live Activity locally via `instance.update()`. |
| 29 | +**Background**: OpenCode server fires events to the relay, relay sends `liveactivity` APNs pushes with `content-state`, iOS updates the widget. |
| 30 | +**Push-to-start**: Relay sends a `start` push to begin a Live Activity even when the app hasn't initiated one. |
| 31 | + |
| 32 | +## Content-State Shape |
| 33 | + |
| 34 | +```typescript |
| 35 | +type SessionActivityProps = { |
| 36 | + status: "working" | "retry" | "permission" | "complete" | "error" |
| 37 | + sessionTitle: string // e.g. "Fix auth bug" |
| 38 | + lastMessage: string // truncated ~120 chars, e.g. "Installing dependencies..." |
| 39 | + retryInfo: string | null // e.g. "Retry 2/5 in 8s" when status is "retry" |
| 40 | +} |
| 41 | +``` |
| 42 | +
|
| 43 | +This is intentionally lean -- it keeps APNs payload size well under the 4KB limit. |
| 44 | +
|
| 45 | +## Dynamic Island / Lock Screen Layout |
| 46 | +
|
| 47 | +| Slot | Content | |
| 48 | +| ------------------------ | ---------------------------------------------- | |
| 49 | +| **Banner** (Lock Screen) | Session title, status badge, last message text | |
| 50 | +| **Compact leading** | App icon or "OC" text | |
| 51 | +| **Compact trailing** | Status word ("Working", "Done", "Needs input") | |
| 52 | +| **Minimal** | Small status dot/icon | |
| 53 | +| **Expanded leading** | Session title + status | |
| 54 | +| **Expanded trailing** | Time elapsed or ETA | |
| 55 | +| **Expanded bottom** | Last message text, retry info if applicable | |
| 56 | +
|
| 57 | +--- |
| 58 | +
|
| 59 | +## Phase 1: Core Live Activity (App-Initiated, Local + Push Updates) |
| 60 | +
|
| 61 | +This phase delivers the end-to-end working feature. |
| 62 | +
|
| 63 | +### 1a. Install and configure expo-widgets |
| 64 | +
|
| 65 | +**Package**: `mobile-voice` |
| 66 | +
|
| 67 | +- Add `expo-widgets` and `@expo/ui` to dependencies |
| 68 | +- Add the plugin to `app.json`: |
| 69 | + ```json |
| 70 | + [ |
| 71 | + "expo-widgets", |
| 72 | + { |
| 73 | + "enablePushNotifications": true, |
| 74 | + "widgets": [ |
| 75 | + { |
| 76 | + "name": "SessionActivity", |
| 77 | + "displayName": "OpenCode Session", |
| 78 | + "description": "Live session monitoring on Lock Screen and Dynamic Island" |
| 79 | + } |
| 80 | + ] |
| 81 | + } |
| 82 | + ] |
| 83 | + ``` |
| 84 | +- Add `NSSupportsLiveActivities: true` and `NSSupportsLiveActivitiesFrequentUpdates: true` to `expo.ios.infoPlist` in `app.json` |
| 85 | +- Requires a new EAS dev build after this step |
| 86 | +
|
| 87 | +### 1b. Create the Live Activity component |
| 88 | +
|
| 89 | +**New file**: `src/widgets/session-activity.tsx` |
| 90 | +
|
| 91 | +- Define `SessionActivityProps` type |
| 92 | +- Build the `LiveActivityLayout` using `@expo/ui/swift-ui` primitives (`Text`, `VStack`, `HStack`) |
| 93 | +- Export via `createLiveActivity('SessionActivity', SessionActivity)` |
| 94 | +- Adapt layout per slot (banner, compact, minimal, expanded) |
| 95 | +- Use `LiveActivityEnvironment.colorScheme` to handle dark/light |
| 96 | +
|
| 97 | +### 1c. Create a Live Activity management hook |
| 98 | +
|
| 99 | +**New file**: `src/hooks/use-live-activity.ts` |
| 100 | +
|
| 101 | +Responsibilities: |
| 102 | +
|
| 103 | +- `startActivity(sessionTitle, sessionId)` -- calls `SessionActivity.start(props, deepLinkURL)`, stores the instance, gets the push token |
| 104 | +- `updateActivity(props)` -- calls `instance.update(props)` for foreground SSE-driven updates |
| 105 | +- `endActivity(finalStatus)` -- calls `instance.end('default', finalProps)` |
| 106 | +- Manages the per-activity push token lifecycle |
| 107 | +- Exposes `activityPushToken: string | null` for relay registration |
| 108 | +- Handles edge cases: activity already running (end previous before starting new), activities disabled by user, iOS version checks |
| 109 | +
|
| 110 | +### 1d. Integrate into useMonitoring |
| 111 | +
|
| 112 | +**File**: `src/hooks/use-monitoring.ts` |
| 113 | +
|
| 114 | +- Import and use `useLiveActivity` |
| 115 | +- **On `beginMonitoring(job)`**: call `startActivity(sessionTitle, job.sessionID)` |
| 116 | +- **In the SSE event handler** (foreground): map classified events to `updateActivity()` calls: |
| 117 | + - `session.status` busy -> `{ status: "working", lastMessage: <latest text> }` |
| 118 | + - `session.status` retry -> `{ status: "retry", retryInfo: "Retry N in Xs" }` |
| 119 | + - `permission.asked` -> `{ status: "permission", lastMessage: "Needs your decision" }` |
| 120 | + - `session.status` idle (complete) -> `endActivity("complete")` |
| 121 | + - `session.error` -> `endActivity("error")` |
| 122 | +- **On stop monitoring**: end the activity if still running |
| 123 | +- **On app background**: stop SSE (already happens), rely on APNs pushes for updates |
| 124 | +- **On app foreground**: reconnect SSE, sync local activity state with `syncSessionState()` |
| 125 | +
|
| 126 | +### 1e. Register activity push tokens with the relay |
| 127 | +
|
| 128 | +**File**: `src/lib/relay-client.ts` |
| 129 | +
|
| 130 | +- New function: `registerActivityToken(input)` -- calls new relay endpoint `POST /v1/activity/register` |
| 131 | + ```typescript |
| 132 | + registerActivityToken(input: { |
| 133 | + relayBaseURL: string |
| 134 | + secret: string |
| 135 | + activityToken: string |
| 136 | + sessionID: string |
| 137 | + bundleId?: string |
| 138 | + }): Promise<void> |
| 139 | + ``` |
| 140 | +- New function: `unregisterActivityToken(input)` -- cleanup |
| 141 | + ```typescript |
| 142 | + unregisterActivityToken(input: { |
| 143 | + relayBaseURL: string |
| 144 | + secret: string |
| 145 | + sessionID: string |
| 146 | + }): Promise<void> |
| 147 | + ``` |
| 148 | +
|
| 149 | +**File**: `src/hooks/use-live-activity.ts` or `use-monitoring.ts` |
| 150 | +
|
| 151 | +- When `activityPushToken` becomes available after `startActivity()`, send it to the relay alongside the `sessionID` |
| 152 | +- On activity end, unregister the token |
| 153 | +
|
| 154 | +### 1f. Extend the APN relay for Live Activity pushes |
| 155 | +
|
| 156 | +**Package**: `apn-relay` |
| 157 | +
|
| 158 | +New endpoint: `POST /v1/activity/register` |
| 159 | +
|
| 160 | +```typescript |
| 161 | +{ |
| 162 | + secret: string, |
| 163 | + sessionID: string, |
| 164 | + activityToken: string, // the per-activity push token |
| 165 | + bundleId?: string |
| 166 | +} |
| 167 | +``` |
| 168 | +
|
| 169 | +New endpoint: `POST /v1/activity/unregister` |
| 170 | +
|
| 171 | +```typescript |
| 172 | +{ |
| 173 | + secret: string, |
| 174 | + sessionID: string |
| 175 | +} |
| 176 | +``` |
| 177 | +
|
| 178 | +New DB table: `activity_registration` |
| 179 | +
|
| 180 | +```sql |
| 181 | +id TEXT PRIMARY KEY, |
| 182 | +secret_hash TEXT NOT NULL, |
| 183 | +session_id TEXT NOT NULL, |
| 184 | +activity_token TEXT NOT NULL, |
| 185 | +bundle_id TEXT NOT NULL, |
| 186 | +apns_env TEXT NOT NULL DEFAULT 'production', |
| 187 | +created_at INTEGER NOT NULL, |
| 188 | +updated_at INTEGER NOT NULL, |
| 189 | +UNIQUE(secret_hash, session_id) |
| 190 | +``` |
| 191 | +
|
| 192 | +Modified: `POST /v1/event` handler |
| 193 | +
|
| 194 | +- After sending the regular alert push (existing behavior), also check `activity_registration` for matching `(secret_hash, session_id)` |
| 195 | +- If a registration exists, send a second push with: |
| 196 | + - `apns-push-type: liveactivity` |
| 197 | + - `apns-topic: {bundleId}.push-type.liveactivity` |
| 198 | + - Payload with `content-state` containing the `SessionActivityProps` fields |
| 199 | + - `event: "update"` for progress, `event: "end"` for complete/error |
| 200 | +
|
| 201 | +New function in `apns.ts`: `sendLiveActivityUpdate(input)` |
| 202 | +
|
| 203 | +- Separate from the existing `send()` function |
| 204 | +- Uses `liveactivity` push type headers |
| 205 | +- Constructs `content-state` payload format |
| 206 | +
|
| 207 | +### 1g. Extend the OpenCode server push-relay for richer events |
| 208 | +
|
| 209 | +**File**: `packages/opencode/src/server/push-relay.ts` |
| 210 | +
|
| 211 | +- Extend `Type` union: `"complete" | "permission" | "error" | "progress"` |
| 212 | +- Add cases to `map()` function: |
| 213 | + - `session.status` with `type: "busy"` -> `{ type: "progress", sessionID }` |
| 214 | + - `session.status` with `type: "retry"` -> `{ type: "progress", sessionID }` (with retry metadata) |
| 215 | + - `message.updated` where the message has tool-use or assistant text -> `{ type: "progress", sessionID }` (throttled) |
| 216 | +- Add to `notify()` / `post()`: include a `contentState` object in the relay payload for progress events |
| 217 | +- Add throttling: don't send more than ~1 progress push per 10-15 seconds to stay within APNs budget |
| 218 | +- Extend `evt` payload sent to relay: |
| 219 | + ```typescript |
| 220 | + { |
| 221 | + secret, serverID, eventType, sessionID, title, body, |
| 222 | + // New field for Live Activity updates: |
| 223 | + contentState?: { |
| 224 | + status: "working" | "retry" | "permission" | "complete" | "error", |
| 225 | + sessionTitle: string, |
| 226 | + lastMessage: string, |
| 227 | + retryInfo: string | null |
| 228 | + } |
| 229 | + } |
| 230 | + ``` |
| 231 | +
|
| 232 | +--- |
| 233 | +
|
| 234 | +## Phase 2: Push-to-Start |
| 235 | +
|
| 236 | +This lets the server start a Live Activity on the phone when a session begins, even if the user didn't initiate it from the app. |
| 237 | +
|
| 238 | +### 2a. Register push-to-start token from the app |
| 239 | +
|
| 240 | +**File**: `src/hooks/use-live-activity.ts` |
| 241 | +
|
| 242 | +- On app launch, call `addPushToStartTokenListener()` from `expo-widgets` |
| 243 | +- Send the push-to-start token to the relay at registration time (extend existing `/v1/device/register` or new field) |
| 244 | +- This token is app-wide (not per-activity), so it lives alongside the device push token |
| 245 | +
|
| 246 | +### 2b. Extend relay for push-to-start |
| 247 | +
|
| 248 | +**Package**: `apn-relay` |
| 249 | +
|
| 250 | +- Add `push_to_start_token` column to `device_registration` table (nullable) |
| 251 | +- Extend `/v1/device/register` to accept `pushToStartToken` field |
| 252 | +- New logic in `/v1/event`: if `eventType` is the first event for a session and no `activity_registration` exists yet, send a push-to-start payload: |
| 253 | + ```json |
| 254 | + { |
| 255 | + "aps": { |
| 256 | + "timestamp": 1712345678, |
| 257 | + "event": "start", |
| 258 | + "content-state": { |
| 259 | + "status": "working", |
| 260 | + "sessionTitle": "Fix auth bug", |
| 261 | + "lastMessage": "Starting...", |
| 262 | + "retryInfo": null |
| 263 | + }, |
| 264 | + "attributes-type": "SessionActivityAttributes", |
| 265 | + "attributes": { |
| 266 | + "sessionId": "abc123" |
| 267 | + }, |
| 268 | + "alert": { |
| 269 | + "title": "Session Started", |
| 270 | + "body": "OpenCode is working on: Fix auth bug" |
| 271 | + } |
| 272 | + } |
| 273 | + } |
| 274 | + ``` |
| 275 | +
|
| 276 | +### 2c. Server-side: emit session start events |
| 277 | +
|
| 278 | +**File**: `packages/opencode/src/server/push-relay.ts` |
| 279 | +
|
| 280 | +- Add a `"start"` event type |
| 281 | +- Map `session.status` with `type: "busy"` (first time for a session) to `{ type: "start", sessionID }` |
| 282 | +- Include session metadata (title, directory) in the payload so the relay can populate the `attributes` field for push-to-start |
| 283 | +
|
| 284 | +--- |
| 285 | +
|
| 286 | +## Phase 3: Polish and Edge Cases |
| 287 | +
|
| 288 | +- **Deep linking**: When user taps the Live Activity, open the app and navigate to that session (`mobilevoice://session/{id}`) |
| 289 | +- **Multiple activities**: Handle the case where the user starts multiple sessions from different servers. iOS supports multiple concurrent Live Activities. |
| 290 | +- **Activity expiry**: iOS ends Live Activities after 8 hours. Handle the timeout gracefully (end with a "timed out" status). |
| 291 | +- **Token rotation**: Activity push tokens can rotate. The `addPushTokenListener` handles this -- forward new tokens to the relay. |
| 292 | +- **Cleanup**: When the relay receives an APNs error like `InvalidToken` for an activity token, delete the `activity_registration` row. |
| 293 | +- **Stale activities**: On app foreground, check `SessionActivity.getInstances()` to clean up any orphaned activities. |
| 294 | + |
| 295 | +--- |
| 296 | + |
| 297 | +## Changes Per Package Summary |
| 298 | + |
| 299 | +| Package | Files Changed | Files Added | |
| 300 | +| ---------------- | ------------------------------------------------------------------ | -------------------------------------------------------------------- | |
| 301 | +| **mobile-voice** | `app.json`, `package.json`, `use-monitoring.ts`, `relay-client.ts` | `src/widgets/session-activity.tsx`, `src/hooks/use-live-activity.ts` | |
| 302 | +| **apn-relay** | `src/index.ts`, `src/apns.ts`, `src/schema.sql.ts` | (none) | |
| 303 | +| **opencode** | `src/server/push-relay.ts` | (none) | |
| 304 | + |
| 305 | +## Build Requirements |
| 306 | + |
| 307 | +- New EAS dev build required after Phase 1a (native widget extension target) |
| 308 | +- Relay deployment after Phase 1f |
| 309 | +- OpenCode server rebuild after Phase 1g |
| 310 | + |
| 311 | +## Key Technical References |
| 312 | + |
| 313 | +- `expo-widgets` docs: https://docs.expo.dev/versions/latest/sdk/widgets/ |
| 314 | +- `expo-widgets` alpha blog post: https://expo.dev/blog/home-screen-widgets-and-live-activities-in-expo |
| 315 | +- Apple ActivityKit push notifications: https://developer.apple.com/documentation/activitykit/starting-and-updating-live-activities-with-activitykit-push-notifications |
| 316 | +- Existing APN relay: `packages/apn-relay/src/` |
| 317 | +- Existing push-relay (server-side): `packages/opencode/src/server/push-relay.ts` |
| 318 | +- Existing monitoring hook: `packages/mobile-voice/src/hooks/use-monitoring.ts` |
| 319 | +- Existing relay client: `packages/mobile-voice/src/lib/relay-client.ts` |
| 320 | + |
| 321 | +## Limitations / Risks |
| 322 | + |
| 323 | +- **expo-widgets is alpha** (March 2026) -- APIs may break |
| 324 | +- **Images not yet supported** in `@expo/ui` widget components (on Expo's roadmap) |
| 325 | +- **Live Activities have an 8-hour max duration** enforced by iOS |
| 326 | +- **APNs budget**: iOS throttles frequent updates; keep progress pushes to ~1 per 10-15 seconds |
| 327 | +- **NSSupportsLiveActivitiesFrequentUpdates** needed in Info.plist for higher update frequency |
| 328 | +- **Dev builds required** -- adding the widget extension is a native change, OTA won't cover it |
0 commit comments