Skip to content

fix: [SDK-5580] end duplicate Live Activities and keep the update token - #1780

Open
nan-li wants to merge 3 commits into
mainfrom
nan/sdk-5580
Open

nan-li wants to merge 3 commits into
mainfrom
nan/sdk-5580

Conversation

@nan-li

@nan-li nan-li commented Oct 20, 2026 •

Copy link
Copy Markdown
Contributor

Description

One Line Summary

End duplicate Live Activities at startup and keep their shared update token when one is dismissed. SDK-5580.

Details

Motivation

When two subscriptions on one device hold the same push-to-start token, one push starts two Live Activities with the same OneSignal activityId. They share one update token registration, so only the activity registered last receives updates, and dismissing either one removed the registration and froze the other.

Scope

  • Dismissing an activity no longer removes the update token while another live activity of the same type has the same activityId.
  • In setup(), live activities that share an activityId are reduced to one. The SDK keeps the activity whose push token matches the cached update token, otherwise the first one with a token, ends the rest immediately, and attaches listeners only to the kept one. Needs iOS 16.2, like the existing dedupe of newly started activities.
  • Nothing changes for activities without a duplicate, or for apps that call enter and exit themselves.

Testing

Unit testing

Tests with fake activities cover grouping by activityId (ended and dismissed activities excluded), choosing the activity to keep, ending all but one per group, and the dismissal check. Two executor tests cover reading the cached update token, including when a removal is pending. ActivityKit activities cannot be created in unit tests, so the wiring in listenForActivity is not covered.

Manual testing

The full Reduced unit test plan passes locally on an iPhone 17 Pro simulator (iOS 26.5), 647 tests with 0 failures.

On an iPhone running iOS 26 with two subscriptions sharing one push-to-start token (a second user and push subscription created after clearing the SDK cache), the app was swiped away and one push-to-start was sent. It reached both subscriptions, and one widget remained once the app launched and setup() ran. Not checked on the device: an update after the dedupe reaching the surviving widget, and a dismissal while a duplicate is live leaving the other widget updating.

Affected code checklist

  • Notifications
    • Display
    • Open
    • Push Processing
    • Confirm Deliveries
  • Outcomes
  • Sessions
  • In-App Messaging
  • REST API requests
  • Public API changes

Checklist

Overview

  • I have filled out all REQUIRED sections above
  • PR does one thing
  • Any Public API changes are explained in the PR details and conform to existing APIs

Testing

  • I have included test coverage for these changes, or explained why they are not needed
  • All automated tests pass, or I explained why that is not possible
  • I have personally tested this on my device, or explained why that is not possible

Final pass

  • Code is as readable as possible.
  • I have reviewed this PR myself, ensuring it meets each checklist item

🤖 Generated with Claude Code

Activities with the same OneSignal activityId share one update token registration,
so dismissing a duplicate froze the survivor and only the last registered widget updated.
@nan-li
nan-li marked this pull request as ready for review October 29, 2026 16:15

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Multi-model review

Reviewed with Claude Opus 5.5, GPT 5.6 Sol, and Grok 4.7. Substantive diff is 311 lines after excluding project.pbxproj.

Act on (3/3): Setup now ends duplicates in an unstructured Task, while the existing activityUpdates loop still ends every other live instance and listens to whoever the stream yields. Those policies disagree (cached token vs newest), and Apple already notes the same activity can appear in both places on push-to-start. If they interleave, both widgets can be ended, or listeners attach to the copy setup is about to kill.

Act on (3/3): endDuplicates chooses a keeper once, then still ends remaining live copies if that keeper dies mid-await. testEndDuplicates_whenTheKeeperEndsDuringTheAwaits_returnsNothing locks that in. The caller then attaches no listeners and never exits, so the backend can keep a dead token.

Consider: Skipping exit on dismiss does not enter the survivor's token, so the last registration can still belong to the dismissed activity. Ended-but-still-listed activities are omitted from duplicateGroups and can still enter. cachedUpdateToken treats a pending/unsuccessful OSRequestSetUpdateToken as the backend token. activityToKeep can pick the wrong instance while pushToken is still nil.

Noted: Keeper listeners wait until all ends finish, so contentUpdates in that window are missed. Activity.activities order is unspecified for the no-token fallback.

Open in Web View Automation 

Sent by Cursor Automation: PR Reviews

Comment on lines +263 to 268
if #available(iOS 16.2, *), !duplicates.isEmpty {
Task {
for activity in await endDuplicateActivities(duplicates, cachedUpdateToken: _executor.cachedUpdateToken) {
listenForActivityEvents(activityType, activity: activity, options: options)
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Act on (3/3): This cleanup runs concurrently with the activityUpdates loop below, which still ends every other live activity with the same activityId and then listens to the yielded instance.

Those rules disagree (cached token vs newest). On push-to-start the same activity can appear in both Activity.activities and activityUpdates. If the stream yields B while this task kept A, the updates loop can end A and listen to B, then this task ends B — both widgets gone. The reverse order reattaches listeners to a duplicate this path is about to kill.

Serialize both paths through one keeper policy, and skip end/listen when the yielded activity is already not live.

Comment on lines +71 to +80
let cachedToken = await cachedUpdateToken(activityId)
guard let keeper = activityToKeep(group.filter { $0.isLive }, cachedUpdateToken: cachedToken) else {
continue
}
kept.append(keeper)
for duplicate in group where duplicate.id != keeper.id && duplicate.isLive {
await end(duplicate)
}
}
return kept.filter { $0.isLive }

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Act on (3/3): The keeper is chosen once. If it dies during await end(duplicate) (user dismiss, or the activityUpdates path), this loop still ends every remaining live sibling, then kept.filter { $0.isLive } returns nothing.

Listeners are attached only after this returns, and activityStateUpdates does not replay, so exit is never called. The update-token cache / backend can keep pushing to a dead token. testEndDuplicates_whenTheKeeperEndsDuringTheAwaits_returnsNothing currently encodes this.

Re-check keeper.isLive after each await, promote a new keeper if needed, and exit(activityId) if nothing is still live.

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