> ## Documentation Index
> Fetch the complete documentation index at: https://285e39fd5e337e58f16290.sightscreen.app/llms.txt
> Use this file to discover all available pages before exploring further.

# Live Activities

> Live Activity lifecycle, the three-manager architecture, push-to-start, token management, Dynamic Island presentations, and the APNs contract.

## Overview

Live Activities are the core user-facing feature of Sightscreen. When a user follows a live match, the app starts a Live Activity that shows ball-by-ball scoring on the Lock Screen and Dynamic Island. All content is driven by backend APNs pushes — the app renders what it receives.

The Live Activity system is managed by **three dedicated managers**, composed behind a `LiveActivityService` facade.

***

## Three-manager architecture

The original monolithic `LiveActivityService` has been split into three focused managers, each owning a distinct concern.

```mermaid theme={null}
flowchart TB
    LAS[LiveActivityService<br/>facade] --> TM[LiveActivityTokenManager]
    LAS --> LM[LiveActivityLifecycleManager]
    LAS --> FM[FollowManager]

    TM -->|POST /activity-token| Backend
    LM -->|Activity.request<br/>activityUpdates<br/>activityStateUpdates| ActivityKit
    FM -->|POST follow/unfollow<br/>GET /user/follows| Backend
    FM -->|followedMatchIds| UserDefaults
```

### LiveActivityTokenManager

Owns everything related to push tokens — observation, deduplication, and backend registration.

| Responsibility                        | Detail                                                                                          |
| ------------------------------------- | ----------------------------------------------------------------------------------------------- |
| Push-to-start token observation       | Observes `Activity.pushToStartTokenUpdates` and deduplicates double-fire events from the system |
| Per-activity update token observation | Observes `pushTokenUpdates` on each activity instance                                           |
| POST tokens to backend                | Calls `POST /matches/:matchId/activity-token` whenever a new or rotated token is received       |
| Token caching                         | Caches the last sent token per match to avoid redundant POSTs                                   |

### LiveActivityLifecycleManager

Owns the creation, adoption, observation, and teardown of Live Activity instances.

| Responsibility         | Detail                                                                                                               |
| ---------------------- | -------------------------------------------------------------------------------------------------------------------- |
| Local creation         | Calls `Activity.request()` with `pushType: .token` for user-initiated follows                                        |
| Adopt push-started LAs | Observes `Activity.activityUpdates` to claim activities the system created via push-to-start                         |
| Dismiss observation    | Observes `activityStateUpdates` — when state becomes `.dismissed`, calls `POST /matches/:matchId/activity-dismissed` |
| Restore on relaunch    | Re-attaches observers to all existing activities after a cold start                                                  |
| Stale check            | If no content update arrives for 30 minutes, ends the activity                                                       |
| Content observation    | Watches `contentState` updates on each activity for rendering                                                        |

### FollowManager

Owns the user's follow/unfollow intent and syncs it with the backend.

| Responsibility              | Detail                                                                                                              |
| --------------------------- | ------------------------------------------------------------------------------------------------------------------- |
| Follow / unfollow API calls | POSTs to the backend when the user taps the bell icon                                                               |
| Local persistence           | Stores `followedMatchIds` in UserDefaults for immediate UI state                                                    |
| Retry pending unfollows     | Queues failed unfollows and retries on next opportunity                                                             |
| Bell icon states            | Drives the three bell states: empty (not following), filled (following), filled + warning (following but LA failed) |
| Sync on launch              | Calls `GET /user/follows` to reconcile local state with the server                                                  |

<Warning>
  The three managers share no mutable state directly. `LiveActivityService` (the facade) coordinates between them. If you need to change how managers interact, modify the facade — not the managers themselves.
</Warning>

***

## Live Activity lifecycle

```mermaid theme={null}
stateDiagram-v2
    [*] --> Started: User follows (local)<br/>or push-to-start (remote)

    Started --> Active: System grants activity

    state Active {
        [*] --> Receiving
        Receiving --> Receiving: Content-state push update
    }

    Active --> Ended_Unfollow: User unfollows
    Active --> Ended_MatchOver: Backend sends dismissal-date
    Active --> Ended_Stale: No update for 30 min
    Active --> Ended_Error: ActivityKit error

    Ended_Unfollow --> Dismissed: User swipes / 4h timeout
    Ended_MatchOver --> Dismissed: User swipes / 4h timeout
    Ended_Stale --> Dismissed: User swipes / 4h timeout
    Ended_Error --> Dismissed: User swipes / 4h timeout

    Dismissed --> [*]
```

### Start routes

There are two ways a Live Activity can start:

<Steps>
  <Step title="User follow (local creation)">
    User taps the bell icon. `FollowManager` records the follow. `LifecycleManager` calls `Activity.request()` with `pushType: .token`. `TokenManager` begins observing the update token and POSTs it to the backend.
  </Step>

  <Step title="Push-to-start (remote creation)">
    Backend sends a push-to-start APNs notification. The system creates the Live Activity without the app running. On next launch (or in background), `LifecycleManager.adoptPushStartedActivity()` claims the activity via `Activity.activityUpdates`. `TokenManager` then observes and registers the update token.
  </Step>
</Steps>

### End reasons

A Live Activity can end for four reasons:

| Reason            | Triggered by                                                                                             |
| ----------------- | -------------------------------------------------------------------------------------------------------- |
| User unfollow     | User taps bell icon again. `FollowManager` calls unfollow API, `LifecycleManager` calls `Activity.end()` |
| Match over        | Backend sends APNs push with `dismissal-date`. Activity transitions to ended state                       |
| Stale timeout     | `LifecycleManager` detects no content update for 30 minutes, calls `Activity.end()`                      |
| ActivityKit error | System terminates the activity (e.g., resource limits exceeded)                                          |

***

## Token management

### Three token types

| Token               | Source                                                 | Purpose                                                                                 |
| ------------------- | ------------------------------------------------------ | --------------------------------------------------------------------------------------- |
| Push-to-start token | `Activity.pushToStartTokenUpdates` (type-level stream) | Allows backend to start a Live Activity remotely                                        |
| Update token        | `activity.pushTokenUpdates` (per-instance stream)      | Allows backend to update an existing Live Activity via APNs                             |
| Device token        | `UIApplication` delegate                               | Standard push notification token (managed by `NotificationService`, not `TokenManager`) |

### Token rotation

iOS can rotate tokens at any time. `TokenManager` handles this by:

1. Continuously observing the async stream (not reading a snapshot)
2. Comparing each received token against the cached last-sent value
3. Only POSTing to backend when the token actually changes

```swift theme={null}
// Observing update tokens — TokenManager pattern
for await tokenData in activity.pushTokenUpdates {
    let token = tokenData.hexString
    guard token != lastSentTokens[matchId] else { continue } // deduplicate
    try await api.post("/matches/\(matchId)/activity-token", body: ["token": token])
    lastSentTokens[matchId] = token
}
```

### Push-to-start token deduplication

The system may fire `pushToStartTokenUpdates` twice for the same token value. `TokenManager` deduplicates by caching and comparing before sending.

<Warning>
  Never try to read `activity.pushToken` synchronously after creation. The token is not available immediately. Always use the `pushTokenUpdates` async stream.
</Warning>

***

## Push-to-start flow

```mermaid theme={null}
sequenceDiagram
    participant Backend
    participant APNs
    participant System as iOS System
    participant LM as LifecycleManager
    participant TM as TokenManager

    Note over Backend: User pre-followed a match
    Backend->>APNs: push-to-start payload
    APNs->>System: Create Live Activity
    System-->>LM: activityUpdates stream fires
    LM->>LM: adoptPushStartedActivity()
    LM->>TM: Start observing update token
    TM-->>TM: pushTokenUpdates fires
    TM->>Backend: POST /matches/:matchId/activity-token
    Backend->>APNs: Content-state push update
    APNs->>System: Update Live Activity
```

<Steps>
  <Step title="Pre-follow">
    The user follows a match before it goes live. `FollowManager` records the follow and `TokenManager` registers the push-to-start token with the backend.
  </Step>

  <Step title="Backend triggers start">
    When the match goes live, the backend sends a push-to-start APNs payload using the registered push-to-start token.
  </Step>

  <Step title="System creates activity">
    iOS creates the Live Activity in the background. The app does not need to be running.
  </Step>

  <Step title="Adoption">
    `LifecycleManager` detects the new activity via `Activity.activityUpdates` and calls `adoptPushStartedActivity()` to begin managing it.
  </Step>

  <Step title="Token registration">
    `TokenManager` observes the update token for the adopted activity and POSTs it to the backend, enabling subsequent content updates.
  </Step>
</Steps>

<Tip>
  Push-to-start allows the backend to launch a Live Activity even if the user has not opened the app since following the match. This is the primary start path for pre-followed matches.
</Tip>

***

## Stale activity cleanup

If a Live Activity receives no content-state push for **30 minutes**, `LifecycleManager` considers it stale and ends it. This prevents users from seeing outdated scores (e.g., during a backend outage or if the match is abandoned).

The stale check runs:

* On app foreground (via `ScenePhase` observation)
* When restoring activities on relaunch
* Periodically while the app is active

<Note>
  The 30-minute threshold is a local safety net. The backend should always send an explicit end push when a match completes. Stale detection catches edge cases where that push was lost.
</Note>

***

## Required presentations

Every Live Activity must implement **6 mandatory presentations**. Missing any of these causes a build-time or App Store review rejection.

| Presentation           | Where it appears                                                         |
| ---------------------- | ------------------------------------------------------------------------ |
| Compact Leading        | Dynamic Island — left side of the TrueDepth cutout                       |
| Compact Trailing       | Dynamic Island — right side of the TrueDepth cutout                      |
| Minimal                | Dynamic Island — small circular view when multiple activities are active |
| Expanded               | Dynamic Island — long-press or primary activity view                     |
| Lock Screen            | Lock Screen banner below the clock                                       |
| Watch/CarPlay `.small` | Apple Watch and CarPlay                                                  |

<Note>
  The compact leading and trailing views appear together when only one Live Activity is running. If multiple are active, iOS picks one for the Minimal presentation and shows the other in Compact.
</Note>

***

## Content state contract

The backend sends APNs pushes that update the Live Activity content state. The iOS app decodes and renders — no transformation.

| Requirement      | Detail                                       |
| ---------------- | -------------------------------------------- |
| Key format       | camelCase (matches Swift `Codable` defaults) |
| Max payload size | Under 4KB (APNs hard limit)                  |
| Required fields  | `timestamp`, `event`, `content-state`        |
| APNs push type   | `liveactivity` header for updates            |

```json theme={null}
{
  "aps": {
    "timestamp": 1711234567,
    "event": "update",
    "content-state": {
      "matchDisplayState": "live",
      "compactLeading": { "teamAbbr": "MI", "score": "87/6" },
      "compactTrailing": { "overs": "12.3", "runRate": "6.96" },
      "expanded": { /* full expanded display state */ }
    },
    "alert": {
      "title": "Wicket!",
      "body": "Rohit Sharma c Kohli b Bumrah 45(32)"
    }
  }
}
```

### APNs push types

| Push type       | Purpose                                                   |
| --------------- | --------------------------------------------------------- |
| `liveactivity`  | Update or end an existing Live Activity                   |
| `push-to-start` | Start a Live Activity remotely without the app being open |

### Key event handling

Certain events (wickets, milestones) are assigned **priority 10** (`relevance-score: 10`) in the APNs push. This ensures iOS gives them prominence in the Dynamic Island with haptic feedback and expanded presentation.

***

## API endpoints

| Endpoint                               | Method | Purpose                                       |
| -------------------------------------- | ------ | --------------------------------------------- |
| `/matches/:matchId/activity-token`     | POST   | Register an update token for a Live Activity  |
| `/matches/:matchId/activity-dismissed` | POST   | Notify backend that the user dismissed the LA |
| `/user/follows`                        | GET    | Sync followed matches on launch               |
| `/matches/:matchId/follow`             | POST   | Follow a match                                |
| `/matches/:matchId/unfollow`           | POST   | Unfollow a match                              |

***

## State management summary

| State               | Owner            | Storage      | Purpose                                     |
| ------------------- | ---------------- | ------------ | ------------------------------------------- |
| `followedMatchIds`  | FollowManager    | UserDefaults | Which matches the user is following         |
| `lastSentTokens`    | TokenManager     | In-memory    | Deduplication cache for token POSTs         |
| Active activity map | LifecycleManager | In-memory    | Maps match IDs to `Activity` instances      |
| Pending unfollows   | FollowManager    | In-memory    | Queues failed unfollows for retry           |
| Bell icon state     | FollowManager    | Computed     | Derived from follow state + activity health |
