Skip to content

[ios][widgets] Fix Live Activity getInstances() filtering and start URL scoping - #48489

Merged
jakex7 merged 5 commits into
expo:mainfrom
huextrat:@huextrat/fix-live-activity-instances-and-url
Aug 7, 2026
Merged

jakex7 merged 5 commits into
expo:mainfrom
huextrat:@huextrat/fix-live-activity-instances-and-url

Conversation

@huextrat

@huextrat huextrat commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Why

Fixes three related bugs in the expo-widgets Live Activity plumbing:

How

getInstances() now filters Activity<LiveActivityAttributes>.activities by the persisted content.state.name and keeps only .active/.stale activities — a stale activity is still visible and updatable, while ended/dismissed ones are not. The availability guard moves from iOS 16.1 to 16.2 because Activity.content requires it; start() already required 16.2, so no activity created by this module can exist below that.

The start URL moves from WidgetsStorage into LiveActivityAttributes. Attributes are fixed at Activity.request and never rewritten, which is exactly the semantics of a per-start() deep link: scoped to one activity, unaffected by content updates, nothing to clear. .widgetURL(context.attributes.url) is applied in both the Lock Screen banner closure and the Dynamic Island builder (previously only the island had it). The now-unused getLiveActivityUrl helper and __expo_widgets_live_activity_<name>_url storage key are removed.

The new attribute is optional, so existing activities started by a previous version (attributes decode as {}) and push-to-start payloads that don't include it keep working; they simply carry no URL.

Notes for reviewers — small deliberate behavior changes:

  • On iOS 16.1 exactly, getInstances() now throws LiveActivitiesNotSupportedException instead of returning [] (consistent with start() on that version; no activity can exist there anyway).
  • Ended-but-still-visible activities are no longer enumerable through getInstances(), matching its documented "currently active instances" contract. Exposing id/activityState on NativeLiveActivity so cleanup paths can enumerate them (the secondary ask in [expo-widgets] LiveActivityFactory.getInstances() returns live activities belonging to other factories, relabelled with the caller's name #48396) is left for a follow-up PR.
  • An activity started before an app update loses its deep link after the update (its attributes were encoded without a URL, and the storage key is no longer read). Transient by nature — Live Activities live at most ~12 h.

Test Plan

  • et check-packages expo-widgets passes (build, typecheck, lint, format).
  • To reproduce, use the repro app from the issues: https://github.com/MarianoFacundoArch/expo-widgets-getinstances-repro (scenarios 1–3 in its README, [REPRO]-prefixed logs in Metro):
    • Scenario 1: after factoryA.start(), factoryA.getInstances().length == 1 and factoryB.getInstances().length == 0; factoryB.getInstances()[0].update() no longer overwrites activity A.
    • Scenario 2: with factory.start(props, 'myapp://somewhere'), tapping the Lock Screen banner now opens the app at myapp://somewhere.
    • Scenario 3: after end('immediate') followed by start(props) without a URL, the new activity carries no URL.

Checklist

  • I added a changelog.md entry and rebuilt the package sources according to this short guide
  • This diff will work correctly for npx expo prebuild & EAS Build (eg: updated a module plugin). — no plugin/prebuild changes; iOS runtime code only.
  • Conforms with the Documentation Writing Style Guide

🤖 Generated with Claude Code

huextrat and others added 3 commits August 4, 2026 11:33
… factories' activities

getInstances() returned every live activity in the app regardless of
which factory it was called on, and stamped the caller's name onto each
wrapper. A later update() through such a wrapper overwrote an unrelated
activity's content. It also returned activities already ended or
dismissed by the system, which cannot be updated.

Filter by the persisted content.state.name and keep only active/stale
activities (stale ones are still visible and updatable). The guard moves
from iOS 16.1 to 16.2 because Activity.content requires it; start()
already requires 16.2, so no activity can exist below that.

Fixes expo#48396

Co-Authored-By: Claude Fable 5 <[email protected]>
…deliver it to the banner

The URL passed to LiveActivityFactory.start(props, url) was stored in
WidgetsStorage under a key derived from the factory name: written only
when non-nil, never cleared, and shared by every activity started under
that name. A later start() without a URL silently inherited the previous
one. On top of that, .widgetURL() was only applied to the Dynamic Island
builder, so the Lock Screen banner never received the URL at all and
tapping it could not deep-link into the app.

Store the URL in LiveActivityAttributes instead. Attributes are fixed at
Activity.request and never rewritten, which is exactly the semantics of
a start() URL: scoped to one activity, unaffected by content updates,
nothing to clear. Apply .widgetURL(context.attributes.url) in both the
banner content closure and the Dynamic Island builder, and drop the now
unused WidgetsStorage plumbing (getLiveActivityUrl and the
__expo_widgets_live_activity_<name>_url key).

The new attribute is optional, so activities started by a previous
version (attributes decoded as {}) and push-to-start payloads without it
keep working; they simply carry no URL.

Fixes expo#48400
Fixes expo#48401

Co-Authored-By: Claude Fable 5 <[email protected]>
@expo-bot expo-bot added the contributor: external PR author is not a member of the expo GitHub org label Aug 4, 2026
@github-actions

github-actions Bot commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Subscribed to pull request

File Patterns Mentions
packages/expo-widgets/** @jakex7

Generated by CodeMention

@jakex7

jakex7 commented Aug 7, 2026

Copy link
Copy Markdown
Member

/review

@expo-bot

expo-bot commented Aug 7, 2026

Copy link
Copy Markdown
Collaborator

🤖 AI code review

Decision: Approve with comments

Overall PR risk: Medium. The change modifies existing iOS Live Activity behavior: it moves the deep link URL from app-group storage into LiveActivityAttributes, adds .widgetURL to the Lock Screen banner, filters getInstances() by factory name and activity state, and raises that guard to iOS 16.2 availability. The blast radius is limited to apps that use expo-widgets Live Activities on iOS; Android, TypeScript signatures, and prebuild are untouched, and a revert is direct. The most plausible breakage is the tap URL: activities started by an older build now decode with url == nil and lose their deep link, the always-applied banner modifier can conflict with a widgetURL set inside the layout, and a push-to-start sender now controls the url attribute that reaches widgetURL without any scheme or host check. No critical defect was found, so these three warnings are comments rather than blockers.

🟡 Warning (3)

  • Push-to-start payload can now set the Live Activity tap URL — packages/expo-widgets/ios/Widgets/WidgetLiveActivity.swift:41 (security) · id:1b6ea5ef1095
    Confidence: Medium — the data path is direct, but it needs an app that uses push-to-start.
    Impact if shipped: Medium — a push sender can choose the URL that opens when the user taps the Live Activity.
    Suggested remediation: Validate the URL before you pass it to widgetURL. Accept only the app's own URL schemes, or the app's universal link hosts, and drop everything else. A small helper in Utils.swift can do this check for both the banner and the Dynamic Island.

    Evidence and reasoning

    The PR moves the deep link URL from app-group storage into LiveActivityAttributes. Before the change, getLiveActivityUrl(forName:) read the URL from WidgetsStorage. Only a local start() call wrote that value, so the URL was always chosen on the device.

    LiveActivityAttributes is also the attributes type for push-to-start. This module supports push-to-start: WidgetsModule.swift:136 reads Activity<LiveActivityAttributes>.pushToStartToken and sends the token to JavaScript. In a push-to-start APNs payload, the sender supplies the attributes object, and ActivityKit decodes it into LiveActivityAttributes. The new url property is a stored property of that struct, so the payload now controls it.

    Both .widgetURL(...) calls pass that value straight to the system with no check on the scheme or the host. URL.init(string:) accepts any scheme.

    Attacker: any party that can send a push-to-start notification to the app. This includes a compromised app backend and a third-party push provider. Input they control: the url field inside attributes. Consequence: the user taps the Live Activity on the Lock Screen, and the system opens an attacker-chosen target. That target is either an external page that looks like part of the app, or an arbitrary in-app deep link route.

    The same string also reaches the Dynamic Island widgetURL at line 68.

  • Live Activities started before the app update lose their deep link URL — packages/expo-widgets/ios/Widgets/WidgetLiveActivity.swift:68 (correctness) · id:2ea37b68b433
    Confidence: High — the diff deletes the only reader of the stored URL and adds no fallback for activities whose attributes predate the new field.
    Impact if shipped: Medium — a Live Activity that is running during an app update stops deep linking, and taps open the app's default route.
    Suggested remediation: Keep getLiveActivityUrl(forName:) for one release and use it as a fallback when context.attributes.url is nil, for example context.attributes.url.flatMap(URL.init(string:)) ?? getLiveActivityUrl(forName: context.state.name).

    Evidence and reasoning

    The PR moves the deep link URL from App Group storage into LiveActivityAttributes.url. Two places change together:

    1. LiveActivityFactory.start no longer writes __expo_widgets_live_activity_<name>_url (packages/expo-widgets/ios/LiveActivityFactory.swift).
    2. getLiveActivityUrl(forName:) is deleted from packages/expo-widgets/ios/Widgets/Utils.swift, and the widget extension now reads context.attributes.url.

    ActivityKit persists the ActivityAttributes value that existed when the activity started. A Live Activity that the previous app version started has no url key in its stored attributes. url is optional, so the synthesized decoder returns nil and the activity still appears in Activity<LiveActivityAttributes>.activities. But context.attributes.url is then nil, so .widgetURL(nil) applies.

    Before this PR the same activity still deep linked, because the widget extension read the URL from App Group storage by factory name. That reader is gone. A Live Activity can run for many hours, so an app update during an active delivery or ride removes the deep link for the rest of that activity.

    The Dynamic Island path at line 68 is the concrete regression: it worked through the storage lookup before, and now returns nil for every pre-update activity.

  • Lock Screen banner always applies widgetURL, so it can override a layout widgetURL — packages/expo-widgets/ios/Widgets/WidgetLiveActivity.swift:41 (correctness) · id:2ea37b68b433
    Confidence: Medium — the conflict path is traced through the renderer, but SwiftUI does not define which of two widgetURL modifiers wins.
    Impact if shipped: Medium — a Live Activity started without a URL can lose the deep link that its own layout sets.
    Suggested remediation: Apply the modifier only when the activity has a URL. For example, branch in the ViewBuilder: if let url = context.attributes.url.flatMap(URL.init(string:)) { LiveActivityBannerView(context: context, nodes: nodes).widgetURL(url) } else { LiveActivityBannerView(context: context, nodes: nodes) }.

    Evidence and reasoning

    The diff adds .widgetURL(...) to the Lock Screen banner. The modifier runs on every render, also when context.attributes.url is nil, because flatMap gives nil.

    A layout author can already set a URL from JavaScript. packages/expo-widgets/bundle/ui-globals.ts re-exports @expo/ui/swift-ui/modifiers, which exports widgetURL. ViewModifierRegistry.swift:2167 registers it, and WidgetsDynamicView renders each node through UIBaseView, which applies the props modifiers. So a banner node can carry its own widgetURL.

    This repo's own documentation for that modifier states the rule, at packages/expo-ui/src/swift-ui/modifiers/widgets.ts:15: "Widgets support one widgetURL modifier in their view hierarchy. If multiple views have widgetURL modifiers, the behavior is undefined."

    Before this change the banner closure had no widgetURL, so the layout modifier was the only one. After this change there are two, and the new one sits above the layout. An app that calls start(props) with no URL and sets the link inside the layout can stop deep linking from the Lock Screen.


This review is advisory — it never blocks a merge and never auto-approves.

Layouts can already set their own deep link through the widgetURL
modifier from @expo/ui, and a view hierarchy with more than one
widgetURL modifier is undefined behavior per the modifier's own
documentation. Applying .widgetURL(nil) unconditionally above the
layout could therefore override a URL the layout sets.

Branch on the attribute instead, in both the Lock Screen banner closure
and the Dynamic Island builder. The island had the always-applied
pattern before this PR too, so this also removes a pre-existing
conflict with layouts that set widgetURL on island sections.

Co-Authored-By: Claude Fable 5 <[email protected]>

@jakex7 jakex7 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for contributing!

@huextrat

huextrat commented Aug 7, 2026

Copy link
Copy Markdown
Contributor Author

Replying to the AI review point by point:

1. Push-to-start payload can now set the Live Activity tap URL — acknowledged, leaving as is. widgetURL never opens the browser: tapping always opens the containing app, and the URL is delivered to the app's own link handler (this resolves the reviewer's stated uncertainty). So the payload gains no more power than any push notification carrying a deep link, and a sender with push-to-start access already controls name and props — the entire rendered content. A scheme/host allowlist in the library would impose policy it can't know (custom schemes, universal-link hosts vary per app), and a server-chosen URL is arguably a feature for push-to-start. Apps must validate incoming URLs in their link handler regardless of source.

2. Live Activities started before the app update lose their deep link — acknowledged (it's also called out in the PR description) and deliberately accepted. One caution about the suggested remediation: the bare fallback context.attributes.url.flatMap(URL.init(string:)) ?? getLiveActivityUrl(forName: context.state.name) would reintroduce #48401 — the legacy __expo_widgets_live_activity_<name>_url keys are never removed, so any new activity started without a URL would silently pick up the stale stored URL through the fallback, which is the exact bug this PR fixes. A correct shim needs the fallback plus unconditional key removal in start(), and a reminder to delete it a release later. Given the affected window — an activity already running at the moment of an app update, for the remainder of its ≤12 h lifetime — the transient regression seems preferable to a stateful migration path. Happy to add the corrected shim if maintainers prefer.

3. Lock Screen banner always applies widgetURL, so it can override a layout widgetURL — good catch, fixed in 028ab24. .widgetURL is now applied only when the activity actually has a URL, on both the Lock Screen banner and the Dynamic Island. The island had the always-applied pattern before this PR too (with the possibly-nil storage lookup), so this also removes a pre-existing conflict with layouts that set widgetURL on island sections.

@huextrat

huextrat commented Aug 7, 2026

Copy link
Copy Markdown
Contributor Author

@jakex7 FYI, point 3 of the review is fixed in 028ab24 (conditional widgetURL on banner + island)

@jakex7
jakex7 merged commit e3fbee9 into expo:main Aug 7, 2026
12 of 13 checks passed
KevinvdBurg pushed a commit to KevinvdBurg/expo that referenced this pull request Aug 11, 2026
…RL scoping (expo#48489)

# Why

Fixes three related bugs in the expo-widgets Live Activity plumbing:

- Closes expo#48396 — `LiveActivityFactory.getInstances()` returns every
live activity in the app regardless of which factory it is called on,
stamps the caller's name onto each wrapper, and also returns activities
that have already ended or been dismissed. With two factories, calling
`update()` through such a wrapper overwrites an unrelated activity's
content.
- Closes expo#48400 — the URL passed to `start(props, url)` is only applied
to the Dynamic Island; the Lock Screen banner never receives it, so
tapping the banner cannot deep-link into the app.
- Closes expo#48401 — the URL is stored in `WidgetsStorage` under a key
derived from the factory name: written only when non-nil, never cleared,
and shared by every activity started under that name. A later `start()`
without a URL silently inherits the previous one.

# How

**`getInstances()`** now filters
`Activity<LiveActivityAttributes>.activities` by the persisted
`content.state.name` and keeps only `.active`/`.stale` activities — a
stale activity is still visible and updatable, while ended/dismissed
ones are not. The availability guard moves from iOS 16.1 to 16.2 because
`Activity.content` requires it; `start()` already required 16.2, so no
activity created by this module can exist below that.

**The start URL** moves from `WidgetsStorage` into
`LiveActivityAttributes`. Attributes are fixed at `Activity.request` and
never rewritten, which is exactly the semantics of a per-`start()` deep
link: scoped to one activity, unaffected by content updates, nothing to
clear. `.widgetURL(context.attributes.url)` is applied in both the Lock
Screen banner closure and the Dynamic Island builder (previously only
the island had it). The now-unused `getLiveActivityUrl` helper and
`__expo_widgets_live_activity_<name>_url` storage key are removed.

The new attribute is optional, so existing activities started by a
previous version (attributes decode as `{}`) and push-to-start payloads
that don't include it keep working; they simply carry no URL.

Notes for reviewers — small deliberate behavior changes:

- On iOS 16.1 exactly, `getInstances()` now throws
`LiveActivitiesNotSupportedException` instead of returning `[]`
(consistent with `start()` on that version; no activity can exist there
anyway).
- Ended-but-still-visible activities are no longer enumerable through
`getInstances()`, matching its documented "currently active instances"
contract. Exposing `id`/`activityState` on `NativeLiveActivity` so
cleanup paths can enumerate them (the secondary ask in expo#48396) is left
for a follow-up PR.
- An activity started before an app update loses its deep link after the
update (its attributes were encoded without a URL, and the storage key
is no longer read). Transient by nature — Live Activities live at most
~12 h.

# Test Plan

- `et check-packages expo-widgets` passes (build, typecheck, lint,
format).
- To reproduce, use the repro app from the issues:
https://github.com/MarianoFacundoArch/expo-widgets-getinstances-repro
(scenarios 1–3 in its README, `[REPRO]`-prefixed logs in Metro):
- Scenario 1: after `factoryA.start()`, `factoryA.getInstances().length
== 1` and `factoryB.getInstances().length == 0`;
`factoryB.getInstances()[0].update()` no longer overwrites activity A.
- Scenario 2: with `factory.start(props, 'myapp://somewhere')`, tapping
the Lock Screen banner now opens the app at `myapp://somewhere`.
- Scenario 3: after `end('immediate')` followed by `start(props)`
without a URL, the new activity carries no URL.

# Checklist

- [x] I added a `changelog.md` entry and rebuilt the package sources
according to [this short
guide](https://github.com/expo/expo/blob/main/CONTRIBUTING.md#-before-submitting)
- [ ] This diff will work correctly for `npx expo prebuild` & EAS Build
(eg: updated a module plugin). — no plugin/prebuild changes; iOS runtime
code only.
- [x] Conforms with the [Documentation Writing Style
Guide](https://github.com/expo/expo/blob/main/guides/Expo%20Documentation%20Writing%20Style%20Guide.md)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Fable 5 <[email protected]>
Co-authored-by: Jakub Grzywacz <[email protected]>
jakex7 added a commit that referenced this pull request Aug 21, 2026
…RL scoping (#48489)

Fixes three related bugs in the expo-widgets Live Activity plumbing:

- Closes #48396 — `LiveActivityFactory.getInstances()` returns every
live activity in the app regardless of which factory it is called on,
stamps the caller's name onto each wrapper, and also returns activities
that have already ended or been dismissed. With two factories, calling
`update()` through such a wrapper overwrites an unrelated activity's
content.
- Closes #48400 — the URL passed to `start(props, url)` is only applied
to the Dynamic Island; the Lock Screen banner never receives it, so
tapping the banner cannot deep-link into the app.
- Closes #48401 — the URL is stored in `WidgetsStorage` under a key
derived from the factory name: written only when non-nil, never cleared,
and shared by every activity started under that name. A later `start()`
without a URL silently inherits the previous one.

**`getInstances()`** now filters
`Activity<LiveActivityAttributes>.activities` by the persisted
`content.state.name` and keeps only `.active`/`.stale` activities — a
stale activity is still visible and updatable, while ended/dismissed
ones are not. The availability guard moves from iOS 16.1 to 16.2 because
`Activity.content` requires it; `start()` already required 16.2, so no
activity created by this module can exist below that.

**The start URL** moves from `WidgetsStorage` into
`LiveActivityAttributes`. Attributes are fixed at `Activity.request` and
never rewritten, which is exactly the semantics of a per-`start()` deep
link: scoped to one activity, unaffected by content updates, nothing to
clear. `.widgetURL(context.attributes.url)` is applied in both the Lock
Screen banner closure and the Dynamic Island builder (previously only
the island had it). The now-unused `getLiveActivityUrl` helper and
`__expo_widgets_live_activity_<name>_url` storage key are removed.

The new attribute is optional, so existing activities started by a
previous version (attributes decode as `{}`) and push-to-start payloads
that don't include it keep working; they simply carry no URL.

Notes for reviewers — small deliberate behavior changes:

- On iOS 16.1 exactly, `getInstances()` now throws
`LiveActivitiesNotSupportedException` instead of returning `[]`
(consistent with `start()` on that version; no activity can exist there
anyway).
- Ended-but-still-visible activities are no longer enumerable through
`getInstances()`, matching its documented "currently active instances"
contract. Exposing `id`/`activityState` on `NativeLiveActivity` so
cleanup paths can enumerate them (the secondary ask in #48396) is left
for a follow-up PR.
- An activity started before an app update loses its deep link after the
update (its attributes were encoded without a URL, and the storage key
is no longer read). Transient by nature — Live Activities live at most
~12 h.

- `et check-packages expo-widgets` passes (build, typecheck, lint,
format).
- To reproduce, use the repro app from the issues:
https://github.com/MarianoFacundoArch/expo-widgets-getinstances-repro
(scenarios 1–3 in its README, `[REPRO]`-prefixed logs in Metro):
- Scenario 1: after `factoryA.start()`, `factoryA.getInstances().length
== 1` and `factoryB.getInstances().length == 0`;
`factoryB.getInstances()[0].update()` no longer overwrites activity A.
- Scenario 2: with `factory.start(props, 'myapp://somewhere')`, tapping
the Lock Screen banner now opens the app at `myapp://somewhere`.
- Scenario 3: after `end('immediate')` followed by `start(props)`
without a URL, the new activity carries no URL.

- [x] I added a `changelog.md` entry and rebuilt the package sources
according to [this short
guide](https://github.com/expo/expo/blob/main/CONTRIBUTING.md#-before-submitting)
- [ ] This diff will work correctly for `npx expo prebuild` & EAS Build
(eg: updated a module plugin). — no plugin/prebuild changes; iOS runtime
code only.
- [x] Conforms with the [Documentation Writing Style
Guide](https://github.com/expo/expo/blob/main/guides/Expo%20Documentation%20Writing%20Style%20Guide.md)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Fable 5 <[email protected]>
Co-authored-by: Jakub Grzywacz <[email protected]>
@alanjhughes alanjhughes added the published Changes from the PR have been published to npm label Aug 24, 2026
jakex7 added a commit that referenced this pull request Nov 9, 2026
## Why

Ended Live Activities can still be visible on the Lock Screen, but
`getInstances()` filters them out. Apps cannot retrieve them to dismiss
them explicitly, as discussed in #51642.

## How

Partially revert #48489: remove the state filter and keep the
factory-name filter. Add `getState()` so apps can choose which
activities to update or dismiss.

Callers that only need active instances must now filter by state. Each
state lookup scans ActivityKit's activities by ID, like `update()` and
`end()`.

## Test Plan



## Screenshots

No UI change.

## Checklist

- [x] I added a changeset and followed [this short
guide](https://github.com/expo/expo/blob/main/CONTRIBUTING.md#-before-submitting)
- [ ] This diff will work correctly for `npx expo prebuild` & EAS Build
(eg: updated a module plugin).
- [x] Conforms with the [Documentation Writing Style
Guide](https://github.com/expo/expo/blob/main/guides/Expo%20Documentation%20Writing%20Style%20Guide.md)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

contributor: external PR author is not a member of the expo GitHub org published Changes from the PR have been published to npm

Projects

None yet

4 participants