From 35b4b5be1e7703e3c2a5f0a152a7f4e455f2cc31 Mon Sep 17 00:00:00 2001 From: Nan Date: Mon, 19 Oct 2026 14:59:30 -0700 Subject: [PATCH 1/3] fix: [SDK-5580] end duplicate Live Activities and keep the update token 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. --- .../OneSignal.xcodeproj/project.pbxproj | 4 + .../Executors/OSLiveActivitiesExecutor.swift | 9 ++ .../Source/OSLiveActivityDuplicates.swift | 97 ++++++++++++++++ .../OneSignalLiveActivitiesManagerImpl.swift | 42 +++++-- .../LiveActivitiesManagerTests.swift | 109 ++++++++++++++++++ .../OSLiveActivitiesExecutorTests.swift | 30 +++++ 6 files changed, 280 insertions(+), 11 deletions(-) create mode 100644 iOS_SDK/OneSignalSDK/OneSignalLiveActivities/Source/OSLiveActivityDuplicates.swift diff --git a/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj b/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj index 9fc71c3266..1424a0576f 100644 --- a/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj +++ b/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj @@ -121,6 +121,7 @@ 3C30FE362F21FBE1001B9C25 /* EarlyTriggerTrackingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C30FE352F21FBE1001B9C25 /* EarlyTriggerTrackingTests.swift */; }; 3C8B41D82F7E9A6C001B9C25 /* TriggerThreadingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C8B41D72F7E9A6C001B9C25 /* TriggerThreadingTests.swift */; }; 3C3D34E92E95EAA5006A2924 /* LiveActivityConstants.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C3D34E82E95EAA5006A2924 /* LiveActivityConstants.swift */; }; + E5195D813B9499A01F9A102A /* OSLiveActivityDuplicates.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06B90FBD16038D378B54330A /* OSLiveActivityDuplicates.swift */; }; 3C3D8D782E92DB7500C3E977 /* OSLiveActivityViewExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C3D8D772E92DB7500C3E977 /* OSLiveActivityViewExtensions.swift */; }; 3C427AC9301BB28A0059B8B7 /* OSOperationRepoFlushTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C427AC8301BB28A0059B8B7 /* OSOperationRepoFlushTests.swift */; }; B5481A0100000000000000A8 /* OSRequestRetryBackoffTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5481A0100000000000000A7 /* OSRequestRetryBackoffTests.swift */; }; @@ -1444,6 +1445,7 @@ 3C30FE352F21FBE1001B9C25 /* EarlyTriggerTrackingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EarlyTriggerTrackingTests.swift; sourceTree = ""; }; 3C8B41D72F7E9A6C001B9C25 /* TriggerThreadingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TriggerThreadingTests.swift; sourceTree = ""; }; 3C3D34E82E95EAA5006A2924 /* LiveActivityConstants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveActivityConstants.swift; sourceTree = ""; }; + 06B90FBD16038D378B54330A /* OSLiveActivityDuplicates.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSLiveActivityDuplicates.swift; sourceTree = ""; }; 3C3D8D772E92DB7500C3E977 /* OSLiveActivityViewExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSLiveActivityViewExtensions.swift; sourceTree = ""; }; 3C427AC8301BB28A0059B8B7 /* OSOperationRepoFlushTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSOperationRepoFlushTests.swift; sourceTree = ""; }; B5481A0100000000000000A7 /* OSRequestRetryBackoffTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSRequestRetryBackoffTests.swift; sourceTree = ""; }; @@ -2640,6 +2642,7 @@ 3CFA8F4D2E9087DB00201FE5 /* OSLiveActivitiesExtension.swift */, 3CFA8F492E9087DB00201FE5 /* AnyCodable.swift */, 3C3D34E82E95EAA5006A2924 /* LiveActivityConstants.swift */, + 06B90FBD16038D378B54330A /* OSLiveActivityDuplicates.swift */, 3CFA8F4A2E9087DB00201FE5 /* DefaultLiveActivityAttributes.swift */, ); path = Source; @@ -4842,6 +4845,7 @@ 3CFA8F4F2E9087DB00201FE5 /* AnyCodable.swift in Sources */, 3C3D8D782E92DB7500C3E977 /* OSLiveActivityViewExtensions.swift in Sources */, 3C3D34E92E95EAA5006A2924 /* LiveActivityConstants.swift in Sources */, + E5195D813B9499A01F9A102A /* OSLiveActivityDuplicates.swift in Sources */, 3CFA8F502E9087DB00201FE5 /* OSLiveActivitiesExecutor.swift in Sources */, 3CFA8F512E9087DB00201FE5 /* DefaultLiveActivityAttributes.swift in Sources */, 3CFA8F522E9087DB00201FE5 /* OSRequestSetStartToken.swift in Sources */, diff --git a/iOS_SDK/OneSignalSDK/OneSignalLiveActivities/Source/Executors/OSLiveActivitiesExecutor.swift b/iOS_SDK/OneSignalSDK/OneSignalLiveActivities/Source/Executors/OSLiveActivitiesExecutor.swift index 1afa990e7f..5aad635946 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalLiveActivities/Source/Executors/OSLiveActivitiesExecutor.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalLiveActivities/Source/Executors/OSLiveActivitiesExecutor.swift @@ -186,6 +186,15 @@ class OSLiveActivitiesExecutor: OSPushSubscriptionObserver { } } + /// The update token last sent or queued for this activityId, which is the one the backend has or will have. + func cachedUpdateToken(_ activityId: String) async -> String? { + await withCheckedContinuation { continuation in + self.requestDispatch.async { + continuation.resume(returning: (self.updateTokens.items[activityId] as? OSRequestSetUpdateToken)?.token) + } + } + } + func pollPendingRequests() { OneSignalLog.onesignalLog(.LL_VERBOSE, message: "OneSignal.LiveActivities pollPendingRequests") diff --git a/iOS_SDK/OneSignalSDK/OneSignalLiveActivities/Source/OSLiveActivityDuplicates.swift b/iOS_SDK/OneSignalSDK/OneSignalLiveActivities/Source/OSLiveActivityDuplicates.swift new file mode 100644 index 0000000000..e284015bfa --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalLiveActivities/Source/OSLiveActivityDuplicates.swift @@ -0,0 +1,97 @@ +/* + Modified MIT License + + Copyright 2026 OneSignal + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + 1. The above copyright notice and this permission notice shall be included in + all copies or substantial portions of the Software. + + 2. All copies of substantial portions of the Software may only be used in connection + with services provided by OneSignal. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + THE SOFTWARE. + */ + +// Effectively blanks out this file for Mac Catalyst +#if targetEnvironment(macCatalyst) +#else +import OneSignalCore +import ActivityKit + +/// The parts of an `Activity` that duplicate handling reads, so it can be tested without ActivityKit. +protocol OSLiveActivityInstance { + var id: String { get } + var oneSignalActivityId: String { get } + var pushTokenString: String? { get } + var isLive: Bool { get } +} + +@available(iOS 16.1, *) +extension Activity: OSLiveActivityInstance where Attributes: OneSignalLiveActivityAttributes { + var oneSignalActivityId: String { attributes.onesignal.activityId } + var pushTokenString: String? { pushToken?.map { String(format: "%02x", $0) }.joined() } + var isLive: Bool { activityState != .ended && activityState != .dismissed } +} + +extension OneSignalLiveActivitiesManagerImpl { + /// Live activities that share a OneSignal activityId with another live activity, keyed by that activityId. + static func duplicateGroups(_ activities: [T]) -> [String: [T]] { + Dictionary(grouping: activities.filter { $0.isLive }, by: { $0.oneSignalActivityId }).filter { $0.value.count > 1 } + } + + /// Prefers the activity whose token the backend already has, so no re-registration is needed. + static func activityToKeep(_ group: [T], cachedUpdateToken: String?) -> T? { + group.first { $0.pushTokenString != nil && $0.pushTokenString == cachedUpdateToken } + ?? group.first { $0.pushTokenString != nil } + ?? group.first + } + + /// Ends all but one activity in each group and returns the kept ones. + static func endDuplicates( + _ groups: [String: [T]], + cachedUpdateToken: (String) async -> String?, + end: (T) async -> Void + ) async -> [T] { + var kept = [T]() + for (activityId, group) in groups { + let cachedToken = await cachedUpdateToken(activityId) + guard let keeper = activityToKeep(group, cachedUpdateToken: cachedToken) else { + continue + } + kept.append(keeper) + for duplicate in group where duplicate.id != keeper.id { + await end(duplicate) + } + } + return kept + } + + @available(iOS 16.2, *) + static func endDuplicateActivities( + _ groups: [String: [Activity]], + cachedUpdateToken: (String) async -> String? + ) async -> [Activity] { + await endDuplicates(groups, cachedUpdateToken: cachedUpdateToken) { duplicate in + OneSignalLog.onesignalLog(.LL_DEBUG, message: "OneSignal.LiveActivities ending duplicate activity: \(Attributes.self):\(duplicate.oneSignalActivityId):\(duplicate.id)") + await duplicate.end(nil, dismissalPolicy: ActivityUIDismissalPolicy.immediate) + } + } + + static func hasLiveDuplicate(_ activity: T, in activities: [T]) -> Bool { + activities.contains { $0.id != activity.id && $0.oneSignalActivityId == activity.oneSignalActivityId && $0.isLive } + } +} +#endif diff --git a/iOS_SDK/OneSignalSDK/OneSignalLiveActivities/Source/OneSignalLiveActivitiesManagerImpl.swift b/iOS_SDK/OneSignalSDK/OneSignalLiveActivities/Source/OneSignalLiveActivitiesManagerImpl.swift index cdcf6fc8c8..b903ceb129 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalLiveActivities/Source/OneSignalLiveActivitiesManagerImpl.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalLiveActivities/Source/OneSignalLiveActivitiesManagerImpl.swift @@ -249,11 +249,21 @@ public class OneSignalLiveActivitiesManagerImpl: NSObject, OSLiveActivities { */ // Establish listeners for activity (if any exist) - for activity in Activity.activities { - listenForActivityStateUpdates(activityType, activity: activity, options: options) - listenForActivityPushToUpdate(activityType, activity: activity, options: options) - if #available(iOS 16.2, *) { - listenForContentUpdates(activityType, activity: activity) + let activities = Activity.activities + var duplicates = [String: [Activity]]() + if #available(iOS 16.2, *) { + duplicates = duplicateGroups(activities) + } + for activity in activities where duplicates[activity.oneSignalActivityId] == nil { + listenForActivityEvents(activityType, activity: activity, options: options) + } + + // Duplicates share one update token registration, so only the kept activity gets listeners. + if #available(iOS 16.2, *), !duplicates.isEmpty { + Task { + for activity in await endDuplicateActivities(duplicates, cachedUpdateToken: _executor.cachedUpdateToken) { + listenForActivityEvents(activityType, activity: activity, options: options) + } } } @@ -272,15 +282,20 @@ public class OneSignalLiveActivitiesManagerImpl: NSObject, OSLiveActivities { } } - listenForActivityStateUpdates(activityType, activity: activity, options: options) - listenForActivityPushToUpdate(activityType, activity: activity, options: options) - if #available(iOS 16.2, *) { - listenForContentUpdates(activityType, activity: activity) - } + listenForActivityEvents(activityType, activity: activity, options: options) } } } + @available(iOS 16.1, *) + private static func listenForActivityEvents(_ activityType: Attributes.Type, activity: Activity, options: LiveActivitySetupOptions? = nil) { + listenForActivityStateUpdates(activityType, activity: activity, options: options) + listenForActivityPushToUpdate(activityType, activity: activity, options: options) + if #available(iOS 16.2, *) { + listenForContentUpdates(activityType, activity: activity) + } + } + @available(iOS 16.1, *) private static func listenForActivityStateUpdates(_ activityType: Attributes.Type, activity: Activity, options: LiveActivitySetupOptions? = nil) { // listen for activity dismisses so we can forget about the token @@ -289,7 +304,12 @@ public class OneSignalLiveActivitiesManagerImpl: NSObject, OSLiveActivities { for await activityState in activity.activityStateUpdates { switch activityState { case .dismissed: - OneSignalLiveActivitiesManagerImpl.exit(activity.attributes.onesignal.activityId) + // The update token registration is per activityId, so a duplicate still on screen is using it. + if hasLiveDuplicate(activity, in: Activity.activities) { + OneSignalLog.onesignalLog(.LL_DEBUG, message: "OneSignal.LiveActivities keeping update token for: \(activityType):\(activity.attributes.onesignal.activityId), a duplicate is still live") + } else { + OneSignalLiveActivitiesManagerImpl.exit(activity.attributes.onesignal.activityId) + } case .active: break case .ended: break case .stale: break diff --git a/iOS_SDK/OneSignalSDK/OneSignalLiveActivitiesTests/LiveActivitiesManagerTests.swift b/iOS_SDK/OneSignalSDK/OneSignalLiveActivitiesTests/LiveActivitiesManagerTests.swift index 298334194c..fc9f36f17a 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalLiveActivitiesTests/LiveActivitiesManagerTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalLiveActivitiesTests/LiveActivitiesManagerTests.swift @@ -151,4 +151,113 @@ final class LiveActivitiesManagerTests: XCTestCase { XCTAssertEqual(result!.query, "param1=value1¶m2=value2") XCTAssertEqual(result!.fragment, "section") } + + // MARK: - Duplicate activities + + private struct FakeActivity: OSLiveActivityInstance { + let id: String + let oneSignalActivityId: String + var pushTokenString: String? + var isLive = true + } + + func testDuplicateGroups_groupsOnlyLiveActivitiesSharingAnActivityId() throws { + /* Setup */ + let activities = [ + FakeActivity(id: "x1", oneSignalActivityId: "x"), + FakeActivity(id: "y1", oneSignalActivityId: "y"), + FakeActivity(id: "x2", oneSignalActivityId: "x"), + FakeActivity(id: "y2", oneSignalActivityId: "y", isLive: false), + FakeActivity(id: "z1", oneSignalActivityId: "z") + ] + + /* When */ + let groups = OneSignalLiveActivitiesManagerImpl.duplicateGroups(activities) + + /* Then */ + XCTAssertEqual(Array(groups.keys), ["x"]) + XCTAssertEqual(groups["x"]?.map(\.id), ["x1", "x2"]) + } + + func testActivityToKeep_prefersTheActivityWithTheCachedToken() throws { + /* Setup */ + let group = [ + FakeActivity(id: "a1", oneSignalActivityId: "a", pushTokenString: "token-1"), + FakeActivity(id: "a2", oneSignalActivityId: "a", pushTokenString: "token-2"), + FakeActivity(id: "a3", oneSignalActivityId: "a", pushTokenString: "token-3") + ] + + /* Then */ + XCTAssertEqual(OneSignalLiveActivitiesManagerImpl.activityToKeep(group, cachedUpdateToken: "token-2")?.id, "a2") + } + + func testActivityToKeep_withoutACachedMatch_keepsAnActivityWithAToken() throws { + /* Setup */ + let group = [ + FakeActivity(id: "a1", oneSignalActivityId: "a"), + FakeActivity(id: "a2", oneSignalActivityId: "a", pushTokenString: "token-2") + ] + + /* Then */ + XCTAssertEqual(OneSignalLiveActivitiesManagerImpl.activityToKeep(group, cachedUpdateToken: "token-other")?.id, "a2") + XCTAssertEqual(OneSignalLiveActivitiesManagerImpl.activityToKeep(group, cachedUpdateToken: nil)?.id, "a2") + } + + func testActivityToKeep_withoutTokens_keepsTheFirstActivity() throws { + /* Setup */ + let group = [ + FakeActivity(id: "a1", oneSignalActivityId: "a"), + FakeActivity(id: "a2", oneSignalActivityId: "a") + ] + + /* Then */ + XCTAssertEqual(OneSignalLiveActivitiesManagerImpl.activityToKeep(group, cachedUpdateToken: nil)?.id, "a1") + } + + func testEndDuplicates_endsAllButOneActivityPerActivityId() async throws { + /* Setup */ + let activities = [ + FakeActivity(id: "x1", oneSignalActivityId: "x", pushTokenString: "token-x1"), + FakeActivity(id: "x2", oneSignalActivityId: "x", pushTokenString: "token-x2"), + FakeActivity(id: "x3", oneSignalActivityId: "x", pushTokenString: "token-x3"), + FakeActivity(id: "y1", oneSignalActivityId: "y"), + FakeActivity(id: "y2", oneSignalActivityId: "y", pushTokenString: "token-y2") + ] + let cachedTokens = ["x": "token-x3"] + var ended = [String]() + + /* When */ + let kept = await OneSignalLiveActivitiesManagerImpl.endDuplicates( + OneSignalLiveActivitiesManagerImpl.duplicateGroups(activities), + cachedUpdateToken: { cachedTokens[$0] }, + end: { ended.append($0.id) } + ) + + /* Then */ + XCTAssertEqual(Set(kept.map(\.id)), ["x3", "y2"]) + XCTAssertEqual(Set(ended), ["x1", "x2", "y1"]) + } + + func testHasLiveDuplicate_whenAnotherLiveActivityHasTheSameActivityId_returnsTrue() throws { + /* Setup */ + let dismissed = FakeActivity(id: "a1", oneSignalActivityId: "a", isLive: false) + let activities = [dismissed, FakeActivity(id: "a2", oneSignalActivityId: "a")] + + /* Then */ + XCTAssertTrue(OneSignalLiveActivitiesManagerImpl.hasLiveDuplicate(dismissed, in: activities)) + } + + func testHasLiveDuplicate_withoutAnotherLiveActivityForTheActivityId_returnsFalse() throws { + /* Setup */ + let dismissed = FakeActivity(id: "a1", oneSignalActivityId: "a", isLive: false) + let activities = [ + dismissed, + FakeActivity(id: "a2", oneSignalActivityId: "a", isLive: false), + FakeActivity(id: "b1", oneSignalActivityId: "b") + ] + + /* Then */ + XCTAssertFalse(OneSignalLiveActivitiesManagerImpl.hasLiveDuplicate(dismissed, in: activities)) + XCTAssertFalse(OneSignalLiveActivitiesManagerImpl.hasLiveDuplicate(dismissed, in: [])) + } } diff --git a/iOS_SDK/OneSignalSDK/OneSignalLiveActivitiesTests/OSLiveActivitiesExecutorTests.swift b/iOS_SDK/OneSignalSDK/OneSignalLiveActivitiesTests/OSLiveActivitiesExecutorTests.swift index 43c13db6ee..c967fd8733 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalLiveActivitiesTests/OSLiveActivitiesExecutorTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalLiveActivitiesTests/OSLiveActivitiesExecutorTests.swift @@ -418,6 +418,36 @@ final class OSLiveActivitiesExecutorTests: XCTestCase { XCTAssertTrue(mockClient.executedRequests[1] == request2) } + func testCachedUpdateTokenReturnsTheSetTokenForTheActivityId() async throws { + /* Setup */ + let mockDispatchQueue = MockDispatchQueue() + let mockClient = setUpSubscribedUser() + + let request = OSRequestSetUpdateToken(key: "my-activity-id", token: "my-token") + mockClient.setMockResponseForRequest(request: String(describing: request), response: [String: Any]()) + + let executor = OSLiveActivitiesExecutor(requestDispatch: mockDispatchQueue) + executor.append(request) + mockDispatchQueue.waitForDispatches(2) + + /* Then */ + let cachedToken = await executor.cachedUpdateToken("my-activity-id") + let otherToken = await executor.cachedUpdateToken("other-activity-id") + XCTAssertEqual(cachedToken, "my-token") + XCTAssertNil(otherToken) + } + + func testCachedUpdateTokenIsNilWhenARemoveIsPending() async throws { + /* Setup */ + let executor = OSLiveActivitiesExecutor(requestDispatch: MockDispatchQueue()) + executor.updateTokens.add(OSRequestSetUpdateToken(key: "my-activity-id", token: "my-token")) + executor.updateTokens.add(OSRequestRemoveUpdateToken(key: "my-activity-id")) + + /* Then */ + let cachedToken = await executor.cachedUpdateToken("my-activity-id") + XCTAssertNil(cachedToken) + } + func testReceiveReceiptsRequestNotExecutedWithSameNotificationId() throws { /* Setup */ let mockDispatchQueue = MockDispatchQueue() From 8aa3e2ae22ae7276928b33d3c3182c4fc8b73e70 Mon Sep 17 00:00:00 2001 From: Nan Date: Tue, 20 Oct 2026 10:49:58 -0700 Subject: [PATCH 2/3] style: [SDK-5580] trim Live Activity duplicate comments --- .../Executors/OSLiveActivitiesExecutor.swift | 1 - .../Source/OSLiveActivityDuplicates.swift | 7 +++---- .../OneSignalLiveActivitiesManagerImpl.swift | 5 +++-- .../LiveActivitiesManagerTests.swift | 16 ---------------- .../OSLiveActivitiesExecutorTests.swift | 4 ---- 5 files changed, 6 insertions(+), 27 deletions(-) diff --git a/iOS_SDK/OneSignalSDK/OneSignalLiveActivities/Source/Executors/OSLiveActivitiesExecutor.swift b/iOS_SDK/OneSignalSDK/OneSignalLiveActivities/Source/Executors/OSLiveActivitiesExecutor.swift index 5aad635946..c3345278c4 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalLiveActivities/Source/Executors/OSLiveActivitiesExecutor.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalLiveActivities/Source/Executors/OSLiveActivitiesExecutor.swift @@ -186,7 +186,6 @@ class OSLiveActivitiesExecutor: OSPushSubscriptionObserver { } } - /// The update token last sent or queued for this activityId, which is the one the backend has or will have. func cachedUpdateToken(_ activityId: String) async -> String? { await withCheckedContinuation { continuation in self.requestDispatch.async { diff --git a/iOS_SDK/OneSignalSDK/OneSignalLiveActivities/Source/OSLiveActivityDuplicates.swift b/iOS_SDK/OneSignalSDK/OneSignalLiveActivities/Source/OSLiveActivityDuplicates.swift index e284015bfa..aca3fabb20 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalLiveActivities/Source/OSLiveActivityDuplicates.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalLiveActivities/Source/OSLiveActivityDuplicates.swift @@ -31,7 +31,7 @@ import OneSignalCore import ActivityKit -/// The parts of an `Activity` that duplicate handling reads, so it can be tested without ActivityKit. +/// Tests cannot create an `Activity`, so the helpers below take this protocol. protocol OSLiveActivityInstance { var id: String { get } var oneSignalActivityId: String { get } @@ -47,19 +47,18 @@ extension Activity: OSLiveActivityInstance where Attributes: OneSignalLiveActivi } extension OneSignalLiveActivitiesManagerImpl { - /// Live activities that share a OneSignal activityId with another live activity, keyed by that activityId. static func duplicateGroups(_ activities: [T]) -> [String: [T]] { Dictionary(grouping: activities.filter { $0.isLive }, by: { $0.oneSignalActivityId }).filter { $0.value.count > 1 } } - /// Prefers the activity whose token the backend already has, so no re-registration is needed. + /// The backend sends updates to the cached update token, so we prefer the + /// activity that has that token. static func activityToKeep(_ group: [T], cachedUpdateToken: String?) -> T? { group.first { $0.pushTokenString != nil && $0.pushTokenString == cachedUpdateToken } ?? group.first { $0.pushTokenString != nil } ?? group.first } - /// Ends all but one activity in each group and returns the kept ones. static func endDuplicates( _ groups: [String: [T]], cachedUpdateToken: (String) async -> String?, diff --git a/iOS_SDK/OneSignalSDK/OneSignalLiveActivities/Source/OneSignalLiveActivitiesManagerImpl.swift b/iOS_SDK/OneSignalSDK/OneSignalLiveActivities/Source/OneSignalLiveActivitiesManagerImpl.swift index b903ceb129..eee6ba7dec 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalLiveActivities/Source/OneSignalLiveActivitiesManagerImpl.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalLiveActivities/Source/OneSignalLiveActivitiesManagerImpl.swift @@ -254,11 +254,12 @@ public class OneSignalLiveActivitiesManagerImpl: NSObject, OSLiveActivities { if #available(iOS 16.2, *) { duplicates = duplicateGroups(activities) } + // If a duplicate that we end gets listeners, its token replaces the + // kept activity's token. for activity in activities where duplicates[activity.oneSignalActivityId] == nil { listenForActivityEvents(activityType, activity: activity, options: options) } - // Duplicates share one update token registration, so only the kept activity gets listeners. if #available(iOS 16.2, *), !duplicates.isEmpty { Task { for activity in await endDuplicateActivities(duplicates, cachedUpdateToken: _executor.cachedUpdateToken) { @@ -304,7 +305,7 @@ public class OneSignalLiveActivitiesManagerImpl: NSObject, OSLiveActivities { for await activityState in activity.activityStateUpdates { switch activityState { case .dismissed: - // The update token registration is per activityId, so a duplicate still on screen is using it. + // If a live duplicate exists, `exit` can stop its updates. if hasLiveDuplicate(activity, in: Activity.activities) { OneSignalLog.onesignalLog(.LL_DEBUG, message: "OneSignal.LiveActivities keeping update token for: \(activityType):\(activity.attributes.onesignal.activityId), a duplicate is still live") } else { diff --git a/iOS_SDK/OneSignalSDK/OneSignalLiveActivitiesTests/LiveActivitiesManagerTests.swift b/iOS_SDK/OneSignalSDK/OneSignalLiveActivitiesTests/LiveActivitiesManagerTests.swift index fc9f36f17a..68f39f9961 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalLiveActivitiesTests/LiveActivitiesManagerTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalLiveActivitiesTests/LiveActivitiesManagerTests.swift @@ -162,7 +162,6 @@ final class LiveActivitiesManagerTests: XCTestCase { } func testDuplicateGroups_groupsOnlyLiveActivitiesSharingAnActivityId() throws { - /* Setup */ let activities = [ FakeActivity(id: "x1", oneSignalActivityId: "x"), FakeActivity(id: "y1", oneSignalActivityId: "y"), @@ -171,51 +170,42 @@ final class LiveActivitiesManagerTests: XCTestCase { FakeActivity(id: "z1", oneSignalActivityId: "z") ] - /* When */ let groups = OneSignalLiveActivitiesManagerImpl.duplicateGroups(activities) - /* Then */ XCTAssertEqual(Array(groups.keys), ["x"]) XCTAssertEqual(groups["x"]?.map(\.id), ["x1", "x2"]) } func testActivityToKeep_prefersTheActivityWithTheCachedToken() throws { - /* Setup */ let group = [ FakeActivity(id: "a1", oneSignalActivityId: "a", pushTokenString: "token-1"), FakeActivity(id: "a2", oneSignalActivityId: "a", pushTokenString: "token-2"), FakeActivity(id: "a3", oneSignalActivityId: "a", pushTokenString: "token-3") ] - /* Then */ XCTAssertEqual(OneSignalLiveActivitiesManagerImpl.activityToKeep(group, cachedUpdateToken: "token-2")?.id, "a2") } func testActivityToKeep_withoutACachedMatch_keepsAnActivityWithAToken() throws { - /* Setup */ let group = [ FakeActivity(id: "a1", oneSignalActivityId: "a"), FakeActivity(id: "a2", oneSignalActivityId: "a", pushTokenString: "token-2") ] - /* Then */ XCTAssertEqual(OneSignalLiveActivitiesManagerImpl.activityToKeep(group, cachedUpdateToken: "token-other")?.id, "a2") XCTAssertEqual(OneSignalLiveActivitiesManagerImpl.activityToKeep(group, cachedUpdateToken: nil)?.id, "a2") } func testActivityToKeep_withoutTokens_keepsTheFirstActivity() throws { - /* Setup */ let group = [ FakeActivity(id: "a1", oneSignalActivityId: "a"), FakeActivity(id: "a2", oneSignalActivityId: "a") ] - /* Then */ XCTAssertEqual(OneSignalLiveActivitiesManagerImpl.activityToKeep(group, cachedUpdateToken: nil)?.id, "a1") } func testEndDuplicates_endsAllButOneActivityPerActivityId() async throws { - /* Setup */ let activities = [ FakeActivity(id: "x1", oneSignalActivityId: "x", pushTokenString: "token-x1"), FakeActivity(id: "x2", oneSignalActivityId: "x", pushTokenString: "token-x2"), @@ -226,29 +216,24 @@ final class LiveActivitiesManagerTests: XCTestCase { let cachedTokens = ["x": "token-x3"] var ended = [String]() - /* When */ let kept = await OneSignalLiveActivitiesManagerImpl.endDuplicates( OneSignalLiveActivitiesManagerImpl.duplicateGroups(activities), cachedUpdateToken: { cachedTokens[$0] }, end: { ended.append($0.id) } ) - /* Then */ XCTAssertEqual(Set(kept.map(\.id)), ["x3", "y2"]) XCTAssertEqual(Set(ended), ["x1", "x2", "y1"]) } func testHasLiveDuplicate_whenAnotherLiveActivityHasTheSameActivityId_returnsTrue() throws { - /* Setup */ let dismissed = FakeActivity(id: "a1", oneSignalActivityId: "a", isLive: false) let activities = [dismissed, FakeActivity(id: "a2", oneSignalActivityId: "a")] - /* Then */ XCTAssertTrue(OneSignalLiveActivitiesManagerImpl.hasLiveDuplicate(dismissed, in: activities)) } func testHasLiveDuplicate_withoutAnotherLiveActivityForTheActivityId_returnsFalse() throws { - /* Setup */ let dismissed = FakeActivity(id: "a1", oneSignalActivityId: "a", isLive: false) let activities = [ dismissed, @@ -256,7 +241,6 @@ final class LiveActivitiesManagerTests: XCTestCase { FakeActivity(id: "b1", oneSignalActivityId: "b") ] - /* Then */ XCTAssertFalse(OneSignalLiveActivitiesManagerImpl.hasLiveDuplicate(dismissed, in: activities)) XCTAssertFalse(OneSignalLiveActivitiesManagerImpl.hasLiveDuplicate(dismissed, in: [])) } diff --git a/iOS_SDK/OneSignalSDK/OneSignalLiveActivitiesTests/OSLiveActivitiesExecutorTests.swift b/iOS_SDK/OneSignalSDK/OneSignalLiveActivitiesTests/OSLiveActivitiesExecutorTests.swift index c967fd8733..c773f99561 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalLiveActivitiesTests/OSLiveActivitiesExecutorTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalLiveActivitiesTests/OSLiveActivitiesExecutorTests.swift @@ -419,7 +419,6 @@ final class OSLiveActivitiesExecutorTests: XCTestCase { } func testCachedUpdateTokenReturnsTheSetTokenForTheActivityId() async throws { - /* Setup */ let mockDispatchQueue = MockDispatchQueue() let mockClient = setUpSubscribedUser() @@ -430,7 +429,6 @@ final class OSLiveActivitiesExecutorTests: XCTestCase { executor.append(request) mockDispatchQueue.waitForDispatches(2) - /* Then */ let cachedToken = await executor.cachedUpdateToken("my-activity-id") let otherToken = await executor.cachedUpdateToken("other-activity-id") XCTAssertEqual(cachedToken, "my-token") @@ -438,12 +436,10 @@ final class OSLiveActivitiesExecutorTests: XCTestCase { } func testCachedUpdateTokenIsNilWhenARemoveIsPending() async throws { - /* Setup */ let executor = OSLiveActivitiesExecutor(requestDispatch: MockDispatchQueue()) executor.updateTokens.add(OSRequestSetUpdateToken(key: "my-activity-id", token: "my-token")) executor.updateTokens.add(OSRequestRemoveUpdateToken(key: "my-activity-id")) - /* Then */ let cachedToken = await executor.cachedUpdateToken("my-activity-id") XCTAssertNil(cachedToken) } From 5293e25f2cb70d62c2709d0f21f1dd59a69596e8 Mon Sep 17 00:00:00 2001 From: Nan Date: Wed, 21 Oct 2026 16:52:23 -0700 Subject: [PATCH 3/3] chore: [SDK-5580] re-check Live Activity liveness when ending duplicates --- .../Source/OSLiveActivityDuplicates.swift | 8 +-- .../OneSignalLiveActivitiesManagerImpl.swift | 2 +- .../LiveActivitiesManagerTests.swift | 49 +++++++++++++++++-- 3 files changed, 52 insertions(+), 7 deletions(-) diff --git a/iOS_SDK/OneSignalSDK/OneSignalLiveActivities/Source/OSLiveActivityDuplicates.swift b/iOS_SDK/OneSignalSDK/OneSignalLiveActivities/Source/OSLiveActivityDuplicates.swift index aca3fabb20..a25d83370f 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalLiveActivities/Source/OSLiveActivityDuplicates.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalLiveActivities/Source/OSLiveActivityDuplicates.swift @@ -65,17 +65,19 @@ extension OneSignalLiveActivitiesManagerImpl { end: (T) async -> Void ) async -> [T] { var kept = [T]() + // An activity can end during any await below, so we read isLive again + // before we keep or end an activity. for (activityId, group) in groups { let cachedToken = await cachedUpdateToken(activityId) - guard let keeper = activityToKeep(group, cachedUpdateToken: cachedToken) else { + guard let keeper = activityToKeep(group.filter { $0.isLive }, cachedUpdateToken: cachedToken) else { continue } kept.append(keeper) - for duplicate in group where duplicate.id != keeper.id { + for duplicate in group where duplicate.id != keeper.id && duplicate.isLive { await end(duplicate) } } - return kept + return kept.filter { $0.isLive } } @available(iOS 16.2, *) diff --git a/iOS_SDK/OneSignalSDK/OneSignalLiveActivities/Source/OneSignalLiveActivitiesManagerImpl.swift b/iOS_SDK/OneSignalSDK/OneSignalLiveActivities/Source/OneSignalLiveActivitiesManagerImpl.swift index eee6ba7dec..14ef75ea45 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalLiveActivities/Source/OneSignalLiveActivitiesManagerImpl.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalLiveActivities/Source/OneSignalLiveActivitiesManagerImpl.swift @@ -307,7 +307,7 @@ public class OneSignalLiveActivitiesManagerImpl: NSObject, OSLiveActivities { case .dismissed: // If a live duplicate exists, `exit` can stop its updates. if hasLiveDuplicate(activity, in: Activity.activities) { - OneSignalLog.onesignalLog(.LL_DEBUG, message: "OneSignal.LiveActivities keeping update token for: \(activityType):\(activity.attributes.onesignal.activityId), a duplicate is still live") + OneSignalLog.onesignalLog(.LL_DEBUG, message: "OneSignal.LiveActivities keeping update token for: \(activityType):\(activity.attributes.onesignal.activityId):\(activity.id), a duplicate is still live") } else { OneSignalLiveActivitiesManagerImpl.exit(activity.attributes.onesignal.activityId) } diff --git a/iOS_SDK/OneSignalSDK/OneSignalLiveActivitiesTests/LiveActivitiesManagerTests.swift b/iOS_SDK/OneSignalSDK/OneSignalLiveActivitiesTests/LiveActivitiesManagerTests.swift index 68f39f9961..a91a2d7e61 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalLiveActivitiesTests/LiveActivitiesManagerTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalLiveActivitiesTests/LiveActivitiesManagerTests.swift @@ -154,11 +154,18 @@ final class LiveActivitiesManagerTests: XCTestCase { // MARK: - Duplicate activities - private struct FakeActivity: OSLiveActivityInstance { + private final class FakeActivity: OSLiveActivityInstance { let id: String let oneSignalActivityId: String - var pushTokenString: String? - var isLive = true + let pushTokenString: String? + var isLive: Bool + + init(id: String, oneSignalActivityId: String, pushTokenString: String? = nil, isLive: Bool = true) { + self.id = id + self.oneSignalActivityId = oneSignalActivityId + self.pushTokenString = pushTokenString + self.isLive = isLive + } } func testDuplicateGroups_groupsOnlyLiveActivitiesSharingAnActivityId() throws { @@ -226,6 +233,35 @@ final class LiveActivitiesManagerTests: XCTestCase { XCTAssertEqual(Set(ended), ["x1", "x2", "y1"]) } + func testEndDuplicates_whenTheCachedTokenActivityHasEnded_keepsTheLiveOne() async throws { + let endedActivity = FakeActivity(id: "x1", oneSignalActivityId: "x", pushTokenString: "token-x1", isLive: false) + let liveActivity = FakeActivity(id: "x2", oneSignalActivityId: "x", pushTokenString: "token-x2") + var ended = [String]() + + let kept = await OneSignalLiveActivitiesManagerImpl.endDuplicates( + ["x": [endedActivity, liveActivity]], + cachedUpdateToken: { _ in "token-x1" }, + end: { ended.append($0.id) } + ) + + XCTAssertEqual(kept.map(\.id), ["x2"]) + XCTAssertEqual(ended, []) + } + + func testEndDuplicates_whenTheKeeperEndsDuringTheAwaits_returnsNothing() async throws { + let keeper = FakeActivity(id: "x1", oneSignalActivityId: "x", pushTokenString: "token-x1") + let duplicate = FakeActivity(id: "x2", oneSignalActivityId: "x", pushTokenString: "token-x2") + + let kept = await OneSignalLiveActivitiesManagerImpl.endDuplicates( + ["x": [keeper, duplicate]], + cachedUpdateToken: { _ in "token-x1" }, + // A new activity with the same activityId ends the keeper while this call waits. + end: { _ in keeper.isLive = false } + ) + + XCTAssertTrue(kept.isEmpty) + } + func testHasLiveDuplicate_whenAnotherLiveActivityHasTheSameActivityId_returnsTrue() throws { let dismissed = FakeActivity(id: "a1", oneSignalActivityId: "a", isLive: false) let activities = [dismissed, FakeActivity(id: "a2", oneSignalActivityId: "a")] @@ -244,4 +280,11 @@ final class LiveActivitiesManagerTests: XCTestCase { XCTAssertFalse(OneSignalLiveActivitiesManagerImpl.hasLiveDuplicate(dismissed, in: activities)) XCTAssertFalse(OneSignalLiveActivitiesManagerImpl.hasLiveDuplicate(dismissed, in: [])) } + + func testHasLiveDuplicate_forALiveActivityWithoutAnotherForItsActivityId_returnsFalse() throws { + let live = FakeActivity(id: "a1", oneSignalActivityId: "a") + let activities = [live, FakeActivity(id: "b1", oneSignalActivityId: "b")] + + XCTAssertFalse(OneSignalLiveActivitiesManagerImpl.hasLiveDuplicate(live, in: activities)) + } }