feat(ios): render session live activity on lock screen and dynamic island - #421
Open
RonenMars wants to merge 3 commits into
Open
feat(ios): render session live activity on lock screen and dynamic island#421RonenMars wants to merge 3 commits into
RonenMars wants to merge 3 commits into
Conversation
…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
force-pushed
the
feat/live-activity-contract
branch
from
July 25, 2026 21:22
e60be12 to
2f763ed
Compare
RonenMars
force-pushed
the
feat/live-activity-ios-render
branch
from
July 25, 2026 21:22
02ee981 to
d21f19d
Compare
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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, whichbabel-preset-exposerializes into a source string evaluated inside the extension's own bundle.That bundle injects
@expo/ui/swift-uiand 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:
constants/themereads. They are thedark/lightpalette'sstatus.running,status.waiting,text.primary,text.secondary, kept in sync by hand.The imports at the top of the file exist purely to type-check the JSX; without them
TextandImageresolve to the DOM globals and typecheck silently wrong.Elapsed time renders through SwiftUI's
timerIntervalseeded fromstartedAt, so the OS ticks it natively — the timer keeps moving with the app force-quit.Only
bannerand the two compact Island slots are styled.minimaland the expanded regions fall back to system defaults, which is intended v1 scope, not an oversight — the plugin rendersEmptyView()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_updateframes routinely land inside the same millisecond, and the resulting timestamp ties madereducepick 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_updatehandler in the[activeServerIds]effect — the one place every status change passes through with itsserverIdalready stamped.Attaching a second
onAllelsewhere would silently miss servers connected later, sinceonAllonly iterates clients that exist when it is called (its docstring claims otherwise; noted, not fixed here).services/live-activity.web.tsno-ops the same entry points so Metro's platform resolution keeps the web bundle away fromexpo-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
staleDateis not set, which breaks Decision 2 of the runbook.That decision leaned on
staleDateas 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-widgetshardcodesstaleDate: nilinios/LiveActivity.swift:23,35andios/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-widgetsor 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 inservices/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, andIntegration testsfail 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:makeSearchStylesinapp/conversation/[id].tsx:67, plus two i18n key-signature errors incomponents/onboarding/steps/ConnectStep.tsx:196,197.__tests__/integration/conversation-*.CI on this branch reports the identical
Test Suites: 4 failed, 36 passed/Tests: 34 failed, 211 passed, andtscreports 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.