Repository navigation
Conversation
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.
There was a problem hiding this comment.
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.
Sent by Cursor Automation: PR Reviews
| if #available(iOS 16.2, *), !duplicates.isEmpty { | ||
| Task { | ||
| for activity in await endDuplicateActivities(duplicates, cachedUpdateToken: _executor.cachedUpdateToken) { | ||
| listenForActivityEvents(activityType, activity: activity, options: options) | ||
| } | ||
| } |
There was a problem hiding this comment.
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.
| 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 } |
There was a problem hiding this comment.
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.


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
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.enterandexitthemselves.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
listenForActivityis 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
Checklist
Overview
Testing
Final pass
🤖 Generated with Claude Code