Skip to content

feat(ios): render session live activity on lock screen and dynamic island - #421

Open
RonenMars wants to merge 3 commits into
feat/live-activity-contractfrom
feat/live-activity-ios-render
Open

feat(ios): render session live activity on lock screen and dynamic island#421
RonenMars wants to merge 3 commits into
feat/live-activity-contractfrom
feat/live-activity-ios-render

Conversation

@RonenMars

@RonenMars RonenMars commented Jul 24, 2026

Copy link
Copy Markdown
Owner

Stacked on #420.

Adds the widget layout and the impure half of the reconciler, so a running session raises a Live Activity and a finished one clears it.

The widget runs in a different world than the rest of the app

The layout carries the 'widget' directive, which babel-preset-expo serializes into a source string evaluated inside the extension's own bundle.
That bundle injects @expo/ui/swift-ui and its modifiers as globals and nothing else, so the function cannot close over imports, module constants, or shared helpers — anything it references by closure is simply undefined at runtime.

Two consequences that look like mistakes but are not:

  • Colors are inlined literals rather than constants/theme reads. They are the dark / light palette's status.running, status.waiting, text.primary, text.secondary, kept in sync by hand.
  • Icons are SF Symbols, not Phosphor. Phosphor is a React Native view library and there is no RN renderer in that process.

The imports at the top of the file exist purely to type-check the JSX; without them Text and Image resolve to the DOM globals and typecheck silently wrong.

Elapsed time renders through SwiftUI's timerInterval seeded from startedAt, so the OS ticks it natively — the timer keeps moving with the app force-quit.

Only banner and the two compact Island slots are styled.
minimal and the expanded regions fall back to system defaults, which is intended v1 scope, not an oversight — the plugin renders EmptyView() for unimplemented sections and the system fills in.

A real bug the tests caught

Eviction ordering now uses a monotonic counter rather than Date.now().
Several session_update frames routinely land inside the same millisecond, and the resulting timestamp ties made reduce pick the first element instead of the least recently updated one — so the wrong activity was evicted at the cap.

Wiring

Reconciliation hangs off the existing session_update handler in the [activeServerIds] effect — the one place every status change passes through with its serverId already stamped.
Attaching a second onAll elsewhere would silently miss servers connected later, since onAll only iterates clients that exist when it is called (its docstring claims otherwise; noted, not fixed here).

services/live-activity.web.ts no-ops the same entry points so Metro's platform resolution keeps the web bundle away from expo-widgets.

The jest mock targets the widget module rather than expo-widgets, because the 'widget' directive only resolves under Babel's widget transform — under Jest the JSX would reference undefined SwiftUI globals.

Known limitation — Phase 1a ships silent expiry

staleDate is not set, which breaks Decision 2 of the runbook.
That decision leaned on staleDate as Phase 1a's honesty mechanism: the thing that greys a surface out at Apple's ~8h ceiling instead of letting it vanish.

It cannot be set. expo-widgets hardcodes staleDate: nil in ios/LiveActivity.swift:23,35 and ios/LiveActivityFactory.swift:30, with no JS parameter to override it.

The user-visible consequence: a Live Activity for a session still running at ~8h disappears with no explanation, which reads as "the session ended".
That is accepted behavior for 1a, not a defect to file.

Deliberately not patching expo-widgets or building a workaround.
Phase 1b's streamer-side APNs renewal removes the 8h ceiling outright and makes the question moot.

The same limitation is recorded in a comment at the start() call in services/live-activity.ts, so anyone debugging a vanished surface finds it in the code rather than only here.

Verification

21 unit + integration tests green, including start/update/end, cap-3 eviction, and per-server keying.
Full iOS build succeeds with the extension embedded. Device verification of the rendered surfaces is still pending.


CI is red on this branch, and it was red before this work

Type check, i18n, and Integration tests fail here. None of those failures come from this stack.

Measured on a clean worktree at integration-merge-354-355-376 (f2242bc4) with no changes applied:

  • Type check — 3 errors: makeSearchStyles in app/conversation/[id].tsx:67, plus two i18n key-signature errors in components/onboarding/steps/ConnectStep.tsx:196,197.
  • i18n — 3 failures in 1 suite.
  • Integration tests — 4 suites / 34 tests, all __tests__/integration/conversation-*.

CI on this branch reports the identical Test Suites: 4 failed, 36 passed / Tests: 34 failed, 211 passed, and tsc reports the same 3 errors and no others.
Independently confirmed by the repo owner against their own clean worktree.

These are deliberately left alone — fixing them is unrelated to Live Activities and belongs in its own PR.

…land

Adds the widget layout and the impure half of the reconciler, so a running session raises a Live Activity and a finished one clears it.

The layout carries the 'widget' directive, which Babel serializes into a source string evaluated inside the extension's own bundle.
That bundle injects @expo/ui/swift-ui and its modifiers as globals and nothing else, so the function cannot close over imports, module constants, or shared helpers.
Colors are therefore inlined copies of the dark/light palette rather than constants/theme reads, and icons are SF Symbols rather than Phosphor, which is a React Native view library with no renderer in that process.
The imports at the top of the file exist only to type-check the JSX.

Elapsed time renders through SwiftUI's timerInterval seeded from startedAt, so the OS ticks it natively and no update is needed to keep it moving.
Only banner and the two compact Island slots are styled: minimal and the expanded regions fall back to system defaults, which is the intended v1 scope, not an oversight.

Eviction order uses a monotonic counter rather than Date.now().
Several session_update frames routinely land inside the same millisecond, and the resulting ties made LRU pick an arbitrary victim instead of the least recently updated one.

Reconciliation hangs off the existing session_update handler in the [activeServerIds] effect, the one place every status change passes through with its serverId already stamped.
Attaching a second onAll elsewhere would silently miss servers connected later, since onAll only iterates clients that exist when it is called.

services/live-activity.web.ts no-ops the same entry points so Metro's platform resolution keeps the web bundle away from expo-widgets.
The jest mock targets the widget module rather than expo-widgets, because the 'widget' directive only resolves under Babel's widget transform.
A Live Activity vanishes at Apple's ~8h ceiling while the session may still be running, which reads to the user as "the session ended".
The PR body said so, but someone debugging a disappearing surface reads the code, not the PR, and would reasonably file it as a bug here.

Notes at the start() call that expo-widgets hardcodes staleDate: nil with no JS parameter, so the intended grey-out mitigation cannot be set from this file at all, and that Phase 1b's APNs renewal removes the ceiling rather than patching around it.
@RonenMars
RonenMars force-pushed the feat/live-activity-contract branch from e60be12 to 2f763ed Compare July 25, 2026 21:22
@RonenMars
RonenMars force-pushed the feat/live-activity-ios-render branch from 02ee981 to d21f19d Compare July 25, 2026 21:22
adoptRunningActivities was defined but never called, so it was dead code and the case it exists for went unhandled.

Live Activities outlive the JS context. After an app restart mid-session, any surface still on screen is untracked: it cannot be matched to a session, updated, or ended, so it sits frozen on whatever it last showed.
The reconciler would then start a second surface for the same session, against a cap of three.

Calls it once on mount, before the first session_update can arrive.
Mount-only rather than keyed on activeServerIds — re-running on a server change would tear down surfaces this same session had just raised.

Covers both the orphan case and the regression it invites: a session started after adoption must not be ended by it.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant