diff --git a/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj b/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj index 9fc71c326..1424a0576 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 1afa990e7..c3345278c 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalLiveActivities/Source/Executors/OSLiveActivitiesExecutor.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalLiveActivities/Source/Executors/OSLiveActivitiesExecutor.swift @@ -186,6 +186,14 @@ class OSLiveActivitiesExecutor: OSPushSubscriptionObserver { } } + 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 000000000..a25d83370 --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalLiveActivities/Source/OSLiveActivityDuplicates.swift @@ -0,0 +1,98 @@ +/* + 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 + +/// Tests cannot create an `Activity`, so the helpers below take this protocol. +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 { + static func duplicateGroups(_ activities: [T]) -> [String: [T]] { + Dictionary(grouping: activities.filter { $0.isLive }, by: { $0.oneSignalActivityId }).filter { $0.value.count > 1 } + } + + /// 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 + } + + static func endDuplicates( + _ groups: [String: [T]], + cachedUpdateToken: (String) async -> String?, + 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.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 } + } + + @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 cdcf6fc8c..14ef75ea4 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalLiveActivities/Source/OneSignalLiveActivitiesManagerImpl.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalLiveActivities/Source/OneSignalLiveActivitiesManagerImpl.swift @@ -249,11 +249,22 @@ 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) + } + // 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) + } + + 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 +283,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 +305,12 @@ public class OneSignalLiveActivitiesManagerImpl: NSObject, OSLiveActivities { for await activityState in activity.activityStateUpdates { switch activityState { case .dismissed: - OneSignalLiveActivitiesManagerImpl.exit(activity.attributes.onesignal.activityId) + // 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):\(activity.id), 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 298334194..a91a2d7e6 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalLiveActivitiesTests/LiveActivitiesManagerTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalLiveActivitiesTests/LiveActivitiesManagerTests.swift @@ -151,4 +151,140 @@ final class LiveActivitiesManagerTests: XCTestCase { XCTAssertEqual(result!.query, "param1=value1¶m2=value2") XCTAssertEqual(result!.fragment, "section") } + + // MARK: - Duplicate activities + + private final class FakeActivity: OSLiveActivityInstance { + let id: String + let oneSignalActivityId: String + 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 { + 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") + ] + + let groups = OneSignalLiveActivitiesManagerImpl.duplicateGroups(activities) + + XCTAssertEqual(Array(groups.keys), ["x"]) + XCTAssertEqual(groups["x"]?.map(\.id), ["x1", "x2"]) + } + + func testActivityToKeep_prefersTheActivityWithTheCachedToken() throws { + 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") + ] + + XCTAssertEqual(OneSignalLiveActivitiesManagerImpl.activityToKeep(group, cachedUpdateToken: "token-2")?.id, "a2") + } + + func testActivityToKeep_withoutACachedMatch_keepsAnActivityWithAToken() throws { + let group = [ + FakeActivity(id: "a1", oneSignalActivityId: "a"), + FakeActivity(id: "a2", oneSignalActivityId: "a", pushTokenString: "token-2") + ] + + XCTAssertEqual(OneSignalLiveActivitiesManagerImpl.activityToKeep(group, cachedUpdateToken: "token-other")?.id, "a2") + XCTAssertEqual(OneSignalLiveActivitiesManagerImpl.activityToKeep(group, cachedUpdateToken: nil)?.id, "a2") + } + + func testActivityToKeep_withoutTokens_keepsTheFirstActivity() throws { + let group = [ + FakeActivity(id: "a1", oneSignalActivityId: "a"), + FakeActivity(id: "a2", oneSignalActivityId: "a") + ] + + XCTAssertEqual(OneSignalLiveActivitiesManagerImpl.activityToKeep(group, cachedUpdateToken: nil)?.id, "a1") + } + + func testEndDuplicates_endsAllButOneActivityPerActivityId() async throws { + 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]() + + let kept = await OneSignalLiveActivitiesManagerImpl.endDuplicates( + OneSignalLiveActivitiesManagerImpl.duplicateGroups(activities), + cachedUpdateToken: { cachedTokens[$0] }, + end: { ended.append($0.id) } + ) + + XCTAssertEqual(Set(kept.map(\.id)), ["x3", "y2"]) + 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")] + + XCTAssertTrue(OneSignalLiveActivitiesManagerImpl.hasLiveDuplicate(dismissed, in: activities)) + } + + func testHasLiveDuplicate_withoutAnotherLiveActivityForTheActivityId_returnsFalse() throws { + let dismissed = FakeActivity(id: "a1", oneSignalActivityId: "a", isLive: false) + let activities = [ + dismissed, + FakeActivity(id: "a2", oneSignalActivityId: "a", isLive: false), + FakeActivity(id: "b1", oneSignalActivityId: "b") + ] + + 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)) + } } diff --git a/iOS_SDK/OneSignalSDK/OneSignalLiveActivitiesTests/OSLiveActivitiesExecutorTests.swift b/iOS_SDK/OneSignalSDK/OneSignalLiveActivitiesTests/OSLiveActivitiesExecutorTests.swift index 43c13db6e..c773f9956 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalLiveActivitiesTests/OSLiveActivitiesExecutorTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalLiveActivitiesTests/OSLiveActivitiesExecutorTests.swift @@ -418,6 +418,32 @@ final class OSLiveActivitiesExecutorTests: XCTestCase { XCTAssertTrue(mockClient.executedRequests[1] == request2) } + func testCachedUpdateTokenReturnsTheSetTokenForTheActivityId() async throws { + 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) + + 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 { + let executor = OSLiveActivitiesExecutor(requestDispatch: MockDispatchQueue()) + executor.updateTokens.add(OSRequestSetUpdateToken(key: "my-activity-id", token: "my-token")) + executor.updateTokens.add(OSRequestRemoveUpdateToken(key: "my-activity-id")) + + let cachedToken = await executor.cachedUpdateToken("my-activity-id") + XCTAssertNil(cachedToken) + } + func testReceiveReceiptsRequestNotExecutedWithSameNotificationId() throws { /* Setup */ let mockDispatchQueue = MockDispatchQueue()