Skip to content

feat(ios-client): expose Live Activity push state getters and stop duplicate interaction delivery - #353

Merged
V3RON merged 6 commits into
mainfrom
feat/ios-live-activity-push-state-getters
Oct 16, 2026
Merged

V3RON merged 6 commits into
mainfrom
feat/ios-live-activity-push-state-getters

Conversation

@V3RON

@V3RON V3RON commented Oct 15, 2026 •

Copy link
Copy Markdown
Contributor

What is this?

iOS apps can now read their Live Activity push state whenever they need it, instead of only catching it as a one-off event. getLiveActivityPushTokens() returns the push token of every running Live Activity (static and Dynamic), getLiveActivityPushToStartToken() returns the push-to-start token, and areLiveActivitiesEnabled() says whether the user allows Live Activities. A new activityEnablementChanged event fires when the user flips that setting.

It also fixes how button and toggle taps (interaction events) reach JavaScript. A tap the app had already handled was replayed on the next launch or Metro reload, and a tap that launched the app could be replayed before any listener was attached and lost.

How does it work?

Tokens and enablement are treated as state. The getters read ActivityKit at call time, so they return current values, and the existing token events plus the new enablement event report changes. The recommended pattern is to subscribe first and then call the getter, with the backend upserting tokens, since the same token can arrive from both. The token getters return nothing when enablePushNotifications is off; the enablement API works regardless.

Interactions are treated as events that JavaScript pulls. Every tap is recorded in a small queue in the App Group (in the app's memory when there is no App Group), and native code only signals that taps are available. JavaScript takes them with a synchronous call that removes and returns them in one step — when a listener attaches, on the signal, and when the app becomes active — and hands them to every listener attached at that moment. A tap is therefore never delivered twice, and taps made while the app was killed or suspended arrive once the app runs with a listener. Taps carry a unique id and wait at most 15 minutes (the newest 100 are kept). A VoltraView only takes taps from its own content, and taps whose listeners went away mid-dispatch are put back. The UserDefaults queue from earlier releases is discarded on upgrade, since it contains taps that were already handled.

The design, the alternatives that were rejected (replaying the last token on subscribe, queueing tokens, and a first version that delivered taps hot without persisting them), and the remaining edge cases — including the at-most-once window if the app dies mid-dispatch — are recorded in ADR 0011. Android is unaffected: it sends no native events and Voltra owns no push tokens there.

Why is this useful?

A backend that needs a Live Activity's token no longer depends on JavaScript subscribing before ActivityKit hands the token over. An app can register tokens after sign-in or after a reload, and can hide Live Activity features when the user has turned them off. Apps that handle taps no longer see a tap twice, and taps made while the app was in the background or not running are no longer lost.

@V3RON V3RON changed the title feat(ios-client): expose Live Activity push state getters and drain interactions only to listeners feat(ios-client): expose Live Activity push state getters and deliver each interaction once Oct 15, 2026
@V3RON V3RON changed the title feat(ios-client): expose Live Activity push state getters and deliver each interaction once feat(ios-client): expose Live Activity push state getters and stop duplicate interaction delivery Oct 15, 2026
@V3RON
V3RON marked this pull request as ready for review October 15, 2026 12:47
@V3RON

V3RON commented Oct 16, 2026

Copy link
Copy Markdown
Contributor Author

Physical device test: taps while JavaScript is suspended

The simulator runs couldn't cover a suspended app, because the simulator keeps JavaScript running in the background. So I repeated the test on a physical iPhone 17 Pro (iOS 27.0) with a Release build of the example app at 6a56aeb7. The only additions were a local probe screen, which isn't part of this PR, and the dev-only Appduct tools removed so the Release build launches. agent-device drove the phone and tapped the Live Activity buttons in Notification Center.

The probe is one 'interaction' listener at the app root. It records every tap it receives with:

  • the tap's id and when the tap happened,
  • how long it waited before JavaScript received it,
  • the app state at that moment.

The log survives the app being killed. A 1 s heartbeat records any gap in which JavaScript didn't run, which shows when iOS suspended it.

Scenario Taps Result
App in the background, JavaScript suspended for 81.8 s A, then B 33 s later Each delivered once, 0 s after the tap, with the app in the background
JavaScript suspended for 198.4 s, three taps within 10 s A, B, A All three delivered once, in tap order, each 0 s after its tap
App force-killed A iOS started the app in the background, JavaScript started, and the tap was delivered once, 0 s after it was made
Kill and relaunch, then background for 30 s and reopen none Nothing delivered again: still 6 taps, 6 unique ids
Notification Center opened over the app (the closest to a foreground tap a Live Activity allows) B Delivered once, 0 s after the tap, with the app in the background

Across all scenarios: 7 taps made, 7 received, no duplicates, and every tap arrived in the order it was made.

Sample from the 198 s suspension:

09:02:35 [0uu9] JS SUSPENDED 198.4s (now background)
09:02:35 [0uu9] TAP probe-a tapped=09:02:35 delay=0s state=background id=F60B870E
09:02:40 [0uu9] TAP probe-b tapped=09:02:40 delay=0s state=background id=C377BCFC
09:02:45 [0uu9] TAP probe-a tapped=09:02:45 delay=0s state=background id=B315153B

What this means

  • On a real device, a suspended app receives Live Activity taps immediately. The button's intent runs in the app's own process, so iOS resumes a suspended app, or starts a killed one, to handle the tap. While the app is awake, the native side signals JavaScript, and JavaScript takes the tap at once. This matches the ADR: taps are pulled "on the signal if the process is awake".
  • The pull when the app becomes active never ran. No tap in these runs had to wait for it. It stays as the safety net for a tap JavaScript can't take during that wake-up window.
  • App code should expect 'interaction' listeners to run in the background, with only a few seconds before iOS suspends the app again. The heartbeat shows JavaScript suspended again within about 5 s between taps. A handler doing slow async work, such as a network call, may be paused partway through until the app next runs.

Not covered

  • Taps recorded by another process, such as a widget extension.
  • Locking the device with a passcode.
  • Only one device and iOS version.

@V3RON
V3RON merged commit 7c1405c into main Oct 16, 2026
16 checks passed
@V3RON
V3RON deleted the feat/ios-live-activity-push-state-getters branch October 16, 2026 07:55
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