From 3c8010df0799657f76690bb5af59b3d2b85f246e Mon Sep 17 00:00:00 2001 From: Sumeru Chatterjee Date: Tue, 21 Jul 2026 11:46:52 +0100 Subject: [PATCH 01/15] SDK-496 Route public inAppConsume through InAppManager The public inAppConsume overloads sent the server request but never removed the message locally or posted iterableInboxChanged, despite their docs saying they remove it from the list. They now route through InAppManager.remove, which does all three. Inbox change notifications also fire only after the local mutation completes, so observers no longer read stale message counts; removePrivate and silent-push removal previously posted on a different queue than the mutation. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 2 + swift-sdk/Internal/in-app/InAppManager.swift | 16 +-- swift-sdk/SDK/IterableAPI.swift | 4 +- tests/unit-tests/InAppTests.swift | 40 ++++-- tests/unit-tests/InboxTests.swift | 49 ++++--- tests/unit-tests/IterableAPITests.swift | 137 +++++++++++++------ 6 files changed, 163 insertions(+), 85 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 37d04935d..e302fb3d6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,8 @@ All notable changes to this project will be documented in this file. This project adheres to [Semantic Versioning](http://semver.org/). ## [Unreleased] +### Fixed +- Public `inAppConsume` APIs now remove messages locally and post `iterableInboxChanged`. Inbox change notifications now fire after the local state is updated. ## [6.7.4] ### Added diff --git a/swift-sdk/Internal/in-app/InAppManager.swift b/swift-sdk/Internal/in-app/InAppManager.swift index 0e5c7ef76..e55a8cfca 100644 --- a/swift-sdk/Internal/in-app/InAppManager.swift +++ b/swift-sdk/Internal/in-app/InAppManager.swift @@ -515,16 +515,17 @@ class InAppManager: NSObject, IterableInternalInAppManagerProtocol { successHandler: OnSuccessHandler? = nil, failureHandler: OnFailureHandler? = nil) { ITBInfo() - updateMessage(message, didProcessTrigger: true, consumed: true) + updateMessage(message, didProcessTrigger: true, consumed: true).onSuccess { [weak self] _ in + self?.callbackQueue.async { [weak self] in + self?.notificationCenter.post(name: .iterableInboxChanged, object: self, userInfo: nil) + } + } requestHandler?.inAppConsume(message: message, location: location, source: source, inboxSessionId: inboxSessionId, onSuccess: successHandler, onFailure: failureHandler) - callbackQueue.async { [weak self] in - self?.notificationCenter.post(name: .iterableInboxChanged, object: self, userInfo: nil) - } } private static func isExpired(message: IterableInAppMessage, currentDate: Date) -> Bool { @@ -631,10 +632,9 @@ extension InAppManager: InAppNotifiable { self?.persister.persist(messagesMap.values) } } - } - - callbackQueue.async { [weak self] in - self?.notificationCenter.post(name: .iterableInboxChanged, object: self, userInfo: nil) + self?.callbackQueue.async { [weak self] in + self?.notificationCenter.post(name: .iterableInboxChanged, object: self, userInfo: nil) + } } } diff --git a/swift-sdk/SDK/IterableAPI.swift b/swift-sdk/SDK/IterableAPI.swift index ab44eb3dd..33f6c6d2d 100644 --- a/swift-sdk/SDK/IterableAPI.swift +++ b/swift-sdk/SDK/IterableAPI.swift @@ -922,7 +922,7 @@ import UIKit public static func inAppConsume(message: IterableInAppMessage, location: InAppLocation = .inApp) { guard let implementation, implementation.isSDKInitialized() else { return } - implementation.inAppConsume(message: message, location: location) + implementation.inAppManager.remove(message: message, location: location) } /// Consumes the notification and removes it from the list of in-app messages @@ -935,7 +935,7 @@ import UIKit public static func inAppConsume(message: IterableInAppMessage, location: InAppLocation = .inApp, source: InAppDeleteSource) { guard let implementation, implementation.isSDKInitialized() else { return } - implementation.inAppConsume(message: message, location: location, source: source) + implementation.inAppManager.remove(message: message, location: location, source: source) } /// Tracks analytics data from a session of using an inbox UI diff --git a/tests/unit-tests/InAppTests.swift b/tests/unit-tests/InAppTests.swift index 6c7f8a85a..208f93fc7 100644 --- a/tests/unit-tests/InAppTests.swift +++ b/tests/unit-tests/InAppTests.swift @@ -1123,8 +1123,6 @@ class InAppTests: XCTestCase { } func testInboxChangedIsCalledWhenInAppIsRemovedInServer() { - let expectation1 = expectation(description: "testInboxChangedIsCalledWhenInAppIsRemovedInServer") - let notification = """ { "itbl" : { @@ -1135,17 +1133,34 @@ class InAppTests: XCTestCase { "messageId" : "messageId" } """.toJsonDict() - + + let message = IterableInAppMessage(messageId: "messageId", + campaignId: 1, + trigger: IterableInAppTrigger(dict: [JsonKey.InApp.type: "never"]), + content: IterableHtmlInAppContent(edgeInsets: .zero, html: ""), + saveToInbox: true) + let mockInAppFetcher = MockInAppFetcher() let mockNotificationCenter = MockNotificationCenter() - let reference = mockNotificationCenter.addCallback(forNotification: .iterableInboxChanged) { _ in - expectation1.fulfill() - } - - XCTAssertNotNil(reference) - let config = IterableConfig() - let internalApi = InternalIterableAPI.initializeForTesting(config: config, notificationCenter: mockNotificationCenter) - + let internalApi = InternalIterableAPI.initializeForTesting(config: config, + inAppFetcher: mockInAppFetcher, + notificationCenter: mockNotificationCenter) + + let initialInboxExpectation = expectation(description: "initial inbox load") + let initialReference = mockNotificationCenter.addCallback(forNotification: .iterableInboxChanged) { _ in + initialInboxExpectation.fulfill() + } + mockInAppFetcher.mockMessagesAvailableFromServer(internalApi: internalApi, messages: [message]) + wait(for: [initialInboxExpectation], timeout: testExpectationTimeout) + mockNotificationCenter.removeCallbacks(withIds: initialReference.callbackId) + XCTAssertEqual(internalApi.inAppManager.getInboxMessages().count, 1) + + let removalExpectation = expectation(description: "inbox changed after server removal") + removalExpectation.assertForOverFulfill = true + let removalReference = mockNotificationCenter.addCallback(forNotification: .iterableInboxChanged) { _ in + XCTAssertEqual(internalApi.inAppManager.getInboxMessages().count, 0) + removalExpectation.fulfill() + } let appIntegrationInternal = InternalIterableAppIntegration(tracker: internalApi, urlDelegate: config.urlDelegate, customActionDelegate: config.customActionDelegate, @@ -1155,7 +1170,8 @@ class InAppTests: XCTestCase { appIntegrationInternal.application(MockApplicationStateProvider(applicationState: .background), didReceiveRemoteNotification: notification, fetchCompletionHandler: nil) - wait(for: [expectation1], timeout: testExpectationTimeout) + wait(for: [removalExpectation], timeout: testExpectationTimeout) + mockNotificationCenter.removeCallbacks(withIds: removalReference.callbackId) } func testSyncIsCalledOnLogin() { diff --git a/tests/unit-tests/InboxTests.swift b/tests/unit-tests/InboxTests.swift index 79f466015..c727b5f04 100644 --- a/tests/unit-tests/InboxTests.swift +++ b/tests/unit-tests/InboxTests.swift @@ -158,14 +158,15 @@ class InboxTests: XCTestCase { } func testRemove() { - let expectation1 = expectation(description: "testRemove") let mockInAppFetcher = MockInAppFetcher() + let mockNotificationCenter = MockNotificationCenter() let config = IterableConfig() config.logDelegate = AllLogDelegate() let internalAPI = InternalIterableAPI.initializeForTesting( config: config, - inAppFetcher: mockInAppFetcher + inAppFetcher: mockInAppFetcher, + notificationCenter: mockNotificationCenter ) let payload = """ @@ -191,27 +192,33 @@ class InboxTests: XCTestCase { } """.toJsonDict() - mockInAppFetcher.mockInAppPayloadFromServer(internalApi: internalAPI, payload).onSuccess { _ in - let messages = internalAPI.inAppManager.getInboxMessages() - XCTAssertEqual(messages.count, 2) - - let messageToRemove = messages[0] - internalAPI.inAppManager.remove( - message: messageToRemove, - location: .inbox, - source: .inboxSwipe, - successHandler: { _ in }, - failureHandler: { _, _ in } - ) - - DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { - let newMessages = internalAPI.inAppManager.getInboxMessages() - XCTAssertEqual(newMessages.count, 1) - expectation1.fulfill() - } + let initialInboxExpectation = expectation(description: "initial inbox load") + let initialReference = mockNotificationCenter.addCallback(forNotification: .iterableInboxChanged) { _ in + initialInboxExpectation.fulfill() } + mockInAppFetcher.mockInAppPayloadFromServer(internalApi: internalAPI, payload) + wait(for: [initialInboxExpectation], timeout: testExpectationTimeout) + mockNotificationCenter.removeCallbacks(withIds: initialReference.callbackId) - wait(for: [expectation1], timeout: testExpectationTimeout) + let messages = internalAPI.inAppManager.getInboxMessages() + XCTAssertEqual(messages.count, 2) + + let removalExpectation = expectation(description: "inbox changed after remove") + removalExpectation.assertForOverFulfill = true + let removalReference = mockNotificationCenter.addCallback(forNotification: .iterableInboxChanged) { _ in + XCTAssertEqual(internalAPI.inAppManager.getInboxMessages().count, 1) + removalExpectation.fulfill() + } + internalAPI.inAppManager.remove( + message: messages[0], + location: .inbox, + source: .inboxSwipe, + successHandler: { _ in }, + failureHandler: { _, _ in } + ) + + wait(for: [removalExpectation], timeout: testExpectationTimeout) + mockNotificationCenter.removeCallbacks(withIds: removalReference.callbackId) } func testShowInboxMessage() { diff --git a/tests/unit-tests/IterableAPITests.swift b/tests/unit-tests/IterableAPITests.swift index 376fcbc55..cc89f007a 100644 --- a/tests/unit-tests/IterableAPITests.swift +++ b/tests/unit-tests/IterableAPITests.swift @@ -1022,18 +1022,43 @@ class IterableAPITests: XCTestCase { wait(for: [expectation1], timeout: testExpectationTimeout) } - func testInAppConsume() { - let expectation1 = expectation(description: "get in app messages") + func testInAppConsumeRemovesInboxMessageBeforeNotifying() { let messageId = UUID().uuidString - + let message = IterableInAppMessage(messageId: messageId, + campaignId: 1, + trigger: IterableInAppTrigger(dict: [JsonKey.InApp.type: "never"]), + createdAt: nil, + expiresAt: nil, + content: IterableHtmlInAppContent(edgeInsets: .zero, html: ""), + saveToInbox: true, + inboxMetadata: nil, + customPayload: nil) + let mockInAppFetcher = MockInAppFetcher(messages: [message]) + let mockNotificationCenter = MockNotificationCenter() + let initialInboxExpectation = expectation(description: "initial inbox load") + let initialReference = mockNotificationCenter.addCallback(forNotification: .iterableInboxChanged) { _ in + initialInboxExpectation.fulfill() + } let networkSession = MockNetworkSession(statusCode: 200) let config = IterableConfig() - let internalAPI = InternalIterableAPI.initializeForTesting(apiKey: IterableAPITests.apiKey, config: config, networkSession: networkSession) + let internalAPI = InternalIterableAPI.initializeForTesting(apiKey: IterableAPITests.apiKey, + config: config, + networkSession: networkSession, + inAppFetcher: mockInAppFetcher, + notificationCenter: mockNotificationCenter) internalAPI.email = "user@example.com" - networkSession.callback = { _, response, _ in - guard let (request, body) = TestUtils.matchingRequest(networkSession: networkSession, - response: response, - endPoint: Const.Path.inAppConsume) else { + let oldImplementation = IterableAPI.implementation + IterableAPI.implementation = internalAPI + defer { IterableAPI.implementation = oldImplementation } + + wait(for: [initialInboxExpectation], timeout: testExpectationTimeout) + mockNotificationCenter.removeCallbacks(withIds: initialReference.callbackId) + XCTAssertEqual(internalAPI.inAppManager.getInboxMessages().count, 1) + + let requestExpectation = expectation(description: "in-app consume request") + requestExpectation.assertForOverFulfill = true + networkSession.requestCallback = { request in + guard request.url?.absoluteString.contains(Const.Path.inAppConsume) == true else { return } TestUtils.validate(request: request, @@ -1041,10 +1066,32 @@ class IterableAPITests: XCTestCase { apiEndPoint: Endpoint.api, path: Const.Path.inAppConsume, queryParams: []) - TestUtils.validateElementPresent(withName: "messageId", andValue: messageId, inDictionary: body) - expectation1.fulfill() + let body = request.httpBody!.json() as! [String: Any] + TestUtils.validateMessageContext(messageId: messageId, + email: IterableAPITests.email, + saveToInbox: true, + silentInbox: true, + location: .inbox, + inBody: body) + requestExpectation.fulfill() } - + + let notificationExpectation = expectation(description: "inbox changed after consume") + notificationExpectation.assertForOverFulfill = true + let notificationReference = mockNotificationCenter.addCallback(forNotification: .iterableInboxChanged) { _ in + XCTAssertEqual(internalAPI.inAppManager.getInboxMessages().count, 0) + notificationExpectation.fulfill() + } + + IterableAPI.inAppConsume(message: message, location: .inbox) + + wait(for: [requestExpectation, notificationExpectation], timeout: testExpectationTimeout) + XCTAssertEqual(networkSession.requests.filter { $0.url?.absoluteString.contains(Const.Path.inAppConsume) == true }.count, 1) + mockNotificationCenter.removeCallbacks(withIds: notificationReference.callbackId) + } + + func testInAppConsumeWithSourceRemovesInboxMessageBeforeNotifying() { + let messageId = "message1" let message = IterableInAppMessage(messageId: messageId, campaignId: 1, trigger: IterableInAppTrigger(dict: [JsonKey.InApp.type: "never"]), @@ -1054,50 +1101,56 @@ class IterableAPITests: XCTestCase { saveToInbox: true, inboxMetadata: nil, customPayload: nil) - internalAPI.inAppConsume(message: message) - wait(for: [expectation1], timeout: testExpectationTimeout) - } - - func testTrackInAppConsumeWithSource() { - let messageId = "message1" - let expectation1 = expectation(description: "testTrackInAppConsumeWithSource") - + let mockInAppFetcher = MockInAppFetcher(messages: [message]) + let mockNotificationCenter = MockNotificationCenter() + let initialInboxExpectation = expectation(description: "initial inbox load") + let initialReference = mockNotificationCenter.addCallback(forNotification: .iterableInboxChanged) { _ in + initialInboxExpectation.fulfill() + } let networkSession = MockNetworkSession(statusCode: 200) - let internalAPI = InternalIterableAPI.initializeForTesting(apiKey: IterableAPITests.apiKey, networkSession: networkSession) + let internalAPI = InternalIterableAPI.initializeForTesting(apiKey: IterableAPITests.apiKey, + networkSession: networkSession, + inAppFetcher: mockInAppFetcher, + notificationCenter: mockNotificationCenter) internalAPI.email = IterableAPITests.email - - networkSession.callback = { _, response, _ in - guard let (request, body) = TestUtils.matchingRequest(networkSession: networkSession, - response: response, - endPoint: Const.Path.inAppConsume) else { + let oldImplementation = IterableAPI.implementation + IterableAPI.implementation = internalAPI + defer { IterableAPI.implementation = oldImplementation } + + wait(for: [initialInboxExpectation], timeout: testExpectationTimeout) + mockNotificationCenter.removeCallbacks(withIds: initialReference.callbackId) + XCTAssertEqual(internalAPI.inAppManager.getInboxMessages().count, 1) + + let requestExpectation = expectation(description: "in-app consume request") + requestExpectation.assertForOverFulfill = true + networkSession.requestCallback = { request in + guard request.url?.absoluteString.contains(Const.Path.inAppConsume) == true else { return } - TestUtils.validate(request: request, requestType: .post, apiEndPoint: Endpoint.api, path: Const.Path.inAppConsume, queryParams: []) + let body = request.httpBody!.json() as! [String: Any] TestUtils.validateMessageContext(messageId: messageId, email: IterableAPITests.email, saveToInbox: true, silentInbox: true, location: .inbox, inBody: body) TestUtils.validateDeviceInfo(inBody: body, withDeviceId: internalAPI.deviceId) TestUtils.validateMatch(keyPath: KeyPath(string: "\(JsonKey.deleteAction)"), value: InAppDeleteSource.deleteButton.jsonValue as! String, inDictionary: body) - - expectation1.fulfill() + requestExpectation.fulfill() } - - let message = IterableInAppMessage(messageId: messageId, - campaignId: 1, - trigger: IterableInAppTrigger(dict: [JsonKey.InApp.type: "never"]), - createdAt: nil, - expiresAt: nil, - content: IterableHtmlInAppContent(edgeInsets: .zero, html: ""), - saveToInbox: true, - inboxMetadata: nil, - customPayload: nil) - - internalAPI.inAppConsume(message: message, location: .inbox, source: .deleteButton) - - wait(for: [expectation1], timeout: testExpectationTimeout) + + let notificationExpectation = expectation(description: "inbox changed after consume") + notificationExpectation.assertForOverFulfill = true + let notificationReference = mockNotificationCenter.addCallback(forNotification: .iterableInboxChanged) { _ in + XCTAssertEqual(internalAPI.inAppManager.getInboxMessages().count, 0) + notificationExpectation.fulfill() + } + + IterableAPI.inAppConsume(message: message, location: .inbox, source: .deleteButton) + + wait(for: [requestExpectation, notificationExpectation], timeout: testExpectationTimeout) + XCTAssertEqual(networkSession.requests.filter { $0.url?.absoluteString.contains(Const.Path.inAppConsume) == true }.count, 1) + mockNotificationCenter.removeCallbacks(withIds: notificationReference.callbackId) } func testUpdateSubscriptions() { From 72313d957f17c43a07dfa09b453cd0116999d12d Mon Sep 17 00:00:00 2001 From: Sumeru Chatterjee Date: Tue, 21 Jul 2026 12:15:26 +0100 Subject: [PATCH 02/15] SDK-496 Post iterableInboxChanged only for inbox messages Removing a popup or JSON-only message announced an inbox change that never happened. removePrivate and silent-push removal now post only when the removed message was an inbox message. Silent-push removal also persisted the pre-removal message map because the value-type snapshot was captured before removeValue; it now persists the map after removal. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 2 +- swift-sdk/Internal/in-app/InAppManager.swift | 13 +++--- tests/unit-tests/InAppTests.swift | 44 ++++++++++++++++++ tests/unit-tests/InboxTests.swift | 25 ++++++++++ tests/unit-tests/IterableAPITests.swift | 48 ++++++++++++++++++++ 5 files changed, 124 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e302fb3d6..e4dc94a0c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ All notable changes to this project will be documented in this file. This project adheres to [Semantic Versioning](http://semver.org/). ## [Unreleased] ### Fixed -- Public `inAppConsume` APIs now remove messages locally and post `iterableInboxChanged`. Inbox change notifications now fire after the local state is updated. +- Public `inAppConsume` APIs now remove messages locally and post `iterableInboxChanged` after the local state is updated. The notification fires only when an inbox message changes, so removing popups or JSON-only messages no longer announces an inbox change. ## [6.7.4] ### Added diff --git a/swift-sdk/Internal/in-app/InAppManager.swift b/swift-sdk/Internal/in-app/InAppManager.swift index e55a8cfca..4899cd878 100644 --- a/swift-sdk/Internal/in-app/InAppManager.swift +++ b/swift-sdk/Internal/in-app/InAppManager.swift @@ -516,6 +516,7 @@ class InAppManager: NSObject, IterableInternalInAppManagerProtocol { failureHandler: OnFailureHandler? = nil) { ITBInfo() updateMessage(message, didProcessTrigger: true, consumed: true).onSuccess { [weak self] _ in + guard message.saveToInbox else { return } self?.callbackQueue.async { [weak self] in self?.notificationCenter.post(name: .iterableInboxChanged, object: self, userInfo: nil) } @@ -626,13 +627,11 @@ extension InAppManager: InAppNotifiable { ITBInfo() updateQueue.async { [weak self] in - if let _ = self?.messagesMap.filter({ $0.key == messageId }).first { - if let messagesMap = self?.messagesMap { - self?.messagesMap.removeValue(forKey: messageId) - self?.persister.persist(messagesMap.values) - } - } - self?.callbackQueue.async { [weak self] in + guard let self = self, let message = self.messagesMap[messageId] else { return } + self.messagesMap.removeValue(forKey: messageId) + self.persister.persist(self.messagesMap.values) + guard message.saveToInbox else { return } + self.callbackQueue.async { [weak self] in self?.notificationCenter.post(name: .iterableInboxChanged, object: self, userInfo: nil) } } diff --git a/tests/unit-tests/InAppTests.swift b/tests/unit-tests/InAppTests.swift index 208f93fc7..21f8301a6 100644 --- a/tests/unit-tests/InAppTests.swift +++ b/tests/unit-tests/InAppTests.swift @@ -1173,6 +1173,50 @@ class InAppTests: XCTestCase { wait(for: [removalExpectation], timeout: testExpectationTimeout) mockNotificationCenter.removeCallbacks(withIds: removalReference.callbackId) } + + func testInboxChangedIsNotCalledWhenNonInboxMessageIsRemovedInServer() { + let notification = """ + { + "itbl" : { + "messageId" : "background_notification", + "isGhostPush" : true + }, + "notificationType" : "InAppRemove", + "messageId" : "messageId" + } + """.toJsonDict() + + let message = IterableInAppMessage(messageId: "messageId", + campaignId: 1, + trigger: IterableInAppTrigger(dict: [JsonKey.InApp.type: "never"]), + content: IterableHtmlInAppContent(edgeInsets: .zero, html: ""), + saveToInbox: false) + let mockInAppFetcher = MockInAppFetcher(messages: [message]) + let mockNotificationCenter = MockNotificationCenter() + let config = IterableConfig() + let internalApi = InternalIterableAPI.initializeForTesting(config: config, + inAppFetcher: mockInAppFetcher, + notificationCenter: mockNotificationCenter) + XCTAssertEqual(internalApi.inAppManager.getMessages().count, 1) + + let notificationExpectation = expectation(description: "no inbox change for non-inbox server removal") + notificationExpectation.isInverted = true + let notificationReference = mockNotificationCenter.addCallback(forNotification: .iterableInboxChanged) { _ in + notificationExpectation.fulfill() + } + let appIntegrationInternal = InternalIterableAppIntegration(tracker: internalApi, + urlDelegate: config.urlDelegate, + customActionDelegate: config.customActionDelegate, + urlOpener: MockUrlOpener(), + inAppNotifiable: internalApi.inAppManager, + embeddedNotifiable: internalApi.embeddedManager) + + appIntegrationInternal.application(MockApplicationStateProvider(applicationState: .background), didReceiveRemoteNotification: notification, fetchCompletionHandler: nil) + + wait(for: [notificationExpectation], timeout: testExpectationTimeoutForInverted) + XCTAssertEqual(internalApi.inAppManager.getMessages().count, 0) + mockNotificationCenter.removeCallbacks(withIds: notificationReference.callbackId) + } func testSyncIsCalledOnLogin() { let expectation1 = expectation(description: "testSyncIsCalledOnLogin") diff --git a/tests/unit-tests/InboxTests.swift b/tests/unit-tests/InboxTests.swift index c727b5f04..7047acc0a 100644 --- a/tests/unit-tests/InboxTests.swift +++ b/tests/unit-tests/InboxTests.swift @@ -220,6 +220,31 @@ class InboxTests: XCTestCase { wait(for: [removalExpectation], timeout: testExpectationTimeout) mockNotificationCenter.removeCallbacks(withIds: removalReference.callbackId) } + + func testRemoveNonInboxMessageDoesNotPostInboxChanged() { + let message = IterableInAppMessage(messageId: "message1", + campaignId: 1, + trigger: IterableInAppTrigger(dict: [JsonKey.InApp.type: "never"]), + content: IterableHtmlInAppContent(edgeInsets: .zero, html: ""), + saveToInbox: false) + let mockInAppFetcher = MockInAppFetcher(messages: [message]) + let mockNotificationCenter = MockNotificationCenter() + let internalAPI = InternalIterableAPI.initializeForTesting(inAppFetcher: mockInAppFetcher, + notificationCenter: mockNotificationCenter) + XCTAssertEqual(internalAPI.inAppManager.getMessages().count, 1) + + let notificationExpectation = expectation(description: "no inbox change for non-inbox removal") + notificationExpectation.isInverted = true + let notificationReference = mockNotificationCenter.addCallback(forNotification: .iterableInboxChanged) { _ in + notificationExpectation.fulfill() + } + + internalAPI.inAppManager.remove(message: message, location: .inApp) + + wait(for: [notificationExpectation], timeout: testExpectationTimeoutForInverted) + XCTAssertEqual(internalAPI.inAppManager.getMessages().count, 0) + mockNotificationCenter.removeCallbacks(withIds: notificationReference.callbackId) + } func testShowInboxMessage() { let expectation1 = expectation(description: "testShowInboxMessage") diff --git a/tests/unit-tests/IterableAPITests.swift b/tests/unit-tests/IterableAPITests.swift index cc89f007a..9c267ffc5 100644 --- a/tests/unit-tests/IterableAPITests.swift +++ b/tests/unit-tests/IterableAPITests.swift @@ -1152,6 +1152,54 @@ class IterableAPITests: XCTestCase { XCTAssertEqual(networkSession.requests.filter { $0.url?.absoluteString.contains(Const.Path.inAppConsume) == true }.count, 1) mockNotificationCenter.removeCallbacks(withIds: notificationReference.callbackId) } + + func testInAppConsumeNonInboxMessageDoesNotPostInboxChanged() { + let messageId = "message1" + let message = IterableInAppMessage(messageId: messageId, + campaignId: 1, + trigger: IterableInAppTrigger(dict: [JsonKey.InApp.type: "never"]), + content: IterableHtmlInAppContent(edgeInsets: .zero, html: ""), + saveToInbox: false) + let mockInAppFetcher = MockInAppFetcher(messages: [message]) + let mockNotificationCenter = MockNotificationCenter() + let networkSession = MockNetworkSession(statusCode: 200) + let internalAPI = InternalIterableAPI.initializeForTesting(apiKey: IterableAPITests.apiKey, + networkSession: networkSession, + inAppFetcher: mockInAppFetcher, + notificationCenter: mockNotificationCenter) + internalAPI.email = IterableAPITests.email + let oldImplementation = IterableAPI.implementation + IterableAPI.implementation = internalAPI + defer { IterableAPI.implementation = oldImplementation } + + XCTAssertEqual(internalAPI.inAppManager.getMessages().count, 1) + + let requestExpectation = expectation(description: "in-app consume request") + networkSession.requestCallback = { request in + guard request.url?.absoluteString.contains(Const.Path.inAppConsume) == true else { return } + let body = request.httpBody!.json() as! [String: Any] + TestUtils.validateMessageContext(messageId: messageId, + email: IterableAPITests.email, + saveToInbox: false, + silentInbox: false, + location: .inApp, + inBody: body) + requestExpectation.fulfill() + } + + let notificationExpectation = expectation(description: "no inbox change for non-inbox consume") + notificationExpectation.isInverted = true + let notificationReference = mockNotificationCenter.addCallback(forNotification: .iterableInboxChanged) { _ in + notificationExpectation.fulfill() + } + + IterableAPI.inAppConsume(message: message, location: .inApp) + + wait(for: [requestExpectation, notificationExpectation], timeout: testExpectationTimeoutForInverted) + XCTAssertEqual(internalAPI.inAppManager.getMessages().count, 0) + XCTAssertEqual(networkSession.requests.filter { $0.url?.absoluteString.contains(Const.Path.inAppConsume) == true }.count, 1) + mockNotificationCenter.removeCallbacks(withIds: notificationReference.callbackId) + } func testUpdateSubscriptions() { let expectation1 = expectation(description: "update subscriptions") From 8003bedfcdcfb7779d7e3682582fe93ab12d5e2a Mon Sep 17 00:00:00 2001 From: Sumeru Chatterjee Date: Tue, 21 Jul 2026 12:59:14 +0100 Subject: [PATCH 03/15] SDK-496 Add JSON-only message availability signal JSON-only messages were handed to onNew once and auto-consumed, with no way to recover a payload the app missed during a cold start or background fetch; customers polled getMessages and diffed. Adds an availability contract: an optional onJsonOnlyMessageAvailable delegate method and iterableJsonOnlyInAppMessageAvailable notification, both on the main thread, backed by a durable identity-scoped unhandled queue persisted before either signal fires. Delivery is at least once until the app acknowledges via markJsonOnlyMessageHandled; unhandled records replay on foreground and are queryable with getUnhandledJsonOnlyMessages. Retention defaults (30 days, 100 records per identity, clear on identity change, immediate triggers only) are working values pending product confirmation. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 3 + swift-sdk/Core/Constants.swift | 1 + swift-sdk/Internal/EmptyInAppManager.swift | 10 + swift-sdk/Internal/InternalIterableAPI.swift | 11 + swift-sdk/Internal/IterableUserDefaults.swift | 9 + .../DependencyContainerProtocol.swift | 37 +- .../Internal/Utilities/LocalStorage.swift | 8 + .../Utilities/LocalStorageProtocol.swift | 7 + .../in-app/InAppManager+Functions.swift | 9 +- swift-sdk/Internal/in-app/InAppManager.swift | 198 +++++++++-- .../Internal/in-app/InAppPersistence.swift | 172 +++++++++ swift-sdk/SDK/IterableAPI.swift | 20 ++ swift-sdk/SDK/IterableConfig.swift | 5 + swift-sdk/SDK/IterableMessaging.swift | 3 + tests/common/MockInAppDelegate.swift | 5 + tests/common/MockLocalStorage.swift | 2 + tests/unit-tests/InAppTests.swift | 328 ++++++++++++++++++ .../unit-tests/IterableDataRegionObjCTests.m | 19 +- 18 files changed, 804 insertions(+), 43 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e4dc94a0c..bfb14aa0d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,9 @@ All notable changes to this project will be documented in this file. This project adheres to [Semantic Versioning](http://semver.org/). ## [Unreleased] +### Added +- Added at least once delivery for JSON-only in-app messages through `IterableInAppDelegate.onJsonOnlyMessageAvailable(message:)` and `iterableJsonOnlyInAppMessageAvailable`. Unhandled messages remain available through `IterableAPI.getUnhandledJsonOnlyMessages()` until acknowledged with `markJsonOnlyMessageHandled(messageId:)`. + ### Fixed - Public `inAppConsume` APIs now remove messages locally and post `iterableInboxChanged` after the local state is updated. The notification fires only when an inbox message changes, so removing popups or JSON-only messages no longer announces an inbox change. diff --git a/swift-sdk/Core/Constants.swift b/swift-sdk/Core/Constants.swift index 97215deb7..4caf23d1b 100644 --- a/swift-sdk/Core/Constants.swift +++ b/swift-sdk/Core/Constants.swift @@ -96,6 +96,7 @@ enum Const { static let visitorConsentTimestamp = "itbl_visitor_consent_timestamp" static let isNotificationsEnabled = "itbl_isNotificationsEnabled" static let hasStoredNotificationSetting = "itbl_hasStoredNotificationSetting" + static let jsonOnlyMessageQueue = "itbl_json_only_message_queue" static let attributionInfoExpiration = 24 } diff --git a/swift-sdk/Internal/EmptyInAppManager.swift b/swift-sdk/Internal/EmptyInAppManager.swift index 26a584757..5c0fd4daf 100644 --- a/swift-sdk/Internal/EmptyInAppManager.swift +++ b/swift-sdk/Internal/EmptyInAppManager.swift @@ -10,6 +10,16 @@ class EmptyInAppManager: IterableInternalInAppManagerProtocol { func start() -> Pending { Fulfill(value: true) } + + func getUnhandledJsonOnlyMessages() -> [IterableInAppMessage] { + [] + } + + func markJsonOnlyMessageHandled(messageId _: String) -> Bool { + false + } + + func clearUnhandledJsonOnlyMessages() {} func handleClick(clickedUrl _: URL?, forMessage _: IterableInAppMessage, location _: InAppLocation, inboxSessionId _: String?) {} diff --git a/swift-sdk/Internal/InternalIterableAPI.swift b/swift-sdk/Internal/InternalIterableAPI.swift index f5c1e3b41..ca5cca430 100644 --- a/swift-sdk/Internal/InternalIterableAPI.swift +++ b/swift-sdk/Internal/InternalIterableAPI.swift @@ -88,6 +88,7 @@ final class InternalIterableAPI: NSObject, PushTrackerProtocol, AuthProvider { apiClient: self.apiClient, requestHandler: self.requestHandler, deviceMetadata: deviceMetadata, + authProvider: self, authManager: self.authManager) }() @@ -264,6 +265,8 @@ final class InternalIterableAPI: NSObject, PushTrackerProtocol, AuthProvider { disableDeviceForCurrentUser(withOnSuccess: onSuccess, onFailure: onFailure) } + inAppManager.clearUnhandledJsonOnlyMessages() + _email = nil _userId = nil @@ -748,6 +751,14 @@ final class InternalIterableAPI: NSObject, PushTrackerProtocol, AuthProvider { onSuccess: onSuccess, onFailure: onFailure) } + + func getUnhandledJsonOnlyMessages() -> [IterableInAppMessage] { + inAppManager.getUnhandledJsonOnlyMessages() + } + + func markJsonOnlyMessageHandled(messageId: String) -> Bool { + inAppManager.markJsonOnlyMessageHandled(messageId: messageId) + } @discardableResult func track(embeddedMessageReceived message: IterableEmbeddedMessage, diff --git a/swift-sdk/Internal/IterableUserDefaults.swift b/swift-sdk/Internal/IterableUserDefaults.swift index 2836655e7..5476cc0ee 100644 --- a/swift-sdk/Internal/IterableUserDefaults.swift +++ b/swift-sdk/Internal/IterableUserDefaults.swift @@ -211,6 +211,14 @@ class IterableUserDefaults { save(bool: newValue, withKey: .hasStoredNotificationSetting) } } + + var jsonOnlyMessageQueueData: Data? { + get { + userDefaults.data(forKey: UserDefaultsKey.jsonOnlyMessageQueue.value) + } set { + userDefaults.set(newValue, forKey: UserDefaultsKey.jsonOnlyMessageQueue.value) + } + } func getAttributionInfo(currentDate: Date) -> IterableAttributionInfo? { (try? codable(withKey: .attributionInfo, currentDate: currentDate)) ?? nil @@ -389,6 +397,7 @@ class IterableUserDefaults { static let isNotificationsEnabled = UserDefaultsKey(value: Const.UserDefault.isNotificationsEnabled) static let hasStoredNotificationSetting = UserDefaultsKey(value: Const.UserDefault.hasStoredNotificationSetting) + static let jsonOnlyMessageQueue = UserDefaultsKey(value: Const.UserDefault.jsonOnlyMessageQueue) } private struct Envelope: Codable { let payload: Data diff --git a/swift-sdk/Internal/Utilities/DependencyContainerProtocol.swift b/swift-sdk/Internal/Utilities/DependencyContainerProtocol.swift index 61337fc3d..75fd741c1 100644 --- a/swift-sdk/Internal/Utilities/DependencyContainerProtocol.swift +++ b/swift-sdk/Internal/Utilities/DependencyContainerProtocol.swift @@ -34,22 +34,29 @@ extension DependencyContainerProtocol { apiClient: ApiClientProtocol, requestHandler: RequestHandlerProtocol, deviceMetadata: DeviceMetadata, + authProvider: AuthProvider, authManager: IterableAuthManagerProtocol?) -> IterableInternalInAppManagerProtocol { - InAppManager(requestHandler: requestHandler, - deviceMetadata: deviceMetadata, - fetcher: createInAppFetcher(apiClient: apiClient, authManager: authManager), - displayer: inAppDisplayer, - persister: inAppPersister, - inAppDelegate: config.inAppDelegate, - inAppDisplayDelegate: config.inAppDisplayDelegate, - urlDelegate: config.urlDelegate, - customActionDelegate: config.customActionDelegate, - urlOpener: urlOpener, - allowedProtocols: config.allowedProtocols, - applicationStateProvider: applicationStateProvider, - notificationCenter: notificationCenter, - dateProvider: dateProvider, - moveToForegroundSyncInterval: config.inAppDisplayInterval) + let jsonOnlyMessageStore = JsonOnlyMessageStore(localStorage: localStorage, + dateProvider: dateProvider, + identityProvider: { [weak authProvider] in + UserIdentitySnapshot(auth: authProvider?.auth) + }) + return InAppManager(requestHandler: requestHandler, + deviceMetadata: deviceMetadata, + fetcher: createInAppFetcher(apiClient: apiClient, authManager: authManager), + displayer: inAppDisplayer, + persister: inAppPersister, + inAppDelegate: config.inAppDelegate, + inAppDisplayDelegate: config.inAppDisplayDelegate, + urlDelegate: config.urlDelegate, + customActionDelegate: config.customActionDelegate, + urlOpener: urlOpener, + allowedProtocols: config.allowedProtocols, + applicationStateProvider: applicationStateProvider, + notificationCenter: notificationCenter, + dateProvider: dateProvider, + jsonOnlyMessageStore: jsonOnlyMessageStore, + moveToForegroundSyncInterval: config.inAppDisplayInterval) } func createAuthManager(config: IterableConfig) -> IterableAuthManagerProtocol { diff --git a/swift-sdk/Internal/Utilities/LocalStorage.swift b/swift-sdk/Internal/Utilities/LocalStorage.swift index 83e64946e..6b095cd65 100644 --- a/swift-sdk/Internal/Utilities/LocalStorage.swift +++ b/swift-sdk/Internal/Utilities/LocalStorage.swift @@ -154,6 +154,14 @@ struct LocalStorage: LocalStorageProtocol { iterableUserDefaults.hasStoredNotificationSetting = newValue } } + + var jsonOnlyMessageQueueData: Data? { + get { + iterableUserDefaults.jsonOnlyMessageQueueData + } set { + iterableUserDefaults.jsonOnlyMessageQueueData = newValue + } + } func getAttributionInfo(currentDate: Date) -> IterableAttributionInfo? { iterableUserDefaults.getAttributionInfo(currentDate: currentDate) diff --git a/swift-sdk/Internal/Utilities/LocalStorageProtocol.swift b/swift-sdk/Internal/Utilities/LocalStorageProtocol.swift index 7a479c945..d5bf5392b 100644 --- a/swift-sdk/Internal/Utilities/LocalStorageProtocol.swift +++ b/swift-sdk/Internal/Utilities/LocalStorageProtocol.swift @@ -38,6 +38,8 @@ protocol LocalStorageProtocol { var isNotificationsEnabled: Bool { get set } var hasStoredNotificationSetting: Bool { get set } + + var jsonOnlyMessageQueueData: Data? { get set } func getAttributionInfo(currentDate: Date) -> IterableAttributionInfo? @@ -49,6 +51,11 @@ protocol LocalStorageProtocol { } extension LocalStorageProtocol { + var jsonOnlyMessageQueueData: Data? { + get { nil } + set {} + } + func upgrade() { } diff --git a/swift-sdk/Internal/in-app/InAppManager+Functions.swift b/swift-sdk/Internal/in-app/InAppManager+Functions.swift index 8e0c93fad..9c50b3d43 100644 --- a/swift-sdk/Internal/in-app/InAppManager+Functions.swift +++ b/swift-sdk/Internal/in-app/InAppManager+Functions.swift @@ -7,6 +7,7 @@ import Foundation enum MessagesProcessorResult { case show(message: IterableInAppMessage, messagesMap: OrderedDictionary) case noShow(message: IterableInAppMessage?, messagesMap: OrderedDictionary) + case jsonOnly(message: IterableInAppMessage, messagesMap: OrderedDictionary) } struct MessagesProcessor { @@ -33,6 +34,8 @@ struct MessagesProcessor { case let .skipAndConsume(message): updateMessage(message, didProcessTrigger: true, consumed: true) return .noShow(message: message, messagesMap: messagesMap) + case let .jsonOnly(message): + return .jsonOnly(message: message, messagesMap: messagesMap) case .none, .wait: return .noShow(message: nil, messagesMap: messagesMap) } @@ -42,6 +45,7 @@ struct MessagesProcessor { case show(IterableInAppMessage) case skip(IterableInAppMessage) case skipAndConsume(IterableInAppMessage) + case jsonOnly(IterableInAppMessage) case none case wait } @@ -63,10 +67,11 @@ struct MessagesProcessor { ITBDebug("isOkToShowNow") - let returnValue = inAppDelegate.onNew(message: message) if message.isJsonOnly { - return .skipAndConsume(message) + return .jsonOnly(message) } + + let returnValue = inAppDelegate.onNew(message: message) if returnValue == .show { ITBDebug("delegate returned show") return .show(message) diff --git a/swift-sdk/Internal/in-app/InAppManager.swift b/swift-sdk/Internal/in-app/InAppManager.swift index 4899cd878..0840fa06d 100644 --- a/swift-sdk/Internal/in-app/InAppManager.swift +++ b/swift-sdk/Internal/in-app/InAppManager.swift @@ -11,6 +11,10 @@ protocol InAppDisplayChecker { protocol IterableInternalInAppManagerProtocol: IterableInAppManagerProtocol, InAppNotifiable, InAppDisplayChecker { func start() -> Pending + + func getUnhandledJsonOnlyMessages() -> [IterableInAppMessage] + func markJsonOnlyMessageHandled(messageId: String) -> Bool + func clearUnhandledJsonOnlyMessages() /// Use this method to handle clicks in InApp Messages /// - parameter clickedUrl: The url that is clicked. @@ -51,6 +55,7 @@ class InAppManager: NSObject, IterableInternalInAppManagerProtocol { applicationStateProvider: ApplicationStateProviderProtocol, notificationCenter: NotificationCenterProtocol, dateProvider: DateProviderProtocol, + jsonOnlyMessageStore: JsonOnlyMessageStore, moveToForegroundSyncInterval: Double) { ITBInfo() @@ -68,6 +73,7 @@ class InAppManager: NSObject, IterableInternalInAppManagerProtocol { self.applicationStateProvider = applicationStateProvider self.notificationCenter = notificationCenter self.dateProvider = dateProvider + self.jsonOnlyMessageStore = jsonOnlyMessageStore self.moveToForegroundSyncInterval = moveToForegroundSyncInterval super.init() @@ -117,6 +123,18 @@ class InAppManager: NSObject, IterableInternalInAppManagerProtocol { func getUnreadInboxMessagesCount() -> Int { getInboxMessages().filter { $0.read == false }.count } + + func getUnhandledJsonOnlyMessages() -> [IterableInAppMessage] { + jsonOnlyMessageStore.getMessages() + } + + func markJsonOnlyMessageHandled(messageId: String) -> Bool { + jsonOnlyMessageStore.remove(messageId: messageId) + } + + func clearUnhandledJsonOnlyMessages() { + jsonOnlyMessageStore.clear() + } func show(message: IterableInAppMessage) { ITBInfo() @@ -192,7 +210,9 @@ class InAppManager: NSObject, IterableInternalInAppManagerProtocol { } } - return scheduleSync() + return replayUnhandledJsonOnlyMessages().flatMap { [weak self] _ in + self?.scheduleSync() ?? Fulfill(value: true) + } } func handleClick(clickedUrl url: URL?, forMessage message: IterableInAppMessage, location: InAppLocation, inboxSessionId: String?) { @@ -227,6 +247,14 @@ class InAppManager: NSObject, IterableInternalInAppManagerProtocol { @objc private func onAppEnteredForeground(notification _: Notification) { ITBInfo() + + replayUnhandledJsonOnlyMessages().onSuccess { [weak self] _ in + self?.processForegroundMessages() + } + } + + private func processForegroundMessages() { + ITBInfo() let waitTime = InAppManager.getWaitTimeInterval(fromLastTime: lastSyncTime, currentTime: dateProvider.currentDate, gap: moveToForegroundSyncInterval) @@ -246,8 +274,8 @@ class InAppManager: NSObject, IterableInternalInAppManagerProtocol { .map { [weak self] in self?.mergeMessages($0) ?? MergeMessagesResult(inboxChanged: false, messagesMap: [:], deliveredMessages: []) } - .map { [weak self] in - self?.processMergedMessages(appIsReady: appIsReady, mergeMessagesResult: $0) ?? true + .flatMap { [weak self] in + self?.processMergedMessages(appIsReady: appIsReady, mergeMessagesResult: $0) ?? Fulfill(value: true) } } @@ -256,23 +284,26 @@ class InAppManager: NSObject, IterableInternalInAppManagerProtocol { MessagesObtainedHandler(messagesMap: messagesMap, messages: messages).handle() } - private func processMergedMessages(appIsReady: Bool, mergeMessagesResult: MergeMessagesResult) -> Bool { + private func processMergedMessages(appIsReady: Bool, mergeMessagesResult: MergeMessagesResult) -> Pending { + let processingResult: Pending if appIsReady { - processAndShowMessage(messagesMap: mergeMessagesResult.messagesMap) + processingResult = processAndShowMessage(messagesMap: mergeMessagesResult.messagesMap) } else { messagesMap = mergeMessagesResult.messagesMap + persistEligibleJsonOnlyMessages() + processingResult = Fulfill(value: true) } - - // track in-app delivery - mergeMessagesResult.deliveredMessages.forEach { - requestHandler?.track(inAppDelivery: $0, - onSuccess: nil, - onFailure: nil) + + return processingResult.map { [weak self] _ in + mergeMessagesResult.deliveredMessages.forEach { + self?.requestHandler?.track(inAppDelivery: $0, + onSuccess: nil, + onFailure: nil) + } + + self?.finishSync(inboxChanged: mergeMessagesResult.inboxChanged) + return true } - - finishSync(inboxChanged: mergeMessagesResult.inboxChanged) - - return true } private func finishSync(inboxChanged: Bool) { @@ -294,6 +325,8 @@ class InAppManager: NSObject, IterableInternalInAppManagerProtocol { return messagesMap case .show(message: _, messagesMap: let messagesMap): return messagesMap + case .jsonOnly(message: _, messagesMap: let messagesMap): + return messagesMap } } @@ -307,19 +340,22 @@ class InAppManager: NSObject, IterableInternalInAppManagerProtocol { } } - private func processAndShowMessage(messagesMap: OrderedDictionary) { + private func processAndShowMessage(messagesMap: OrderedDictionary) -> Pending { var processor = MessagesProcessor(inAppDelegate: inAppDelegate, inAppDisplayChecker: self, messagesMap: messagesMap) let messagesProcessorResult = processor.processMessages() self.messagesMap = getMessagesMap(fromMessagesProcessorResult: messagesProcessorResult) - if case let .noShow(message, _) = messagesProcessorResult, - let message = message, message.isJsonOnly { - requestHandler?.inAppConsume(message.messageId, - onSuccess: nil, - onFailure: nil) + if case let .jsonOnly(message, _) = messagesProcessorResult { + return deliverJsonOnlyMessage(message, consumeOnReplay: true).flatMap { [weak self] processed in + guard processed, let self = self else { + return Fulfill(value: true) + } + return self.processAndShowMessage(messagesMap: self.messagesMap) + } } showMessage(fromMessagesProcessorResult: messagesProcessorResult) + return Fulfill(value: true) } private func showInternal(message: IterableInAppMessage, @@ -372,10 +408,15 @@ class InAppManager: NSObject, IterableInternalInAppManagerProtocol { } private func processExistingMessages() { - _ = InAppManager.getAppIsReady(applicationStateProvider: applicationStateProvider, displayer: displayer).map { [weak self] appIsActive in - if appIsActive, let messagesMap = self?.messagesMap { - self?.processAndShowMessage(messagesMap: messagesMap) - self?.persister.persist(messagesMap.values) + _ = InAppManager.getAppIsReady(applicationStateProvider: applicationStateProvider, displayer: displayer).flatMap { [weak self] appIsActive in + guard appIsActive, let self = self else { + return Fulfill(value: true) + } + return self.processAndShowMessage(messagesMap: self.messagesMap).map { [weak self] _ in + if let messagesMap = self?.messagesMap { + self?.persister.persist(messagesMap.values) + } + return true } } } @@ -506,6 +547,112 @@ class InAppManager: NSObject, IterableInternalInAppManagerProtocol { messagesMap[message.messageId] = message } } + + private func persistEligibleJsonOnlyMessages() { + messagesMap.values + .filter { $0.isJsonOnly && !$0.didProcessTrigger && !$0.consumed && !$0.read && $0.trigger.type == .immediate } + .forEach { jsonOnlyMessageStore.enqueue($0) } + } + + private func replayUnhandledJsonOnlyMessages() -> Pending { + guard applicationStateProvider.applicationState == .active else { + return Fulfill(value: true) + } + + return jsonOnlyMessageStore.getMessages().reduce(Fulfill(value: true) as Pending) { pending, message in + pending.flatMap { [weak self] _ in + self?.deliverJsonOnlyMessage(message, consumeOnReplay: false) ?? Fulfill(value: true) + } + } + } + + private func deliverJsonOnlyMessage(_ message: IterableInAppMessage, consumeOnReplay: Bool) -> Pending { + let result = Fulfill() + + guard jsonOnlyMessageStore.enqueue(message) else { + if consumeOnReplay && !jsonOnlyMessageStore.hasCurrentIdentity { + deliverJsonOnlyMessageWithoutAvailability(message, result: result) + } else { + result.resolve(with: false) + } + return result + } + + let deliver = { [weak self] in + guard let self = self else { + result.resolve(with: false) + return + } + + guard self.applicationStateProvider.applicationState == .active, + let delivery = self.jsonOnlyMessageStore.prepareDelivery(for: message) else { + result.resolve(with: false) + return + } + + if delivery.isInitial { + _ = self.inAppDelegate.onNew(message: delivery.message) + } + self.inAppDelegate.onJsonOnlyMessageAvailable?(message: delivery.message) + self.notificationCenter.post(name: .iterableJsonOnlyInAppMessageAvailable, + object: delivery.message, + userInfo: nil) + + guard delivery.isInitial || consumeOnReplay else { + result.resolve(with: true) + return + } + + self.updateQueue.async { [weak self] in + guard let self = self else { + result.resolve(with: false) + return + } + self.updateMessageSync(message, didProcessTrigger: true, consumed: true) + self.requestHandler?.inAppConsume(message.messageId, + onSuccess: nil, + onFailure: nil) + result.resolve(with: true) + } + } + + if Thread.isMainThread { + deliver() + } else { + DispatchQueue.main.async(execute: deliver) + } + + return result + } + + private func deliverJsonOnlyMessageWithoutAvailability(_ message: IterableInAppMessage, + result: Fulfill) { + let deliver = { [weak self] in + guard let self = self else { + result.resolve(with: false) + return + } + + _ = self.inAppDelegate.onNew(message: message) + self.updateQueue.async { [weak self] in + guard let self = self else { + result.resolve(with: false) + return + } + self.updateMessageSync(message, didProcessTrigger: true, consumed: true) + self.requestHandler?.inAppConsume(message.messageId, + onSuccess: nil, + onFailure: nil) + result.resolve(with: true) + } + } + + if Thread.isMainThread { + deliver() + } else { + DispatchQueue.main.async(execute: deliver) + } + } // From client side private func removePrivate(message: IterableInAppMessage, @@ -572,6 +719,7 @@ class InAppManager: NSObject, IterableInternalInAppManagerProtocol { private let notificationCenter: NotificationCenterProtocol private let persister: InAppPersistenceProtocol + private let jsonOnlyMessageStore: JsonOnlyMessageStore private var messagesMap = OrderedDictionary() private let dateProvider: DateProviderProtocol private var lastDismissedTime: Date? diff --git a/swift-sdk/Internal/in-app/InAppPersistence.swift b/swift-sdk/Internal/in-app/InAppPersistence.swift index 1222cfd7b..bc32f0c91 100644 --- a/swift-sdk/Internal/in-app/InAppPersistence.swift +++ b/swift-sdk/Internal/in-app/InAppPersistence.swift @@ -388,6 +388,178 @@ protocol InAppPersistenceProtocol { func clear() } +final class JsonOnlyMessageStore { + struct Delivery { + let message: IterableInAppMessage + let isInitial: Bool + } + + init(localStorage: LocalStorageProtocol, + dateProvider: DateProviderProtocol, + identityProvider: @escaping () -> UserIdentitySnapshot?) { + self.localStorage = localStorage + self.dateProvider = dateProvider + self.identityProvider = identityProvider + } + + @discardableResult + func enqueue(_ message: IterableInAppMessage) -> Bool { + stateQueue.sync { + guard var state = loadCurrentState() else { return false } + + if state.entries.contains(where: { $0.message.messageId == message.messageId }) { + return true + } + + state.entries.append(Entry(message: message, + storedAt: dateProvider.currentDate, + didBeginInitialDelivery: false)) + state.entries = Array(state.entries.suffix(Self.maximumRecordCount)) + return persist(state) + } + } + + func prepareDelivery(for message: IterableInAppMessage) -> Delivery? { + stateQueue.sync { + guard var state = loadCurrentState() else { return nil } + + if let index = state.entries.firstIndex(where: { $0.message.messageId == message.messageId }) { + let isInitial = !state.entries[index].didBeginInitialDelivery + if isInitial { + state.entries[index].didBeginInitialDelivery = true + guard persist(state) else { return nil } + } + return Delivery(message: state.entries[index].message, isInitial: isInitial) + } + + state.entries.append(Entry(message: message, + storedAt: dateProvider.currentDate, + didBeginInitialDelivery: true)) + state.entries = Array(state.entries.suffix(Self.maximumRecordCount)) + guard persist(state) else { return nil } + return Delivery(message: message, isInitial: true) + } + } + + func getMessages() -> [IterableInAppMessage] { + stateQueue.sync { + loadCurrentState()?.entries.map(\.message) ?? [] + } + } + + var hasCurrentIdentity: Bool { + stateQueue.sync { + identityProvider() != nil + } + } + + @discardableResult + func remove(messageId: String) -> Bool { + stateQueue.sync { + guard var state = loadCurrentState(), + let index = state.entries.firstIndex(where: { $0.message.messageId == messageId }) else { + return false + } + + state.entries.remove(at: index) + return persist(state) + } + } + + func clear() { + stateQueue.sync { + localStorage.jsonOnlyMessageQueueData = nil + } + } + + private struct StoredIdentity: Codable, Equatable { + enum Kind: String, Codable { + case email + case userId + } + + let kind: Kind + let value: String + + init(_ snapshot: UserIdentitySnapshot) { + switch snapshot { + case let .email(email): + kind = .email + value = email + case let .userId(userId): + kind = .userId + value = userId + } + } + } + + private struct Entry: Codable { + let message: IterableInAppMessage + let storedAt: Date + var didBeginInitialDelivery: Bool + } + + private struct State: Codable { + let identity: StoredIdentity + var entries: [Entry] + } + + private func loadCurrentState() -> State? { + guard let snapshot = identityProvider() else { return nil } + + let identity = StoredIdentity(snapshot) + var state: State + if let data = localStorage.jsonOnlyMessageQueueData { + do { + state = try JSONDecoder().decode(State.self, from: data) + } catch { + ITBError("Unable to decode unhandled JSON-only messages: \(error.localizedDescription)") + state = State(identity: identity, entries: []) + } + } else { + state = State(identity: identity, entries: []) + } + + if state.identity != identity { + state = State(identity: identity, entries: []) + } + + let currentDate = dateProvider.currentDate + let retainedEntries = state.entries.filter { entry in + if let expiresAt = entry.message.expiresAt { + return expiresAt > currentDate + } + return entry.storedAt.addingTimeInterval(Self.fallbackRetentionPeriod) > currentDate + } + + if retainedEntries.count != state.entries.count { + state.entries = retainedEntries + } + + guard persist(state) else { return nil } + return state + } + + private func persist(_ state: State) -> Bool { + do { + localStorage.jsonOnlyMessageQueueData = try JSONEncoder().encode(state) + return true + } catch { + ITBError("Unable to persist unhandled JSON-only messages: \(error.localizedDescription)") + return false + } + } + + // Product defaults pending confirmation. + private static let fallbackRetentionPeriod: TimeInterval = 30 * 24 * 60 * 60 + private static let maximumRecordCount = 100 + + private var localStorage: LocalStorageProtocol + private let dateProvider: DateProviderProtocol + private let identityProvider: () -> UserIdentitySnapshot? + private let stateQueue = DispatchQueue(label: "JsonOnlyMessageStore") +} + class InAppInMemoryPersister: InAppPersistenceProtocol { func getMessages() -> [IterableInAppMessage] { [] diff --git a/swift-sdk/SDK/IterableAPI.swift b/swift-sdk/SDK/IterableAPI.swift index 33f6c6d2d..a349ae01a 100644 --- a/swift-sdk/SDK/IterableAPI.swift +++ b/swift-sdk/SDK/IterableAPI.swift @@ -937,6 +937,26 @@ import UIKit implementation.inAppManager.remove(message: message, location: location, source: source) } + + /// Returns JSON-only messages for the current user that have not been marked as handled. + @objc + public static func getUnhandledJsonOnlyMessages() -> [IterableInAppMessage] { + guard let implementation, implementation.isSDKInitialized() else { return [] } + + return implementation.getUnhandledJsonOnlyMessages() + } + + /// Marks a JSON-only message as handled locally. This does not send an `inAppConsume` request. + /// + /// - Parameter messageId: The ID of the message to remove from the unhandled queue. + /// - Returns: `true` when an unhandled message was removed. + @objc(markJsonOnlyMessageHandled:) + @discardableResult + public static func markJsonOnlyMessageHandled(messageId: String) -> Bool { + guard let implementation, implementation.isSDKInitialized() else { return false } + + return implementation.markJsonOnlyMessageHandled(messageId: messageId) + } /// Tracks analytics data from a session of using an inbox UI /// NOTE: this is not normally used publicly, but is needed for our React Native SDK implementation diff --git a/swift-sdk/SDK/IterableConfig.swift b/swift-sdk/SDK/IterableConfig.swift index 7760371e8..e5d09c006 100644 --- a/swift-sdk/SDK/IterableConfig.swift +++ b/swift-sdk/SDK/IterableConfig.swift @@ -75,6 +75,11 @@ public struct IterableAPIMobileFrameworkInfo: Codable { /// - Returns:Return `show` to show the in-app or `skip` to skip this. @objc(onNewMessage:) func onNew(message: IterableInAppMessage) -> InAppShowResponse + + /// Called on the main thread when a JSON-only message is available locally. + /// This may be called more than once until the message is marked as handled. + @objc(onJsonOnlyMessageAvailable:) + optional func onJsonOnlyMessageAvailable(message: IterableInAppMessage) } /// The protocol for adjusting logging diff --git a/swift-sdk/SDK/IterableMessaging.swift b/swift-sdk/SDK/IterableMessaging.swift index 2cd9d51c8..aefda0fbd 100644 --- a/swift-sdk/SDK/IterableMessaging.swift +++ b/swift-sdk/SDK/IterableMessaging.swift @@ -17,6 +17,9 @@ import UIKit public extension Notification.Name { /// This is fired when in app inbox changes. static let iterableInboxChanged = Notification.Name(rawValue: "itbl_inbox_changed") + + /// This is fired when a JSON-only in-app message is available locally. + static let iterableJsonOnlyInAppMessageAvailable = Notification.Name(rawValue: "itbl_json_only_in_app_message_available") } @objcMembers open class DefaultInAppDelegate: IterableInAppDelegate { diff --git a/tests/common/MockInAppDelegate.swift b/tests/common/MockInAppDelegate.swift index fb074ea37..72b7121bf 100644 --- a/tests/common/MockInAppDelegate.swift +++ b/tests/common/MockInAppDelegate.swift @@ -8,6 +8,7 @@ import Foundation class MockInAppDelegate: IterableInAppDelegate { var onNewMessageCallback: ((IterableInAppMessage) -> Void)? + var onJsonOnlyMessageAvailableCallback: ((IterableInAppMessage) -> Void)? init(showInApp: InAppShowResponse = .show) { self.showInApp = showInApp @@ -17,6 +18,10 @@ class MockInAppDelegate: IterableInAppDelegate { onNewMessageCallback?(message) return showInApp } + + func onJsonOnlyMessageAvailable(message: IterableInAppMessage) { + onJsonOnlyMessageAvailableCallback?(message) + } private let showInApp: InAppShowResponse } diff --git a/tests/common/MockLocalStorage.swift b/tests/common/MockLocalStorage.swift index 4b48fb25b..cd430465a 100644 --- a/tests/common/MockLocalStorage.swift +++ b/tests/common/MockLocalStorage.swift @@ -41,6 +41,8 @@ class MockLocalStorage: LocalStorageProtocol { var isNotificationsEnabled: Bool = false var hasStoredNotificationSetting: Bool = false + + var jsonOnlyMessageQueueData: Data? func getAttributionInfo(currentDate: Date) -> IterableAttributionInfo? { guard !MockLocalStorage.isExpired(expiration: attributionInfoExpiration, currentDate: currentDate) else { diff --git a/tests/unit-tests/InAppTests.swift b/tests/unit-tests/InAppTests.swift index 21f8301a6..070653e38 100644 --- a/tests/unit-tests/InAppTests.swift +++ b/tests/unit-tests/InAppTests.swift @@ -1961,6 +1961,334 @@ class InAppTests: XCTestCase { } +private final class LegacySwiftInAppDelegate: NSObject, IterableInAppDelegate { + func onNew(message _: IterableInAppMessage) -> InAppShowResponse { + .show + } +} + +final class JsonOnlyMessageAvailabilityTests: XCTestCase { + override func tearDown() { + IterableAPI.implementation = nil + super.tearDown() + } + + func testAvailabilityPersistsBeforeOrderedMainThreadSignals() { + let onNewExpectation = expectation(description: "legacy onNew") + let availabilityExpectation = expectation(description: "availability delegate") + let notificationExpectation = expectation(description: "availability notification") + let consumeExpectation = expectation(description: "consume request") + let fetcher = MockInAppFetcher() + let notificationCenter = MockNotificationCenter() + let networkSession = MockNetworkSession() + let delegate = MockInAppDelegate() + let message = makeJsonOnlyMessage(id: "message-1") + var order = [String]() + + delegate.onNewMessageCallback = { deliveredMessage in + XCTAssertTrue(Thread.isMainThread) + XCTAssertEqual(IterableAPI.getUnhandledJsonOnlyMessages().map(\.messageId), [deliveredMessage.messageId]) + order.append("onNew") + onNewExpectation.fulfill() + } + delegate.onJsonOnlyMessageAvailableCallback = { deliveredMessage in + XCTAssertTrue(Thread.isMainThread) + XCTAssertEqual(deliveredMessage.messageId, message.messageId) + order.append("delegate") + availabilityExpectation.fulfill() + } + let notificationReference = notificationCenter.addCallback(forNotification: .iterableJsonOnlyInAppMessageAvailable) { notification in + XCTAssertTrue(Thread.isMainThread) + XCTAssertEqual((notification.object as? IterableInAppMessage)?.messageId, message.messageId) + order.append("notification") + notificationExpectation.fulfill() + } + networkSession.requestCallback = { request in + guard request.url?.path.contains(Const.Path.inAppConsume) == true else { return } + order.append("consume") + consumeExpectation.fulfill() + } + + let internalAPI = initialize(fetcher: fetcher, + delegate: delegate, + networkSession: networkSession, + notificationCenter: notificationCenter) + fetch([message], with: fetcher, internalAPI: internalAPI) + + wait(for: [onNewExpectation, availabilityExpectation, notificationExpectation, consumeExpectation], + timeout: testExpectationTimeout) + XCTAssertEqual(order, ["onNew", "delegate", "notification", "consume"]) + notificationCenter.removeCallbacks(withIds: notificationReference.callbackId) + } + + func testPerMessageDeliveryAndIndependentAcknowledgement() { + let availabilityExpectation = expectation(description: "per-message availability") + availabilityExpectation.expectedFulfillmentCount = 2 + let fetcher = MockInAppFetcher() + let delegate = MockInAppDelegate() + let first = makeJsonOnlyMessage(id: "message-1", priorityLevel: 1) + let second = makeJsonOnlyMessage(id: "message-2", priorityLevel: 2) + var deliveredIds = [String]() + + delegate.onJsonOnlyMessageAvailableCallback = { message in + deliveredIds.append(message.messageId) + availabilityExpectation.fulfill() + } + + let internalAPI = initialize(fetcher: fetcher, delegate: delegate) + fetch([first, second], with: fetcher, internalAPI: internalAPI) + + wait(for: [availabilityExpectation], timeout: testExpectationTimeout) + XCTAssertEqual(deliveredIds, [first.messageId, second.messageId]) + XCTAssertEqual(IterableAPI.getUnhandledJsonOnlyMessages().map(\.messageId), [first.messageId, second.messageId]) + XCTAssertTrue(IterableAPI.markJsonOnlyMessageHandled(messageId: first.messageId)) + XCTAssertFalse(IterableAPI.markJsonOnlyMessageHandled(messageId: first.messageId)) + XCTAssertEqual(IterableAPI.getUnhandledJsonOnlyMessages().map(\.messageId), [second.messageId]) + } + + func testBackgroundFetchSurvivesRecreationAndDeliversOnForeground() { + let localStorage = MockLocalStorage() + localStorage.email = Self.email + let persister = MockInAppPersister() + let notificationCenter = MockNotificationCenter() + let applicationState = MockApplicationStateProvider(applicationState: .background) + let firstFetcher = MockInAppFetcher() + let message = makeJsonOnlyMessage(id: "message-1") + var firstAPI: InternalIterableAPI? = initialize(localStorage: localStorage, + fetcher: firstFetcher, + persister: persister, + applicationState: applicationState, + notificationCenter: notificationCenter) + + fetch([message], with: firstFetcher, internalAPI: firstAPI!) + XCTAssertEqual(IterableAPI.getUnhandledJsonOnlyMessages().map(\.messageId), [message.messageId]) + + IterableAPI.implementation = nil + firstAPI = nil + + let availabilityExpectation = expectation(description: "foreground replay") + let recreatedDelegate = MockInAppDelegate() + recreatedDelegate.onJsonOnlyMessageAvailableCallback = { deliveredMessage in + XCTAssertEqual(deliveredMessage.messageId, message.messageId) + availabilityExpectation.fulfill() + } + _ = initialize(localStorage: localStorage, + fetcher: MockInAppFetcher(), + persister: persister, + delegate: recreatedDelegate, + applicationState: applicationState, + notificationCenter: notificationCenter) + + XCTAssertEqual(IterableAPI.getUnhandledJsonOnlyMessages().map(\.messageId), [message.messageId]) + applicationState.applicationState = .active + notificationCenter.post(name: UIApplication.didBecomeActiveNotification, object: nil, userInfo: nil) + + wait(for: [availabilityExpectation], timeout: testExpectationTimeout) + } + + func testForegroundReplayContinuesUntilAcknowledged() { + let replayExpectation = expectation(description: "initial delivery and two replays") + replayExpectation.expectedFulfillmentCount = 3 + let noReplayExpectation = expectation(description: "no replay after acknowledgement") + noReplayExpectation.isInverted = true + let fetcher = MockInAppFetcher() + let notificationCenter = MockNotificationCenter() + let applicationState = MockApplicationStateProvider(applicationState: .active) + let delegate = MockInAppDelegate() + let message = makeJsonOnlyMessage(id: "message-1") + var availabilityCount = 0 + var onNewCount = 0 + + delegate.onNewMessageCallback = { _ in onNewCount += 1 } + delegate.onJsonOnlyMessageAvailableCallback = { _ in + availabilityCount += 1 + replayExpectation.fulfill() + } + + let internalAPI = initialize(fetcher: fetcher, + delegate: delegate, + applicationState: applicationState, + notificationCenter: notificationCenter) + fetch([message], with: fetcher, internalAPI: internalAPI) + notificationCenter.post(name: UIApplication.didBecomeActiveNotification, object: nil, userInfo: nil) + notificationCenter.post(name: UIApplication.didBecomeActiveNotification, object: nil, userInfo: nil) + + wait(for: [replayExpectation], timeout: testExpectationTimeout) + XCTAssertEqual(onNewCount, 1) + XCTAssertTrue(IterableAPI.markJsonOnlyMessageHandled(messageId: message.messageId)) + + delegate.onJsonOnlyMessageAvailableCallback = { _ in noReplayExpectation.fulfill() } + notificationCenter.post(name: UIApplication.didBecomeActiveNotification, object: nil, userInfo: nil) + wait(for: [noReplayExpectation], timeout: testExpectationTimeoutForInverted) + XCTAssertEqual(availabilityCount, 3) + } + + func testDuplicateMessageIdProducesOneUnhandledRecord() { + let fetcher = MockInAppFetcher() + let delegate = MockInAppDelegate() + var availabilityCount = 0 + delegate.onJsonOnlyMessageAvailableCallback = { _ in availabilityCount += 1 } + let internalAPI = initialize(fetcher: fetcher, delegate: delegate) + + fetch([makeJsonOnlyMessage(id: "message-1")], with: fetcher, internalAPI: internalAPI) + fetch([makeJsonOnlyMessage(id: "message-1")], with: fetcher, internalAPI: internalAPI) + + XCTAssertEqual(IterableAPI.getUnhandledJsonOnlyMessages().map(\.messageId), ["message-1"]) + XCTAssertEqual(availabilityCount, 1) + } + + func testIdentitySwitchClearsUnhandledMessages() { + let fetcher = MockInAppFetcher() + let internalAPI = initialize(fetcher: fetcher) + fetch([makeJsonOnlyMessage(id: "message-1")], with: fetcher, internalAPI: internalAPI) + XCTAssertEqual(IterableAPI.getUnhandledJsonOnlyMessages().count, 1) + + fetch([], with: fetcher, internalAPI: internalAPI) + IterableAPI.setUserId("user-b") + XCTAssertTrue(IterableAPI.getUnhandledJsonOnlyMessages().isEmpty) + + IterableAPI.setEmail(Self.email) + XCTAssertTrue(IterableAPI.getUnhandledJsonOnlyMessages().isEmpty) + } + + func testConsumeFailureKeepsUnhandledRecord() { + let consumeExpectation = expectation(description: "failed consume request") + let networkSession = MockNetworkSession(statusCode: 500) + var didObserveConsume = false + networkSession.requestCallback = { request in + guard request.url?.path.contains(Const.Path.inAppConsume) == true, !didObserveConsume else { return } + didObserveConsume = true + consumeExpectation.fulfill() + } + let fetcher = MockInAppFetcher() + let internalAPI = initialize(fetcher: fetcher, networkSession: networkSession) + let message = makeJsonOnlyMessage(id: "message-1") + + fetch([message], with: fetcher, internalAPI: internalAPI) + + wait(for: [consumeExpectation], timeout: testExpectationTimeout) + XCTAssertEqual(IterableAPI.getUnhandledJsonOnlyMessages().map(\.messageId), [message.messageId]) + } + + func testIneligibleMessagesDoNotFireAvailability() { + let noAvailabilityExpectation = expectation(description: "no JSON availability") + noAvailabilityExpectation.isInverted = true + let delegate = MockInAppDelegate(showInApp: .skip) + delegate.onJsonOnlyMessageAvailableCallback = { _ in noAvailabilityExpectation.fulfill() } + let fetcher = MockInAppFetcher() + let internalAPI = initialize(fetcher: fetcher, delegate: delegate) + let html = makeHtmlMessage(id: "html", triggerType: .immediate) + let inbox = makeHtmlMessage(id: "inbox", triggerType: .never, saveToInbox: true) + let eventJson = makeJsonOnlyMessage(id: "event-json", triggerType: .event) + + fetch([html, inbox, eventJson], with: fetcher, internalAPI: internalAPI) + + wait(for: [noAvailabilityExpectation], timeout: testExpectationTimeoutForInverted) + XCTAssertTrue(IterableAPI.getUnhandledJsonOnlyMessages().isEmpty) + } + + func testPublicAPIsBeforeInitializationAndLegacySwiftConformance() { + IterableAPI.implementation = nil + XCTAssertTrue(IterableAPI.getUnhandledJsonOnlyMessages().isEmpty) + XCTAssertFalse(IterableAPI.markJsonOnlyMessageHandled(messageId: "message-1")) + + let config = IterableConfig() + config.inAppDelegate = LegacySwiftInAppDelegate() + XCTAssertNotNil(config.inAppDelegate) + } + + func testStoreEnforcesCapacityAndRetention() { + let localStorage = MockLocalStorage() + let dateProvider = MockDateProvider() + let auth = Auth(userId: nil, email: Self.email, authToken: nil, userIdUnknownUser: nil) + let store = JsonOnlyMessageStore(localStorage: localStorage, + dateProvider: dateProvider, + identityProvider: { UserIdentitySnapshot(auth: auth) }) + + for index in 0...100 { + store.enqueue(makeJsonOnlyMessage(id: "message-\(index)")) + } + + let retainedIds = store.getMessages().map(\.messageId) + XCTAssertEqual(retainedIds.count, 100) + XCTAssertEqual(retainedIds.first, "message-1") + XCTAssertEqual(retainedIds.last, "message-100") + + dateProvider.currentDate = dateProvider.currentDate.addingTimeInterval(30 * 24 * 60 * 60 + 1) + XCTAssertTrue(store.getMessages().isEmpty) + + let expiringLocalStorage = MockLocalStorage() + let expiringDateProvider = MockDateProvider() + let expiringStore = JsonOnlyMessageStore(localStorage: expiringLocalStorage, + dateProvider: expiringDateProvider, + identityProvider: { UserIdentitySnapshot(auth: auth) }) + expiringStore.enqueue(makeJsonOnlyMessage(id: "expiring", + expiresAt: expiringDateProvider.currentDate.addingTimeInterval(1))) + expiringDateProvider.currentDate = expiringDateProvider.currentDate.addingTimeInterval(2) + XCTAssertTrue(expiringStore.getMessages().isEmpty) + } + + private func initialize(localStorage: MockLocalStorage = MockLocalStorage(), + fetcher: MockInAppFetcher, + persister: InAppPersistenceProtocol = MockInAppPersister(), + delegate: IterableInAppDelegate = MockInAppDelegate(), + networkSession: MockNetworkSession = MockNetworkSession(), + applicationState: MockApplicationStateProvider = MockApplicationStateProvider(applicationState: .active), + notificationCenter: MockNotificationCenter = MockNotificationCenter()) -> InternalIterableAPI { + if localStorage.email == nil && localStorage.userId == nil { + localStorage.email = Self.email + } + let config = IterableConfig() + config.autoPushRegistration = false + config.inAppDisplayInterval = 0 + config.inAppDelegate = delegate + IterableAPI.initializeForTesting(config: config, + networkSession: networkSession, + localStorage: localStorage, + inAppFetcher: fetcher, + inAppPersister: persister, + applicationStateProvider: applicationState, + notificationCenter: notificationCenter) + return IterableAPI.implementation! + } + + private func fetch(_ messages: [IterableInAppMessage], + with fetcher: MockInAppFetcher, + internalAPI: InternalIterableAPI) { + let fetchExpectation = expectation(description: "in-app fetch") + fetcher.mockMessagesAvailableFromServer(internalApi: internalAPI, messages: messages).onSuccess { _ in + fetchExpectation.fulfill() + } + wait(for: [fetchExpectation], timeout: testExpectationTimeout) + } + + private func makeJsonOnlyMessage(id: String, + triggerType: IterableInAppTriggerType = .immediate, + priorityLevel: Double = 0, + expiresAt: Date? = nil) -> IterableInAppMessage { + IterableInAppMessage(messageId: id, + campaignId: 1, + trigger: .create(withTriggerType: triggerType), + expiresAt: expiresAt, + content: IterableHtmlInAppContent(edgeInsets: .zero, html: ""), + customPayload: ["id": id], + priorityLevel: priorityLevel, + jsonOnly: true) + } + + private func makeHtmlMessage(id: String, + triggerType: IterableInAppTriggerType, + saveToInbox: Bool = false) -> IterableInAppMessage { + IterableInAppMessage(messageId: id, + campaignId: 1, + trigger: .create(withTriggerType: triggerType), + content: IterableHtmlInAppContent(edgeInsets: .zero, html: ""), + saveToInbox: saveToInbox) + } + + private static let email = "json-only@example.com" +} + extension IterableInAppTrigger { override public var description: String { "type: \(type)" diff --git a/tests/unit-tests/IterableDataRegionObjCTests.m b/tests/unit-tests/IterableDataRegionObjCTests.m index 2684751c0..5fd184a80 100644 --- a/tests/unit-tests/IterableDataRegionObjCTests.m +++ b/tests/unit-tests/IterableDataRegionObjCTests.m @@ -8,6 +8,17 @@ #import @import IterableSDK; +@interface LegacyInAppDelegate : NSObject +@end + +@implementation LegacyInAppDelegate + +- (enum InAppShowResponse)onNewMessage:(IterableInAppMessage * _Nonnull)message { + return InAppShowResponseShow; +} + +@end + @interface IterableDataRegionObjCTests : XCTestCase @end @@ -27,4 +38,10 @@ - (void)testIterableDataRegionIsAccessibleFromObjectiveC { XCTAssertEqualObjects(config.dataRegion, @"https://api.eu.iterable.com/api/"); } -@end \ No newline at end of file +- (void)testLegacyInAppDelegateConformanceRemainsValid { + IterableConfig *config = [[IterableConfig alloc] init]; + config.inAppDelegate = [[LegacyInAppDelegate alloc] init]; + XCTAssertNotNil(config.inAppDelegate); +} + +@end From d384e2c827fb6c37a95ac5985afddb6ca4ded5fa Mon Sep 17 00:00:00 2001 From: Sumeru Chatterjee Date: Wed, 22 Jul 2026 17:14:12 +0100 Subject: [PATCH 04/15] Trigger fresh CI run Co-Authored-By: Claude Opus 4.8 From 90428b44dff761e5e73be9b0c8da3074b2350294 Mon Sep 17 00:00:00 2001 From: Sumeru Chatterjee Date: Wed, 22 Jul 2026 17:58:39 +0100 Subject: [PATCH 05/15] SDK-496 Drop expired JSON-only messages before signaling The unhandled queue pruned expired records only on load, but enqueue and prepareDelivery appended and returned a message without checking expiry. A message fetched while valid could expire before the next foreground replay and still be signaled to the app. Reject an already-expired message in enqueue and prepareDelivery using the same rule loadCurrentState applies, so an expired JSON-only message is never delivered. Co-Authored-By: Claude Opus 4.8 --- .../Internal/in-app/InAppPersistence.swift | 18 +++++-- tests/unit-tests/InAppTests.swift | 53 ++++++++++++++++++- 2 files changed, 66 insertions(+), 5 deletions(-) diff --git a/swift-sdk/Internal/in-app/InAppPersistence.swift b/swift-sdk/Internal/in-app/InAppPersistence.swift index bc32f0c91..ca9539a7e 100644 --- a/swift-sdk/Internal/in-app/InAppPersistence.swift +++ b/swift-sdk/Internal/in-app/InAppPersistence.swift @@ -406,13 +406,15 @@ final class JsonOnlyMessageStore { func enqueue(_ message: IterableInAppMessage) -> Bool { stateQueue.sync { guard var state = loadCurrentState() else { return false } + let currentDate = dateProvider.currentDate + guard !Self.isExpired(message, at: currentDate) else { return false } if state.entries.contains(where: { $0.message.messageId == message.messageId }) { return true } state.entries.append(Entry(message: message, - storedAt: dateProvider.currentDate, + storedAt: currentDate, didBeginInitialDelivery: false)) state.entries = Array(state.entries.suffix(Self.maximumRecordCount)) return persist(state) @@ -422,8 +424,10 @@ final class JsonOnlyMessageStore { func prepareDelivery(for message: IterableInAppMessage) -> Delivery? { stateQueue.sync { guard var state = loadCurrentState() else { return nil } + let currentDate = dateProvider.currentDate if let index = state.entries.firstIndex(where: { $0.message.messageId == message.messageId }) { + guard !Self.isExpired(state.entries[index].message, at: currentDate) else { return nil } let isInitial = !state.entries[index].didBeginInitialDelivery if isInitial { state.entries[index].didBeginInitialDelivery = true @@ -432,8 +436,9 @@ final class JsonOnlyMessageStore { return Delivery(message: state.entries[index].message, isInitial: isInitial) } + guard !Self.isExpired(message, at: currentDate) else { return nil } state.entries.append(Entry(message: message, - storedAt: dateProvider.currentDate, + storedAt: currentDate, didBeginInitialDelivery: true)) state.entries = Array(state.entries.suffix(Self.maximumRecordCount)) guard persist(state) else { return nil } @@ -504,6 +509,11 @@ final class JsonOnlyMessageStore { var entries: [Entry] } + private static func isExpired(_ message: IterableInAppMessage, at currentDate: Date) -> Bool { + guard let expiresAt = message.expiresAt else { return false } + return expiresAt <= currentDate + } + private func loadCurrentState() -> State? { guard let snapshot = identityProvider() else { return nil } @@ -526,8 +536,8 @@ final class JsonOnlyMessageStore { let currentDate = dateProvider.currentDate let retainedEntries = state.entries.filter { entry in - if let expiresAt = entry.message.expiresAt { - return expiresAt > currentDate + if entry.message.expiresAt != nil { + return !Self.isExpired(entry.message, at: currentDate) } return entry.storedAt.addingTimeInterval(Self.fallbackRetentionPeriod) > currentDate } diff --git a/tests/unit-tests/InAppTests.swift b/tests/unit-tests/InAppTests.swift index 070653e38..8b0b84b03 100644 --- a/tests/unit-tests/InAppTests.swift +++ b/tests/unit-tests/InAppTests.swift @@ -2228,13 +2228,63 @@ final class JsonOnlyMessageAvailabilityTests: XCTestCase { XCTAssertTrue(expiringStore.getMessages().isEmpty) } + func testStoreDropsAlreadyExpiredMessage() { + let localStorage = MockLocalStorage() + let dateProvider = MockDateProvider() + let auth = Auth(userId: nil, email: Self.email, authToken: nil, userIdUnknownUser: nil) + let store = JsonOnlyMessageStore(localStorage: localStorage, + dateProvider: dateProvider, + identityProvider: { UserIdentitySnapshot(auth: auth) }) + let message = makeJsonOnlyMessage(id: "expired", expiresAt: dateProvider.currentDate) + + XCTAssertFalse(store.enqueue(message)) + XCTAssertTrue(store.getMessages().isEmpty) + XCTAssertNil(store.prepareDelivery(for: message)) + XCTAssertTrue(store.getMessages().isEmpty) + } + + func testMessageExpiredBeforeForegroundReplayIsNotSignaled() { + let noDelegateExpectation = expectation(description: "no delegate signal for expired message") + noDelegateExpectation.isInverted = true + let noNotificationExpectation = expectation(description: "no notification for expired message") + noNotificationExpectation.isInverted = true + let dateProvider = MockDateProvider() + let applicationState = MockApplicationStateProvider(applicationState: .background) + let notificationCenter = MockNotificationCenter() + let delegate = MockInAppDelegate() + let fetcher = MockInAppFetcher() + let message = makeJsonOnlyMessage(id: "expiring", + expiresAt: dateProvider.currentDate.addingTimeInterval(1)) + + delegate.onJsonOnlyMessageAvailableCallback = { _ in noDelegateExpectation.fulfill() } + let notificationReference = notificationCenter.addCallback(forNotification: .iterableJsonOnlyInAppMessageAvailable) { _ in + noNotificationExpectation.fulfill() + } + let internalAPI = initialize(fetcher: fetcher, + delegate: delegate, + applicationState: applicationState, + notificationCenter: notificationCenter, + dateProvider: dateProvider) + fetch([message], with: fetcher, internalAPI: internalAPI) + XCTAssertEqual(IterableAPI.getUnhandledJsonOnlyMessages().map(\.messageId), [message.messageId]) + + dateProvider.currentDate = dateProvider.currentDate.addingTimeInterval(2) + applicationState.applicationState = .active + notificationCenter.post(name: UIApplication.didBecomeActiveNotification, object: nil, userInfo: nil) + + wait(for: [noDelegateExpectation, noNotificationExpectation], timeout: testExpectationTimeoutForInverted) + XCTAssertTrue(IterableAPI.getUnhandledJsonOnlyMessages().isEmpty) + notificationCenter.removeCallbacks(withIds: notificationReference.callbackId) + } + private func initialize(localStorage: MockLocalStorage = MockLocalStorage(), fetcher: MockInAppFetcher, persister: InAppPersistenceProtocol = MockInAppPersister(), delegate: IterableInAppDelegate = MockInAppDelegate(), networkSession: MockNetworkSession = MockNetworkSession(), applicationState: MockApplicationStateProvider = MockApplicationStateProvider(applicationState: .active), - notificationCenter: MockNotificationCenter = MockNotificationCenter()) -> InternalIterableAPI { + notificationCenter: MockNotificationCenter = MockNotificationCenter(), + dateProvider: DateProviderProtocol = SystemDateProvider()) -> InternalIterableAPI { if localStorage.email == nil && localStorage.userId == nil { localStorage.email = Self.email } @@ -2243,6 +2293,7 @@ final class JsonOnlyMessageAvailabilityTests: XCTestCase { config.inAppDisplayInterval = 0 config.inAppDelegate = delegate IterableAPI.initializeForTesting(config: config, + dateProvider: dateProvider, networkSession: networkSession, localStorage: localStorage, inAppFetcher: fetcher, From 231e67b29df0ec1c85b94c587c4d4be269a8ae66 Mon Sep 17 00:00:00 2001 From: Sumeru Chatterjee Date: Thu, 23 Jul 2026 15:27:33 +0100 Subject: [PATCH 06/15] SDK-496 Scope JSON-only delivery to the enqueuing identity Closes three gaps in the JSON-only availability path found in review. A message enqueued for user A could be persisted and signaled under user B if the identity switched during the async hop to the main queue: prepareDelivery resolved the live identity and its append-if-missing branch recreated the record under the new user. Store operations now validate against the identity captured at the start of the processing run and fail closed on mismatch, and prepareDelivery only delivers records that already exist. Active fetch also persisted eligible JSON-only records one at a time, so the first availability callback could not see later records from the same response. All eligible records are now enqueued before any signal, on both the active and background paths. Initial active delivery followed display priority; it now follows server arrival order, matching the replay path and the documented contract. HTML display selection keeps priority ordering. Duplicate message IDs keep the first stored payload until acknowledgement; a test now pins that behavior. Co-Authored-By: Claude Fable 5 --- .../in-app/InAppManager+Functions.swift | 7 +- swift-sdk/Internal/in-app/InAppManager.swift | 46 +++++--- .../Internal/in-app/InAppPersistence.swift | 54 ++++++---- tests/unit-tests/InAppTests.swift | 100 +++++++++++++++++- 4 files changed, 161 insertions(+), 46 deletions(-) diff --git a/swift-sdk/Internal/in-app/InAppManager+Functions.swift b/swift-sdk/Internal/in-app/InAppManager+Functions.swift index 9c50b3d43..ff8d731a7 100644 --- a/swift-sdk/Internal/in-app/InAppManager+Functions.swift +++ b/swift-sdk/Internal/in-app/InAppManager+Functions.swift @@ -82,10 +82,9 @@ struct MessagesProcessor { } private func getFirstProcessableTriggeredMessage() -> IterableInAppMessage? { - messagesMap.values - .filter(MessagesProcessor.isProcessableTriggeredMessage) - .sorted { $0.priorityLevel < $1.priorityLevel } - .first + let processableMessages = messagesMap.values.filter(MessagesProcessor.isProcessableTriggeredMessage) + return processableMessages.first(where: { $0.isJsonOnly }) + ?? processableMessages.sorted { $0.priorityLevel < $1.priorityLevel }.first } private static func isProcessableTriggeredMessage(_ message: IterableInAppMessage) -> Bool { diff --git a/swift-sdk/Internal/in-app/InAppManager.swift b/swift-sdk/Internal/in-app/InAppManager.swift index 0840fa06d..8d549161b 100644 --- a/swift-sdk/Internal/in-app/InAppManager.swift +++ b/swift-sdk/Internal/in-app/InAppManager.swift @@ -285,12 +285,14 @@ class InAppManager: NSObject, IterableInternalInAppManagerProtocol { } private func processMergedMessages(appIsReady: Bool, mergeMessagesResult: MergeMessagesResult) -> Pending { + messagesMap = mergeMessagesResult.messagesMap + let identityScope = jsonOnlyMessageStore.identityScope + persistEligibleJsonOnlyMessages(identityScope: identityScope) + let processingResult: Pending if appIsReady { - processingResult = processAndShowMessage(messagesMap: mergeMessagesResult.messagesMap) + processingResult = processAndShowMessage(messagesMap: messagesMap, identityScope: identityScope) } else { - messagesMap = mergeMessagesResult.messagesMap - persistEligibleJsonOnlyMessages() processingResult = Fulfill(value: true) } @@ -340,17 +342,18 @@ class InAppManager: NSObject, IterableInternalInAppManagerProtocol { } } - private func processAndShowMessage(messagesMap: OrderedDictionary) -> Pending { + private func processAndShowMessage(messagesMap: OrderedDictionary, + identityScope: UserIdentitySnapshot?) -> Pending { var processor = MessagesProcessor(inAppDelegate: inAppDelegate, inAppDisplayChecker: self, messagesMap: messagesMap) let messagesProcessorResult = processor.processMessages() self.messagesMap = getMessagesMap(fromMessagesProcessorResult: messagesProcessorResult) if case let .jsonOnly(message, _) = messagesProcessorResult { - return deliverJsonOnlyMessage(message, consumeOnReplay: true).flatMap { [weak self] processed in + return deliverJsonOnlyMessage(message, consumeOnReplay: true, identityScope: identityScope).flatMap { [weak self] processed in guard processed, let self = self else { return Fulfill(value: true) } - return self.processAndShowMessage(messagesMap: self.messagesMap) + return self.processAndShowMessage(messagesMap: self.messagesMap, identityScope: identityScope) } } @@ -412,7 +415,9 @@ class InAppManager: NSObject, IterableInternalInAppManagerProtocol { guard appIsActive, let self = self else { return Fulfill(value: true) } - return self.processAndShowMessage(messagesMap: self.messagesMap).map { [weak self] _ in + let identityScope = self.jsonOnlyMessageStore.identityScope + self.persistEligibleJsonOnlyMessages(identityScope: identityScope) + return self.processAndShowMessage(messagesMap: self.messagesMap, identityScope: identityScope).map { [weak self] _ in if let messagesMap = self?.messagesMap { self?.persister.persist(messagesMap.values) } @@ -548,29 +553,33 @@ class InAppManager: NSObject, IterableInternalInAppManagerProtocol { } } - private func persistEligibleJsonOnlyMessages() { + private func persistEligibleJsonOnlyMessages(identityScope: UserIdentitySnapshot?) { + guard let identityScope = identityScope else { return } messagesMap.values .filter { $0.isJsonOnly && !$0.didProcessTrigger && !$0.consumed && !$0.read && $0.trigger.type == .immediate } - .forEach { jsonOnlyMessageStore.enqueue($0) } + .forEach { jsonOnlyMessageStore.enqueue($0, identityScope: identityScope) } } private func replayUnhandledJsonOnlyMessages() -> Pending { - guard applicationStateProvider.applicationState == .active else { + guard applicationStateProvider.applicationState == .active, + let identityScope = jsonOnlyMessageStore.identityScope else { return Fulfill(value: true) } - return jsonOnlyMessageStore.getMessages().reduce(Fulfill(value: true) as Pending) { pending, message in + return jsonOnlyMessageStore.getMessages(identityScope: identityScope).reduce(Fulfill(value: true) as Pending) { pending, message in pending.flatMap { [weak self] _ in - self?.deliverJsonOnlyMessage(message, consumeOnReplay: false) ?? Fulfill(value: true) + self?.deliverJsonOnlyMessage(message, consumeOnReplay: false, identityScope: identityScope) ?? Fulfill(value: true) } } } - private func deliverJsonOnlyMessage(_ message: IterableInAppMessage, consumeOnReplay: Bool) -> Pending { + private func deliverJsonOnlyMessage(_ message: IterableInAppMessage, + consumeOnReplay: Bool, + identityScope: UserIdentitySnapshot?) -> Pending { let result = Fulfill() - guard jsonOnlyMessageStore.enqueue(message) else { - if consumeOnReplay && !jsonOnlyMessageStore.hasCurrentIdentity { + guard let identityScope = identityScope else { + if consumeOnReplay { deliverJsonOnlyMessageWithoutAvailability(message, result: result) } else { result.resolve(with: false) @@ -578,6 +587,11 @@ class InAppManager: NSObject, IterableInternalInAppManagerProtocol { return result } + guard jsonOnlyMessageStore.enqueue(message, identityScope: identityScope) else { + result.resolve(with: false) + return result + } + let deliver = { [weak self] in guard let self = self else { result.resolve(with: false) @@ -585,7 +599,7 @@ class InAppManager: NSObject, IterableInternalInAppManagerProtocol { } guard self.applicationStateProvider.applicationState == .active, - let delivery = self.jsonOnlyMessageStore.prepareDelivery(for: message) else { + let delivery = self.jsonOnlyMessageStore.prepareDelivery(for: message, identityScope: identityScope) else { result.resolve(with: false) return } diff --git a/swift-sdk/Internal/in-app/InAppPersistence.swift b/swift-sdk/Internal/in-app/InAppPersistence.swift index ca9539a7e..a89549b74 100644 --- a/swift-sdk/Internal/in-app/InAppPersistence.swift +++ b/swift-sdk/Internal/in-app/InAppPersistence.swift @@ -402,10 +402,22 @@ final class JsonOnlyMessageStore { self.identityProvider = identityProvider } + var identityScope: UserIdentitySnapshot? { + stateQueue.sync { + identityProvider() + } + } + @discardableResult func enqueue(_ message: IterableInAppMessage) -> Bool { + guard let identityScope = identityScope else { return false } + return enqueue(message, identityScope: identityScope) + } + + @discardableResult + func enqueue(_ message: IterableInAppMessage, identityScope: UserIdentitySnapshot) -> Bool { stateQueue.sync { - guard var state = loadCurrentState() else { return false } + guard var state = loadCurrentState(expectedIdentity: identityScope) else { return false } let currentDate = dateProvider.currentDate guard !Self.isExpired(message, at: currentDate) else { return false } @@ -422,27 +434,24 @@ final class JsonOnlyMessageStore { } func prepareDelivery(for message: IterableInAppMessage) -> Delivery? { + guard let identityScope = identityScope else { return nil } + return prepareDelivery(for: message, identityScope: identityScope) + } + + func prepareDelivery(for message: IterableInAppMessage, identityScope: UserIdentitySnapshot) -> Delivery? { stateQueue.sync { - guard var state = loadCurrentState() else { return nil } + guard var state = loadCurrentState(expectedIdentity: identityScope), + let index = state.entries.firstIndex(where: { $0.message.messageId == message.messageId }) else { + return nil + } let currentDate = dateProvider.currentDate - - if let index = state.entries.firstIndex(where: { $0.message.messageId == message.messageId }) { - guard !Self.isExpired(state.entries[index].message, at: currentDate) else { return nil } - let isInitial = !state.entries[index].didBeginInitialDelivery - if isInitial { - state.entries[index].didBeginInitialDelivery = true - guard persist(state) else { return nil } - } - return Delivery(message: state.entries[index].message, isInitial: isInitial) + guard !Self.isExpired(state.entries[index].message, at: currentDate) else { return nil } + let isInitial = !state.entries[index].didBeginInitialDelivery + if isInitial { + state.entries[index].didBeginInitialDelivery = true + guard persist(state) else { return nil } } - - guard !Self.isExpired(message, at: currentDate) else { return nil } - state.entries.append(Entry(message: message, - storedAt: currentDate, - didBeginInitialDelivery: true)) - state.entries = Array(state.entries.suffix(Self.maximumRecordCount)) - guard persist(state) else { return nil } - return Delivery(message: message, isInitial: true) + return Delivery(message: state.entries[index].message, isInitial: isInitial) } } @@ -452,9 +461,9 @@ final class JsonOnlyMessageStore { } } - var hasCurrentIdentity: Bool { + func getMessages(identityScope: UserIdentitySnapshot) -> [IterableInAppMessage] { stateQueue.sync { - identityProvider() != nil + loadCurrentState(expectedIdentity: identityScope)?.entries.map(\.message) ?? [] } } @@ -514,8 +523,9 @@ final class JsonOnlyMessageStore { return expiresAt <= currentDate } - private func loadCurrentState() -> State? { + private func loadCurrentState(expectedIdentity: UserIdentitySnapshot? = nil) -> State? { guard let snapshot = identityProvider() else { return nil } + if let expectedIdentity = expectedIdentity, snapshot != expectedIdentity { return nil } let identity = StoredIdentity(snapshot) var state: State diff --git a/tests/unit-tests/InAppTests.swift b/tests/unit-tests/InAppTests.swift index 8b0b84b03..19a45da3e 100644 --- a/tests/unit-tests/InAppTests.swift +++ b/tests/unit-tests/InAppTests.swift @@ -2026,11 +2026,14 @@ final class JsonOnlyMessageAvailabilityTests: XCTestCase { availabilityExpectation.expectedFulfillmentCount = 2 let fetcher = MockInAppFetcher() let delegate = MockInAppDelegate() - let first = makeJsonOnlyMessage(id: "message-1", priorityLevel: 1) - let second = makeJsonOnlyMessage(id: "message-2", priorityLevel: 2) + let first = makeJsonOnlyMessage(id: "message-1", priorityLevel: 2) + let second = makeJsonOnlyMessage(id: "message-2", priorityLevel: 1) var deliveredIds = [String]() delegate.onJsonOnlyMessageAvailableCallback = { message in + if deliveredIds.isEmpty { + XCTAssertEqual(IterableAPI.getUnhandledJsonOnlyMessages().map(\.messageId), [first.messageId, second.messageId]) + } deliveredIds.append(message.messageId) availabilityExpectation.fulfill() } @@ -2046,6 +2049,75 @@ final class JsonOnlyMessageAvailabilityTests: XCTestCase { XCTAssertEqual(IterableAPI.getUnhandledJsonOnlyMessages().map(\.messageId), [second.messageId]) } + func testIdentitySwitchBeforeMainDeliveryDoesNotLeakMessage() { + let noOnNewExpectation = expectation(description: "no onNew after identity switch") + noOnNewExpectation.isInverted = true + let noAvailabilityExpectation = expectation(description: "no availability after identity switch") + noAvailabilityExpectation.isInverted = true + let noNotificationExpectation = expectation(description: "no notification after identity switch") + noNotificationExpectation.isInverted = true + let identitySwitchedExpectation = expectation(description: "identity switched") + let fetchCompletedExpectation = expectation(description: "fetch completed") + let fetchStarted = DispatchSemaphore(value: 0) + let continueFetch = DispatchSemaphore(value: 0) + let mainBlocked = DispatchSemaphore(value: 0) + let releaseMain = DispatchSemaphore(value: 0) + let localStorage = MockLocalStorage() + localStorage.email = Self.email + let notificationCenter = MockNotificationCenter() + let fetcher = MockInAppFetcher() + let delegate = MockInAppDelegate() + let message = makeJsonOnlyMessage(id: "message-a") + + delegate.onNewMessageCallback = { _ in noOnNewExpectation.fulfill() } + delegate.onJsonOnlyMessageAvailableCallback = { _ in noAvailabilityExpectation.fulfill() } + let notificationReference = notificationCenter.addCallback(forNotification: .iterableJsonOnlyInAppMessageAvailable) { _ in + noNotificationExpectation.fulfill() + } + let internalAPI = initialize(localStorage: localStorage, + fetcher: fetcher, + delegate: delegate, + notificationCenter: notificationCenter) + fetcher.syncCallback = { [weak fetcher] in + fetcher?.syncCallback = nil + fetchStarted.signal() + continueFetch.wait() + } + fetcher.add(message: message) + internalAPI.inAppManager.scheduleSync().onSuccess { _ in fetchCompletedExpectation.fulfill() } + XCTAssertEqual(fetchStarted.wait(timeout: .now() + testExpectationTimeout), .success) + + DispatchQueue.main.async { + mainBlocked.signal() + _ = releaseMain.wait(timeout: .now() + testExpectationTimeout) + } + DispatchQueue.global().async { + guard mainBlocked.wait(timeout: .now() + testExpectationTimeout) == .success else { + continueFetch.signal() + releaseMain.signal() + return + } + continueFetch.signal() + + let deadline = Date().addingTimeInterval(testExpectationTimeout) + while IterableAPI.getUnhandledJsonOnlyMessages().map(\.messageId) != [message.messageId], Date() < deadline { + Thread.sleep(forTimeInterval: 0.01) + } + XCTAssertEqual(IterableAPI.getUnhandledJsonOnlyMessages().map(\.messageId), [message.messageId]) + + fetcher.mockMessagesAvailableFromServer(internalApi: nil, messages: []) + internalAPI.setUserId("user-b") + XCTAssertTrue(IterableAPI.getUnhandledJsonOnlyMessages().isEmpty) + identitySwitchedExpectation.fulfill() + releaseMain.signal() + } + + wait(for: [identitySwitchedExpectation, fetchCompletedExpectation], timeout: testExpectationTimeout) + wait(for: [noOnNewExpectation, noAvailabilityExpectation, noNotificationExpectation], timeout: testExpectationTimeoutForInverted) + XCTAssertTrue(IterableAPI.getUnhandledJsonOnlyMessages().isEmpty) + notificationCenter.removeCallbacks(withIds: notificationReference.callbackId) + } + func testBackgroundFetchSurvivesRecreationAndDeliversOnForeground() { let localStorage = MockLocalStorage() localStorage.email = Self.email @@ -2137,6 +2209,25 @@ final class JsonOnlyMessageAvailabilityTests: XCTestCase { XCTAssertEqual(availabilityCount, 1) } + func testDuplicateMessageIdKeepsFirstPayloadUntilAcknowledged() { + let localStorage = MockLocalStorage() + let dateProvider = MockDateProvider() + let auth = Auth(userId: nil, email: Self.email, authToken: nil, userIdUnknownUser: nil) + let store = JsonOnlyMessageStore(localStorage: localStorage, + dateProvider: dateProvider, + identityProvider: { UserIdentitySnapshot(auth: auth) }) + let first = makeJsonOnlyMessage(id: "message-1", payloadId: "first") + let second = makeJsonOnlyMessage(id: "message-1", payloadId: "second") + + XCTAssertTrue(store.enqueue(first)) + XCTAssertTrue(store.enqueue(second)) + XCTAssertEqual(store.getMessages().first?.customPayload?["id"] as? String, "first") + + XCTAssertTrue(store.remove(messageId: first.messageId)) + XCTAssertTrue(store.enqueue(second)) + XCTAssertEqual(store.getMessages().first?.customPayload?["id"] as? String, "second") + } + func testIdentitySwitchClearsUnhandledMessages() { let fetcher = MockInAppFetcher() let internalAPI = initialize(fetcher: fetcher) @@ -2316,13 +2407,14 @@ final class JsonOnlyMessageAvailabilityTests: XCTestCase { private func makeJsonOnlyMessage(id: String, triggerType: IterableInAppTriggerType = .immediate, priorityLevel: Double = 0, - expiresAt: Date? = nil) -> IterableInAppMessage { + expiresAt: Date? = nil, + payloadId: String? = nil) -> IterableInAppMessage { IterableInAppMessage(messageId: id, campaignId: 1, trigger: .create(withTriggerType: triggerType), expiresAt: expiresAt, content: IterableHtmlInAppContent(edgeInsets: .zero, html: ""), - customPayload: ["id": id], + customPayload: ["id": payloadId ?? id], priorityLevel: priorityLevel, jsonOnly: true) } From 42abadc579172b1e038df2a118cf6b9452977525 Mon Sep 17 00:00:00 2001 From: Sumeru Chatterjee Date: Thu, 23 Jul 2026 16:27:07 +0100 Subject: [PATCH 07/15] SDK-496 Discard in-app fetch responses that outlive their identity A fetch issued for user A whose response resolved after a switch to user B was processed under B: the identity scope was captured only after the network call returned, so A's messages could land in B's message map and JSON-only queue. The scope is now captured before the fetch and the whole response is discarded as a no-op when the identity has changed by the time it resolves, without updating lastSyncTime. Also: the JSON-only store no longer rewrites persisted state on reads that changed nothing, and jsonOnlyMessageQueueData lost its silent no-op protocol default so every conformer must implement storage. Co-Authored-By: Claude Fable 5 --- .../Utilities/LocalStorageProtocol.swift | 5 -- swift-sdk/Internal/in-app/InAppManager.swift | 28 +++++--- .../Internal/in-app/InAppPersistence.swift | 6 +- tests/unit-tests/InAppTests.swift | 71 ++++++++++++++++++- 4 files changed, 95 insertions(+), 15 deletions(-) diff --git a/swift-sdk/Internal/Utilities/LocalStorageProtocol.swift b/swift-sdk/Internal/Utilities/LocalStorageProtocol.swift index d5bf5392b..8676b5953 100644 --- a/swift-sdk/Internal/Utilities/LocalStorageProtocol.swift +++ b/swift-sdk/Internal/Utilities/LocalStorageProtocol.swift @@ -51,11 +51,6 @@ protocol LocalStorageProtocol { } extension LocalStorageProtocol { - var jsonOnlyMessageQueueData: Data? { - get { nil } - set {} - } - func upgrade() { } diff --git a/swift-sdk/Internal/in-app/InAppManager.swift b/swift-sdk/Internal/in-app/InAppManager.swift index 8d549161b..f3730d98d 100644 --- a/swift-sdk/Internal/in-app/InAppManager.swift +++ b/swift-sdk/Internal/in-app/InAppManager.swift @@ -270,23 +270,35 @@ class InAppManager: NSObject, IterableInternalInAppManagerProtocol { private func synchronize(appIsReady: Bool) -> Pending { ITBInfo() + let identityScope = jsonOnlyMessageStore.identityScope return fetcher.fetch() - .map { [weak self] in - self?.mergeMessages($0) ?? MergeMessagesResult(inboxChanged: false, messagesMap: [:], deliveredMessages: []) + .map { [weak self] messages in + self?.mergeMessages(messages, identityScope: identityScope) } - .flatMap { [weak self] in - self?.processMergedMessages(appIsReady: appIsReady, mergeMessagesResult: $0) ?? Fulfill(value: true) + .flatMap { [weak self] mergeMessagesResult in + guard let self = self, let mergeMessagesResult = mergeMessagesResult else { + return Fulfill(value: true) + } + return self.processMergedMessages(appIsReady: appIsReady, + mergeMessagesResult: mergeMessagesResult, + identityScope: identityScope) } } /// `messages` are new messages coming from the server - private func mergeMessages(_ messages: [IterableInAppMessage]) -> MergeMessagesResult { - MessagesObtainedHandler(messagesMap: messagesMap, messages: messages).handle() + private func mergeMessages(_ messages: [IterableInAppMessage], + identityScope: UserIdentitySnapshot?) -> MergeMessagesResult? { + guard identityScope == jsonOnlyMessageStore.identityScope else { return nil } + return MessagesObtainedHandler(messagesMap: messagesMap, messages: messages).handle() } - private func processMergedMessages(appIsReady: Bool, mergeMessagesResult: MergeMessagesResult) -> Pending { + private func processMergedMessages(appIsReady: Bool, + mergeMessagesResult: MergeMessagesResult, + identityScope: UserIdentitySnapshot?) -> Pending { + guard identityScope == jsonOnlyMessageStore.identityScope else { + return Fulfill(value: true) + } messagesMap = mergeMessagesResult.messagesMap - let identityScope = jsonOnlyMessageStore.identityScope persistEligibleJsonOnlyMessages(identityScope: identityScope) let processingResult: Pending diff --git a/swift-sdk/Internal/in-app/InAppPersistence.swift b/swift-sdk/Internal/in-app/InAppPersistence.swift index a89549b74..1f361c26e 100644 --- a/swift-sdk/Internal/in-app/InAppPersistence.swift +++ b/swift-sdk/Internal/in-app/InAppPersistence.swift @@ -529,12 +529,14 @@ final class JsonOnlyMessageStore { let identity = StoredIdentity(snapshot) var state: State + var stateChanged = false if let data = localStorage.jsonOnlyMessageQueueData { do { state = try JSONDecoder().decode(State.self, from: data) } catch { ITBError("Unable to decode unhandled JSON-only messages: \(error.localizedDescription)") state = State(identity: identity, entries: []) + stateChanged = true } } else { state = State(identity: identity, entries: []) @@ -542,6 +544,7 @@ final class JsonOnlyMessageStore { if state.identity != identity { state = State(identity: identity, entries: []) + stateChanged = true } let currentDate = dateProvider.currentDate @@ -554,9 +557,10 @@ final class JsonOnlyMessageStore { if retainedEntries.count != state.entries.count { state.entries = retainedEntries + stateChanged = true } - guard persist(state) else { return nil } + if stateChanged, !persist(state) { return nil } return state } diff --git a/tests/unit-tests/InAppTests.swift b/tests/unit-tests/InAppTests.swift index 19a45da3e..99533a846 100644 --- a/tests/unit-tests/InAppTests.swift +++ b/tests/unit-tests/InAppTests.swift @@ -1967,6 +1967,34 @@ private final class LegacySwiftInAppDelegate: NSObject, IterableInAppDelegate { } } +private final class BlockingInAppFetcher: InAppFetcherProtocol { + func blockNextFetch(with messages: [IterableInAppMessage]) { + blockedMessages = messages + } + + func fetch() -> Pending<[IterableInAppMessage], Error> { + guard let messages = blockedMessages else { + if didCompleteBlockedFetch { + subsequentFetchStarted.signal() + } + return Fulfill(value: []) + } + + blockedMessages = nil + blockedFetchStarted.signal() + continueBlockedFetch.wait() + didCompleteBlockedFetch = true + return Fulfill(value: messages) + } + + let blockedFetchStarted = DispatchSemaphore(value: 0) + let continueBlockedFetch = DispatchSemaphore(value: 0) + let subsequentFetchStarted = DispatchSemaphore(value: 0) + + private var blockedMessages: [IterableInAppMessage]? + private var didCompleteBlockedFetch = false +} + final class JsonOnlyMessageAvailabilityTests: XCTestCase { override func tearDown() { IterableAPI.implementation = nil @@ -2118,6 +2146,47 @@ final class JsonOnlyMessageAvailabilityTests: XCTestCase { notificationCenter.removeCallbacks(withIds: notificationReference.callbackId) } + func testIdentitySwitchWhileFetchIsInFlightDiscardsResponse() { + let noOnNewExpectation = expectation(description: "no onNew for stale response") + noOnNewExpectation.isInverted = true + let noAvailabilityExpectation = expectation(description: "no availability for stale response") + noAvailabilityExpectation.isInverted = true + let noNotificationExpectation = expectation(description: "no notification for stale response") + noNotificationExpectation.isInverted = true + let settledExpectation = expectation(description: "syncs settled") + let localStorage = MockLocalStorage() + localStorage.email = Self.email + let notificationCenter = MockNotificationCenter() + let fetcher = BlockingInAppFetcher() + let delegate = MockInAppDelegate() + let message = makeJsonOnlyMessage(id: "message-a") + + delegate.onNewMessageCallback = { _ in noOnNewExpectation.fulfill() } + delegate.onJsonOnlyMessageAvailableCallback = { _ in noAvailabilityExpectation.fulfill() } + let notificationReference = notificationCenter.addCallback(forNotification: .iterableJsonOnlyInAppMessageAvailable) { _ in + noNotificationExpectation.fulfill() + } + let internalAPI = initialize(localStorage: localStorage, + fetcher: fetcher, + delegate: delegate, + notificationCenter: notificationCenter) + fetcher.blockNextFetch(with: [message]) + _ = internalAPI.inAppManager.scheduleSync() + defer { fetcher.continueBlockedFetch.signal() } + XCTAssertEqual(fetcher.blockedFetchStarted.wait(timeout: .now() + testExpectationTimeout), .success) + + internalAPI.setUserId("user-b") + fetcher.continueBlockedFetch.signal() + XCTAssertEqual(fetcher.subsequentFetchStarted.wait(timeout: .now() + testExpectationTimeout), .success) + internalAPI.inAppManager.scheduleSync().onSuccess { _ in settledExpectation.fulfill() } + + wait(for: [settledExpectation], timeout: testExpectationTimeout) + wait(for: [noOnNewExpectation, noAvailabilityExpectation, noNotificationExpectation], timeout: testExpectationTimeoutForInverted) + XCTAssertTrue(IterableAPI.getUnhandledJsonOnlyMessages().isEmpty) + XCTAssertFalse(internalAPI.inAppManager.getMessages().contains(where: { $0.messageId == message.messageId })) + notificationCenter.removeCallbacks(withIds: notificationReference.callbackId) + } + func testBackgroundFetchSurvivesRecreationAndDeliversOnForeground() { let localStorage = MockLocalStorage() localStorage.email = Self.email @@ -2369,7 +2438,7 @@ final class JsonOnlyMessageAvailabilityTests: XCTestCase { } private func initialize(localStorage: MockLocalStorage = MockLocalStorage(), - fetcher: MockInAppFetcher, + fetcher: InAppFetcherProtocol, persister: InAppPersistenceProtocol = MockInAppPersister(), delegate: IterableInAppDelegate = MockInAppDelegate(), networkSession: MockNetworkSession = MockNetworkSession(), From c28644790ece31ce8b1b6363f584d6a87ffe61f9 Mon Sep 17 00:00:00 2001 From: Sumeru Chatterjee Date: Thu, 23 Jul 2026 22:45:47 +0100 Subject: [PATCH 08/15] SDK-496 Add identity coordination to in-app delivery and merge A user switch could race in-app processing at several boundaries: a fetch response resolving after the switch, delivery callbacks running mid-switch, queued HTML processing for the previous user, and public getters reading the old map. An IdentityCoordinator (recursive critical section plus a monotonic generation) now scopes every stage: fetch commits merge and map assignment atomically against identity publication, JSON-only delivery revalidates between each customer callback and before the consume mutation, HTML processing prechecks context before the display gate and before onNew without holding the lock across customer code, and getters read context and map in one section. Customer and display callbacks run on a dedicated processing queue instead of the sync queue. Acknowledgements are durable: markJsonOnlyMessageHandled records the message ID with a canonical payload fingerprint (deployment-neutral recursive encoder, iOS 10 safe), so an acknowledged unchanged payload stays suppressed, a changed payload readmits, and retention or capacity eviction of an unacknowledged record cannot readmit it. Acknowledgement and tombstone state apply to JSON-only messages only; an HTML message reusing an ID is unaffected, and cross-type transitions cannot halt batch processing. Server read-state overwrites no longer clear local consumed state, readmitted records reach delivery tracking, and eligible records persist in one batch write. The JSON-only callbacks run inside the identity critical section; the public docs on onNew, onJsonOnlyMessageAvailable, and the notification state the resulting cross-thread wait restriction. Objective-C gains IterableAPI.jsonOnlyInAppMessageAvailableNotification. Co-Authored-By: Claude Fable 5 --- swift-sdk/Internal/Auth.swift | 70 ++ swift-sdk/Internal/InternalIterableAPI.swift | 78 +- .../DependencyContainerProtocol.swift | 11 +- .../in-app/InAppManager+Functions.swift | 73 +- swift-sdk/Internal/in-app/InAppManager.swift | 324 +++++--- .../Internal/in-app/InAppPersistence.swift | 309 ++++++-- swift-sdk/SDK/IterableConfig.swift | 3 +- swift-sdk/SDK/IterableMessaging.swift | 6 + tests/common/MockInAppPersister.swift | 4 + tests/common/MockLocalStorage.swift | 14 +- .../InAppMessageProcessorTests.swift | 17 + tests/unit-tests/InAppTests.swift | 716 +++++++++++++++++- tests/unit-tests/IterableAPITests.swift | 12 +- .../unit-tests/IterableDataRegionObjCTests.m | 25 + 14 files changed, 1451 insertions(+), 211 deletions(-) diff --git a/swift-sdk/Internal/Auth.swift b/swift-sdk/Internal/Auth.swift index f63a51b83..dc276708c 100644 --- a/swift-sdk/Internal/Auth.swift +++ b/swift-sdk/Internal/Auth.swift @@ -8,6 +8,76 @@ protocol AuthProvider: AnyObject { var auth: Auth { get } } +struct UserIdentityContext: Equatable { + let identity: UserIdentitySnapshot? + let generation: UInt64 +} + +final class IdentityCoordinator { + func capture(identityProvider: () -> UserIdentitySnapshot?) -> UserIdentityContext { + withCriticalSection { + UserIdentityContext(identity: identityProvider(), generation: generation) + } + } + + @discardableResult + func performIfCurrent(_ context: UserIdentityContext, + identityProvider: () -> UserIdentitySnapshot?, + _ block: () -> Void) -> Bool { + withCriticalSection { + guard context.generation == generation, + context.identity == identityProvider(), + !hasPendingPublication else { + return false + } + block() + return context.generation == generation && + context.identity == identityProvider() && + !hasPendingPublication + } + } + + func publish(_ block: () -> Void) { + beginPublication() + withCriticalSection { + block() + generation &+= 1 + endPublication() + } + } + + func beginPublication() { + pendingPublicationLock.lock() + pendingPublicationCount += 1 + pendingPublicationLock.unlock() + } + + func endPublication() { + pendingPublicationLock.lock() + pendingPublicationCount -= 1 + pendingPublicationLock.unlock() + } + + func withCriticalSection(_ block: () -> T) -> T { + lock.lock() + defer { lock.unlock() } + return block() + } + + private var hasPendingPublication: Bool { + pendingPublicationLock.lock() + defer { pendingPublicationLock.unlock() } + return pendingPublicationCount > 0 + } + + // Lock order is manager queue, identity section, then JSON store queue. Identity + // holders may call customers but must never synchronously wait on manager queues. + private let lock = NSRecursiveLock() + private let pendingPublicationLock = NSLock() + private var generation: UInt64 = 0 + private var pendingPublicationCount = 0 +} + struct Auth { let userId: String? let email: String? diff --git a/swift-sdk/Internal/InternalIterableAPI.swift b/swift-sdk/Internal/InternalIterableAPI.swift index ca5cca430..a4a2fcd35 100644 --- a/swift-sdk/Internal/InternalIterableAPI.swift +++ b/swift-sdk/Internal/InternalIterableAPI.swift @@ -18,7 +18,7 @@ final class InternalIterableAPI: NSObject, PushTrackerProtocol, AuthProvider { var email: String? { get { - _email + identityValues().email } set { setEmail(newValue) } @@ -26,7 +26,7 @@ final class InternalIterableAPI: NSObject, PushTrackerProtocol, AuthProvider { var userId: String? { get { - _userId + identityValues().userId } set { setUserId(newValue) } @@ -78,7 +78,11 @@ final class InternalIterableAPI: NSObject, PushTrackerProtocol, AuthProvider { } var auth: Auth { - Auth(userId: userId, email: email, authToken: authManager.getAuthToken(), userIdUnknownUser: localStorage.userIdUnknownUser) + let identity = identityValues() + return Auth(userId: identity.userId, + email: identity.email, + authToken: authManager.getAuthToken(), + userIdUnknownUser: localStorage.userIdUnknownUser) } var dependencyContainer: DependencyContainerProtocol @@ -89,6 +93,7 @@ final class InternalIterableAPI: NSObject, PushTrackerProtocol, AuthProvider { requestHandler: self.requestHandler, deviceMetadata: deviceMetadata, authProvider: self, + identityCoordinator: self.identityCoordinator, authManager: self.authManager) }() @@ -146,21 +151,25 @@ final class InternalIterableAPI: NSObject, PushTrackerProtocol, AuthProvider { } func setEmail(_ email: String?, authToken: String? = nil, successHandler: OnSuccessHandler? = nil, failureHandler: OnFailureHandler? = nil, identityResolution: IterableIdentityResolution? = nil) { + identityCoordinator.beginPublication() ITBInfo() - if self._email == email && email != nil { + let currentEmail = identityValues().email + if currentEmail == email && email != nil { + identityCoordinator.endPublication() self.checkAndUpdateAuthToken(authToken) return } - if self._email == email { + if currentEmail == email { + identityCoordinator.endPublication() return } self.logoutPreviousUser() - self._email = email - self._userId = nil + setIdentity(email: email, userId: nil) + identityCoordinator.endPublication() self.onLogin(authToken) { [weak self] in guard let config = self?.config else { @@ -195,21 +204,26 @@ final class InternalIterableAPI: NSObject, PushTrackerProtocol, AuthProvider { } func setUserId(_ userId: String?, authToken: String? = nil, successHandler: OnSuccessHandler? = nil, failureHandler: OnFailureHandler? = nil, isUnknownUser: Bool = false, identityResolution: IterableIdentityResolution? = nil) { + identityCoordinator.beginPublication() + ITBInfo() - if self._userId == userId && userId != nil { + let currentUserId = identityValues().userId + if currentUserId == userId && userId != nil { + identityCoordinator.endPublication() self.checkAndUpdateAuthToken(authToken) return } - if self._userId == userId { + if currentUserId == userId { + identityCoordinator.endPublication() return } self.logoutPreviousUser() - self._email = nil - self._userId = userId + setIdentity(email: nil, userId: userId) + identityCoordinator.endPublication() self.onLogin(authToken) { [weak self] in guard let config = self?.config else { @@ -254,9 +268,12 @@ final class InternalIterableAPI: NSObject, PushTrackerProtocol, AuthProvider { func logoutUser(withOnSuccess onSuccess: OnSuccessHandler?, onFailure: OnFailureHandler?) { + identityCoordinator.beginPublication() + ITBInfo() guard isSDKInitialized() else { + identityCoordinator.endPublication() onFailure?("Iterable SDK is not initialized", nil) return } @@ -265,10 +282,10 @@ final class InternalIterableAPI: NSObject, PushTrackerProtocol, AuthProvider { disableDeviceForCurrentUser(withOnSuccess: onSuccess, onFailure: onFailure) } - inAppManager.clearUnhandledJsonOnlyMessages() + setIdentity(email: nil, userId: nil) + identityCoordinator.endPublication() - _email = nil - _userId = nil + inAppManager.clearUnhandledJsonOnlyMessages() storeIdentifierData() @@ -810,6 +827,7 @@ final class InternalIterableAPI: NSObject, PushTrackerProtocol, AuthProvider { private var _email: String? private var _payloadData: [AnyHashable: Any]? private var _userId: String? + private let identityCoordinator = IdentityCoordinator() private var _successCallback: OnSuccessHandler? = nil private var _failureCallback: OnFailureHandler? = nil @@ -874,11 +892,13 @@ final class InternalIterableAPI: NSObject, PushTrackerProtocol, AuthProvider { } public func isEitherUserIdOrEmailSet() -> Bool { - IterableUtil.isNotNullOrEmpty(string: _email) || IterableUtil.isNotNullOrEmpty(string: _userId) + let identity = identityValues() + return IterableUtil.isNotNullOrEmpty(string: identity.email) || IterableUtil.isNotNullOrEmpty(string: identity.userId) } public func noUserLoggedIn() -> Bool { - IterableUtil.isNullOrEmpty(string: _email) && IterableUtil.isNullOrEmpty(string: _userId) + let identity = identityValues() + return IterableUtil.isNullOrEmpty(string: identity.email) && IterableUtil.isNullOrEmpty(string: identity.userId) } public func isUnknownUserSet() -> Bool { @@ -886,17 +906,14 @@ final class InternalIterableAPI: NSObject, PushTrackerProtocol, AuthProvider { } private func logoutPreviousUser() { - // Delegates to logoutUser(withOnSuccess:onFailure:) so the logout cleanup - // sequence has a single source of truth. The user-switch paths (setEmail/ - // setUserId) pass no handlers: a nil onFailure keeps the not-initialized - // guard a silent no-op, and a nil onSuccess makes the auto-push-off - // completion a no-op, matching this method's previous behavior. + // Preserve the existing no-handler behavior for internal user switches. logoutUser(withOnSuccess: nil, onFailure: nil) } private func storeIdentifierData() { - localStorage.email = _email - localStorage.userId = _userId + let identity = identityValues() + localStorage.email = identity.email + localStorage.userId = identity.userId } private func onLogin(_ authToken: String? = nil, onloginSuccess onloginSuccessCallBack: (()->())? = nil) { @@ -952,8 +969,18 @@ final class InternalIterableAPI: NSObject, PushTrackerProtocol, AuthProvider { } private func retrieveIdentifierData() { - _email = localStorage.email - _userId = localStorage.userId + setIdentity(email: localStorage.email, userId: localStorage.userId) + } + + private func identityValues() -> (email: String?, userId: String?) { + identityCoordinator.withCriticalSection { (_email, _userId) } + } + + private func setIdentity(email: String?, userId: String?) { + identityCoordinator.publish { + _email = email + _userId = userId + } } private func save(pushPayload payload: [AnyHashable: Any]) { @@ -1193,4 +1220,3 @@ final class InternalIterableAPI: NSObject, PushTrackerProtocol, AuthProvider { } - diff --git a/swift-sdk/Internal/Utilities/DependencyContainerProtocol.swift b/swift-sdk/Internal/Utilities/DependencyContainerProtocol.swift index 75fd741c1..db7161111 100644 --- a/swift-sdk/Internal/Utilities/DependencyContainerProtocol.swift +++ b/swift-sdk/Internal/Utilities/DependencyContainerProtocol.swift @@ -35,12 +35,15 @@ extension DependencyContainerProtocol { requestHandler: RequestHandlerProtocol, deviceMetadata: DeviceMetadata, authProvider: AuthProvider, + identityCoordinator: IdentityCoordinator, authManager: IterableAuthManagerProtocol?) -> IterableInternalInAppManagerProtocol { + let identityProvider = { [weak authProvider] in + UserIdentitySnapshot(auth: authProvider?.auth) + } let jsonOnlyMessageStore = JsonOnlyMessageStore(localStorage: localStorage, dateProvider: dateProvider, - identityProvider: { [weak authProvider] in - UserIdentitySnapshot(auth: authProvider?.auth) - }) + identityProvider: identityProvider, + identityCoordinator: identityCoordinator) return InAppManager(requestHandler: requestHandler, deviceMetadata: deviceMetadata, fetcher: createInAppFetcher(apiClient: apiClient, authManager: authManager), @@ -56,6 +59,8 @@ extension DependencyContainerProtocol { notificationCenter: notificationCenter, dateProvider: dateProvider, jsonOnlyMessageStore: jsonOnlyMessageStore, + identityCoordinator: identityCoordinator, + identityProvider: identityProvider, moveToForegroundSyncInterval: config.inAppDisplayInterval) } diff --git a/swift-sdk/Internal/in-app/InAppManager+Functions.swift b/swift-sdk/Internal/in-app/InAppManager+Functions.swift index ff8d731a7..9e2a27c00 100644 --- a/swift-sdk/Internal/in-app/InAppManager+Functions.swift +++ b/swift-sdk/Internal/in-app/InAppManager+Functions.swift @@ -13,12 +13,16 @@ enum MessagesProcessorResult { struct MessagesProcessor { init(inAppDelegate: IterableInAppDelegate, inAppDisplayChecker: InAppDisplayChecker, - messagesMap: OrderedDictionary) { + messagesMap: OrderedDictionary, + currentDate: Date, + isContextCurrent: @escaping () -> Bool) { ITBInfo() self.inAppDelegate = inAppDelegate self.inAppDisplayChecker = inAppDisplayChecker self.messagesMap = messagesMap + self.currentDate = currentDate + self.isContextCurrent = isContextCurrent } mutating func processMessages() -> MessagesProcessorResult { @@ -60,16 +64,20 @@ struct MessagesProcessor { ITBDebug("processing message with id: \(message.messageId)") + if message.isJsonOnly { + return .jsonOnly(message) + } + + guard isContextCurrent() else { return .none } + guard inAppDisplayChecker.isOkToShowNow(message: message) else { ITBDebug("Not ok to show now") return .wait } ITBDebug("isOkToShowNow") - - if message.isJsonOnly { - return .jsonOnly(message) - } + + guard isContextCurrent() else { return .none } let returnValue = inAppDelegate.onNew(message: message) if returnValue == .show { @@ -82,13 +90,16 @@ struct MessagesProcessor { } private func getFirstProcessableTriggeredMessage() -> IterableInAppMessage? { - let processableMessages = messagesMap.values.filter(MessagesProcessor.isProcessableTriggeredMessage) + let processableMessages = messagesMap.values.filter(isProcessableTriggeredMessage) return processableMessages.first(where: { $0.isJsonOnly }) ?? processableMessages.sorted { $0.priorityLevel < $1.priorityLevel }.first } - private static func isProcessableTriggeredMessage(_ message: IterableInAppMessage) -> Bool { - !message.didProcessTrigger && message.trigger.type == .immediate && !message.read + private func isProcessableTriggeredMessage(_ message: IterableInAppMessage) -> Bool { + !message.didProcessTrigger && + message.trigger.type == .immediate && + !message.read && + (message.expiresAt.map { $0 > currentDate } ?? true) } private mutating func updateMessage(_ message: IterableInAppMessage, didProcessTrigger: Bool? = nil, consumed: Bool? = nil) { @@ -110,6 +121,8 @@ struct MessagesProcessor { private let inAppDelegate: IterableInAppDelegate private let inAppDisplayChecker: InAppDisplayChecker private var messagesMap: OrderedDictionary + private let currentDate: Date + private let isContextCurrent: () -> Bool } struct MergeMessagesResult { @@ -120,10 +133,15 @@ struct MergeMessagesResult { /// Merges the results and determines whether inbox changed needs to be fired. struct MessagesObtainedHandler { - init(messagesMap: OrderedDictionary, messages: [IterableInAppMessage]) { + init(messagesMap: OrderedDictionary, + messages: [IterableInAppMessage], + acknowledgedJsonOnlyMessageIds: Set = [], + readmittedJsonOnlyMessageIds: Set = []) { ITBInfo() self.messagesMap = messagesMap self.messages = messages + self.acknowledgedJsonOnlyMessageIds = acknowledgedJsonOnlyMessageIds + self.readmittedJsonOnlyMessageIds = readmittedJsonOnlyMessageIds } func handle() -> MergeMessagesResult { @@ -135,22 +153,53 @@ struct MessagesObtainedHandler { let addedInboxCount = addedMessages.reduce(0) { $1.saveToInbox ? $0 + 1 : $0 } var messagesOverwritten = 0 + var readmittedMessages = [IterableInAppMessage]() var newMessagesMap = OrderedDictionary() messages.forEach { serverMessage in let messageId = serverMessage.messageId if let existingMessage = messagesMap[messageId] { - if Self.shouldOverwrite(clientMessage: existingMessage, withServerMessage: serverMessage) { + if !existingMessage.isJsonOnly, + serverMessage.isJsonOnly, + acknowledgedJsonOnlyMessageIds.contains(messageId) { + serverMessage.consumed = true + serverMessage.didProcessTrigger = true + newMessagesMap[messageId] = serverMessage + if existingMessage.saveToInbox { + messagesOverwritten += 1 + } + } else if existingMessage.isJsonOnly != serverMessage.isJsonOnly { + newMessagesMap[messageId] = serverMessage + readmittedMessages.append(serverMessage) + if existingMessage.saveToInbox || serverMessage.saveToInbox { + messagesOverwritten += 1 + } + } else if serverMessage.isJsonOnly && readmittedJsonOnlyMessageIds.contains(messageId) { + newMessagesMap[messageId] = serverMessage + readmittedMessages.append(serverMessage) + } else if serverMessage.isJsonOnly && acknowledgedJsonOnlyMessageIds.contains(messageId) { + existingMessage.consumed = true + existingMessage.didProcessTrigger = true + newMessagesMap[messageId] = existingMessage + } else if Self.shouldOverwrite(clientMessage: existingMessage, withServerMessage: serverMessage) { + serverMessage.consumed = existingMessage.consumed + serverMessage.didProcessTrigger = existingMessage.didProcessTrigger newMessagesMap[messageId] = serverMessage messagesOverwritten += 1 } else { newMessagesMap[messageId] = existingMessage } } else { + if serverMessage.isJsonOnly && acknowledgedJsonOnlyMessageIds.contains(messageId) { + serverMessage.consumed = true + serverMessage.didProcessTrigger = true + } newMessagesMap[messageId] = serverMessage } } - let deliveredMessages = addedMessages.filter { $0.read != true } + let deliveredMessages = (addedMessages + readmittedMessages).filter { + !$0.read && !acknowledgedJsonOnlyMessageIds.contains($0.messageId) + } return MergeMessagesResult(inboxChanged: removedInboxCount + addedInboxCount + messagesOverwritten > 0, messagesMap: newMessagesMap, @@ -159,6 +208,8 @@ struct MessagesObtainedHandler { private let messagesMap: OrderedDictionary private let messages: [IterableInAppMessage] + private let acknowledgedJsonOnlyMessageIds: Set + private let readmittedJsonOnlyMessageIds: Set // We should only overwrite if the server is read and client is not read. // This is because some client changes may not have propagated to server yet. diff --git a/swift-sdk/Internal/in-app/InAppManager.swift b/swift-sdk/Internal/in-app/InAppManager.swift index f3730d98d..451142c0d 100644 --- a/swift-sdk/Internal/in-app/InAppManager.swift +++ b/swift-sdk/Internal/in-app/InAppManager.swift @@ -56,6 +56,8 @@ class InAppManager: NSObject, IterableInternalInAppManagerProtocol { notificationCenter: NotificationCenterProtocol, dateProvider: DateProviderProtocol, jsonOnlyMessageStore: JsonOnlyMessageStore, + identityCoordinator: IdentityCoordinator, + identityProvider: @escaping () -> UserIdentitySnapshot?, moveToForegroundSyncInterval: Double) { ITBInfo() @@ -74,11 +76,14 @@ class InAppManager: NSObject, IterableInternalInAppManagerProtocol { self.notificationCenter = notificationCenter self.dateProvider = dateProvider self.jsonOnlyMessageStore = jsonOnlyMessageStore + self.identityCoordinator = identityCoordinator + self.identityProvider = identityProvider self.moveToForegroundSyncInterval = moveToForegroundSyncInterval super.init() initializeMessagesMap() + messagesIdentityContext = identityCoordinator.capture(identityProvider: identityProvider) self.notificationCenter.addObserver(self, selector: #selector(onAppEnteredForeground(notification:)), @@ -110,14 +115,30 @@ class InAppManager: NSObject, IterableInternalInAppManagerProtocol { func getMessages() -> [IterableInAppMessage] { ITBInfo() - - return Array(messagesMap.values.filter { InAppManager.isValid(message: $0, currentDate: self.dateProvider.currentDate) }) + + var messages = [IterableInAppMessage]() + let didRead = identityCoordinator.withCriticalSection { + guard let context = messagesIdentityContext else { return false } + return identityCoordinator.performIfCurrent(context, identityProvider: identityProvider) { + messages = Array(messagesMap.values.filter { InAppManager.isValid(message: $0, currentDate: self.dateProvider.currentDate) }) + } + } + guard didRead else { return [] } + return messages } func getInboxMessages() -> [IterableInAppMessage] { ITBInfo() - - return Array(messagesMap.values.filter { InAppManager.isValid(message: $0, currentDate: self.dateProvider.currentDate) && $0.saveToInbox }) + + var messages = [IterableInAppMessage]() + let didRead = identityCoordinator.withCriticalSection { + guard let context = messagesIdentityContext else { return false } + return identityCoordinator.performIfCurrent(context, identityProvider: identityProvider) { + messages = Array(messagesMap.values.filter { InAppManager.isValid(message: $0, currentDate: self.dateProvider.currentDate) && $0.saveToInbox }) + } + } + guard didRead else { return [] } + return messages } func getUnreadInboxMessagesCount() -> Int { @@ -196,7 +217,15 @@ class InAppManager: NSObject, IterableInternalInAppManagerProtocol { } func getMessage(withId id: String) -> IterableInAppMessage? { - messagesMap[id] + var message: IterableInAppMessage? + let didRead = identityCoordinator.withCriticalSection { + guard let context = messagesIdentityContext else { return false } + return identityCoordinator.performIfCurrent(context, identityProvider: identityProvider) { + message = messagesMap[id] + } + } + guard didRead else { return nil } + return message } // MARK: - IterableInternalInAppManagerProtocol @@ -270,54 +299,87 @@ class InAppManager: NSObject, IterableInternalInAppManagerProtocol { private func synchronize(appIsReady: Bool) -> Pending { ITBInfo() - let identityScope = jsonOnlyMessageStore.identityScope + let identityContext = identityCoordinator.capture(identityProvider: identityProvider) return fetcher.fetch() - .map { [weak self] messages in - self?.mergeMessages(messages, identityScope: identityScope) - } - .flatMap { [weak self] mergeMessagesResult in - guard let self = self, let mergeMessagesResult = mergeMessagesResult else { - return Fulfill(value: true) - } - return self.processMergedMessages(appIsReady: appIsReady, - mergeMessagesResult: mergeMessagesResult, - identityScope: identityScope) + .flatMap { [weak self] messages in + self?.processFetchedMessages(messages, + appIsReady: appIsReady, + identityContext: identityContext) ?? Fulfill(value: true) } } - - /// `messages` are new messages coming from the server - private func mergeMessages(_ messages: [IterableInAppMessage], - identityScope: UserIdentitySnapshot?) -> MergeMessagesResult? { - guard identityScope == jsonOnlyMessageStore.identityScope else { return nil } - return MessagesObtainedHandler(messagesMap: messagesMap, messages: messages).handle() - } - - private func processMergedMessages(appIsReady: Bool, - mergeMessagesResult: MergeMessagesResult, - identityScope: UserIdentitySnapshot?) -> Pending { - guard identityScope == jsonOnlyMessageStore.identityScope else { - return Fulfill(value: true) - } - messagesMap = mergeMessagesResult.messagesMap - persistEligibleJsonOnlyMessages(identityScope: identityScope) - let processingResult: Pending - if appIsReady { - processingResult = processAndShowMessage(messagesMap: messagesMap, identityScope: identityScope) - } else { - processingResult = Fulfill(value: true) - } + private func processFetchedMessages(_ messages: [IterableInAppMessage], + appIsReady: Bool, + identityContext: UserIdentityContext) -> Pending { + let result = Fulfill() - return processingResult.map { [weak self] _ in - mergeMessagesResult.deliveredMessages.forEach { - self?.requestHandler?.track(inAppDelivery: $0, - onSuccess: nil, - onFailure: nil) + syncQueue.async { [weak self] in + guard let self = self else { + result.resolve(with: true) + return } - self?.finishSync(inboxChanged: mergeMessagesResult.inboxChanged) - return true + var mergeResult: MergeMessagesResult? + var processingRevision: UInt64? + let committed = self.identityCoordinator.performIfCurrent(identityContext, + identityProvider: self.identityProvider) { + let acknowledgementStatus = self.jsonOnlyMessageStore.acknowledgementStatus(for: messages, + identityContext: identityContext) + let merged = MessagesObtainedHandler(messagesMap: self.messagesMap, + messages: messages, + acknowledgedJsonOnlyMessageIds: acknowledgementStatus.unchangedMessageIds, + readmittedJsonOnlyMessageIds: acknowledgementStatus.changedMessageIds).handle() + self.messagesMap = merged.messagesMap + self.messagesIdentityContext = identityContext + processingRevision = self.messagesRevision + self.persistEligibleJsonOnlyMessages(messagesMap: merged.messagesMap, + identityContext: identityContext) + mergeResult = merged + } + guard committed, + let mergeResult = mergeResult, + let processingRevision = processingRevision else { + result.resolve(with: true) + return + } + + self.processingQueue.async { [weak self] in + guard let self = self else { + result.resolve(with: true) + return + } + let processingResult = appIsReady + ? self.processAndShowMessage(messagesMap: mergeResult.messagesMap, + identityContext: identityContext, + processingRevision: processingRevision) + : Fulfill(value: true) + processingResult.onSuccess { [weak self] _ in + guard let self = self else { + result.resolve(with: true) + return + } + self.syncQueue.async { + guard self.identityCoordinator.performIfCurrent(identityContext, + identityProvider: self.identityProvider, { + mergeResult.deliveredMessages.forEach { + self.requestHandler?.track(inAppDelivery: $0, + onSuccess: nil, + onFailure: nil) + } + self.finishSync(inboxChanged: mergeResult.inboxChanged) + }) else { + result.resolve(with: true) + return + } + result.resolve(with: true) + } + }.onError { error in + result.reject(with: error) + } + } } + + return result } private func finishSync(inboxChanged: Bool) { @@ -355,23 +417,66 @@ class InAppManager: NSObject, IterableInternalInAppManagerProtocol { } private func processAndShowMessage(messagesMap: OrderedDictionary, - identityScope: UserIdentitySnapshot?) -> Pending { - var processor = MessagesProcessor(inAppDelegate: inAppDelegate, inAppDisplayChecker: self, messagesMap: messagesMap) + identityContext: UserIdentityContext, + processingRevision: UInt64) -> Pending { + guard canProcessMessages(identityContext: identityContext, + processingRevision: processingRevision) else { + return Fulfill(value: true) + } + + var processor = MessagesProcessor(inAppDelegate: inAppDelegate, + inAppDisplayChecker: self, + messagesMap: messagesMap, + currentDate: dateProvider.currentDate, + isContextCurrent: { [weak self] in + self?.canProcessMessages(identityContext: identityContext, + processingRevision: processingRevision) ?? false + }) let messagesProcessorResult = processor.processMessages() - self.messagesMap = getMessagesMap(fromMessagesProcessorResult: messagesProcessorResult) + let updatedMessagesMap = getMessagesMap(fromMessagesProcessorResult: messagesProcessorResult) + var didCommit = false + guard identityCoordinator.performIfCurrent(identityContext, identityProvider: identityProvider, { + guard processingRevision == self.messagesRevision else { return } + self.messagesMap = updatedMessagesMap + self.messagesIdentityContext = identityContext + didCommit = true + }), didCommit else { + return Fulfill(value: true) + } if case let .jsonOnly(message, _) = messagesProcessorResult { - return deliverJsonOnlyMessage(message, consumeOnReplay: true, identityScope: identityScope).flatMap { [weak self] processed in - guard processed, let self = self else { + return deliverJsonOnlyMessage(message, consumeOnReplay: true, identityContext: identityContext).flatMap { [weak self] processed in + guard let self = self, + processed || InAppManager.isExpired(message: message, currentDate: self.dateProvider.currentDate) else { return Fulfill(value: true) } - return self.processAndShowMessage(messagesMap: self.messagesMap, identityScope: identityScope) + var nextMessagesMap = OrderedDictionary() + guard self.identityCoordinator.performIfCurrent(identityContext, + identityProvider: self.identityProvider, { + nextMessagesMap = self.messagesMap + }) else { + return Fulfill(value: true) + } + return self.processAndShowMessage(messagesMap: nextMessagesMap, + identityContext: identityContext, + processingRevision: processingRevision) } } - - showMessage(fromMessagesProcessorResult: messagesProcessorResult) + + _ = identityCoordinator.performIfCurrent(identityContext, identityProvider: identityProvider) { + self.showMessage(fromMessagesProcessorResult: messagesProcessorResult) + } return Fulfill(value: true) } + + private func canProcessMessages(identityContext: UserIdentityContext, + processingRevision: UInt64) -> Bool { + var canProcess = false + guard identityCoordinator.performIfCurrent(identityContext, identityProvider: identityProvider, { + canProcess = processingRevision == self.messagesRevision + }) else { return false } + return canProcess + } private func showInternal(message: IterableInAppMessage, consume: Bool, @@ -427,11 +532,23 @@ class InAppManager: NSObject, IterableInternalInAppManagerProtocol { guard appIsActive, let self = self else { return Fulfill(value: true) } - let identityScope = self.jsonOnlyMessageStore.identityScope - self.persistEligibleJsonOnlyMessages(identityScope: identityScope) - return self.processAndShowMessage(messagesMap: self.messagesMap, identityScope: identityScope).map { [weak self] _ in - if let messagesMap = self?.messagesMap { - self?.persister.persist(messagesMap.values) + let identityContext = self.identityCoordinator.capture(identityProvider: self.identityProvider) + var messagesMap = OrderedDictionary() + var processingRevision: UInt64 = 0 + guard self.identityCoordinator.performIfCurrent(identityContext, identityProvider: self.identityProvider, { + messagesMap = self.messagesMap + processingRevision = self.messagesRevision + self.persistEligibleJsonOnlyMessages(messagesMap: messagesMap, + identityContext: identityContext) + }) else { + return Fulfill(value: true) + } + return self.processAndShowMessage(messagesMap: messagesMap, + identityContext: identityContext, + processingRevision: processingRevision).map { [weak self] _ in + guard let self = self else { return true } + _ = self.identityCoordinator.performIfCurrent(identityContext, identityProvider: self.identityProvider) { + self.persister.persist(self.messagesMap.values) } return true } @@ -565,32 +682,38 @@ class InAppManager: NSObject, IterableInternalInAppManagerProtocol { } } - private func persistEligibleJsonOnlyMessages(identityScope: UserIdentitySnapshot?) { - guard let identityScope = identityScope else { return } - messagesMap.values - .filter { $0.isJsonOnly && !$0.didProcessTrigger && !$0.consumed && !$0.read && $0.trigger.type == .immediate } - .forEach { jsonOnlyMessageStore.enqueue($0, identityScope: identityScope) } + private func persistEligibleJsonOnlyMessages(messagesMap: OrderedDictionary, + identityContext: UserIdentityContext) { + guard identityContext.identity != nil else { return } + let messages = messagesMap.values.filter { + $0.isJsonOnly && !$0.didProcessTrigger && !$0.consumed && !$0.read && $0.trigger.type == .immediate + } + guard !messages.isEmpty else { return } + jsonOnlyMessageStore.enqueue(messages, identityContext: identityContext) } private func replayUnhandledJsonOnlyMessages() -> Pending { + let identityContext = jsonOnlyMessageStore.identityContext guard applicationStateProvider.applicationState == .active, - let identityScope = jsonOnlyMessageStore.identityScope else { + identityContext.identity != nil else { return Fulfill(value: true) } - return jsonOnlyMessageStore.getMessages(identityScope: identityScope).reduce(Fulfill(value: true) as Pending) { pending, message in + return jsonOnlyMessageStore.getMessages(identityContext: identityContext).reduce(Fulfill(value: true) as Pending) { pending, message in pending.flatMap { [weak self] _ in - self?.deliverJsonOnlyMessage(message, consumeOnReplay: false, identityScope: identityScope) ?? Fulfill(value: true) + self?.deliverJsonOnlyMessage(message, + consumeOnReplay: false, + identityContext: identityContext) ?? Fulfill(value: true) } } } private func deliverJsonOnlyMessage(_ message: IterableInAppMessage, consumeOnReplay: Bool, - identityScope: UserIdentitySnapshot?) -> Pending { + identityContext: UserIdentityContext) -> Pending { let result = Fulfill() - guard let identityScope = identityScope else { + guard identityContext.identity != nil else { if consumeOnReplay { deliverJsonOnlyMessageWithoutAvailability(message, result: result) } else { @@ -599,7 +722,7 @@ class InAppManager: NSObject, IterableInternalInAppManagerProtocol { return result } - guard jsonOnlyMessageStore.enqueue(message, identityScope: identityScope) else { + guard jsonOnlyMessageStore.enqueue(message, identityContext: identityContext) else { result.resolve(with: false) return result } @@ -611,18 +734,37 @@ class InAppManager: NSObject, IterableInternalInAppManagerProtocol { } guard self.applicationStateProvider.applicationState == .active, - let delivery = self.jsonOnlyMessageStore.prepareDelivery(for: message, identityScope: identityScope) else { + let delivery = self.jsonOnlyMessageStore.prepareDelivery(for: message, + identityContext: identityContext) else { result.resolve(with: false) return } if delivery.isInitial { - _ = self.inAppDelegate.onNew(message: delivery.message) + guard self.identityCoordinator.performIfCurrent(identityContext, + identityProvider: self.identityProvider, { + _ = self.inAppDelegate.onNew(message: delivery.message) + }) else { + result.resolve(with: false) + return + } + } + guard self.identityCoordinator.performIfCurrent(identityContext, + identityProvider: self.identityProvider, { + self.inAppDelegate.onJsonOnlyMessageAvailable?(message: delivery.message) + }) else { + result.resolve(with: false) + return + } + guard self.identityCoordinator.performIfCurrent(identityContext, + identityProvider: self.identityProvider, { + self.notificationCenter.post(name: .iterableJsonOnlyInAppMessageAvailable, + object: delivery.message, + userInfo: nil) + }) else { + result.resolve(with: false) + return } - self.inAppDelegate.onJsonOnlyMessageAvailable?(message: delivery.message) - self.notificationCenter.post(name: .iterableJsonOnlyInAppMessageAvailable, - object: delivery.message, - userInfo: nil) guard delivery.isInitial || consumeOnReplay else { result.resolve(with: true) @@ -630,14 +772,17 @@ class InAppManager: NSObject, IterableInternalInAppManagerProtocol { } self.updateQueue.async { [weak self] in - guard let self = self else { + guard let self = self, + self.identityCoordinator.performIfCurrent(identityContext, + identityProvider: self.identityProvider, { + self.updateMessageSync(message, didProcessTrigger: true, consumed: true) + self.requestHandler?.inAppConsume(message.messageId, + onSuccess: nil, + onFailure: nil) + }) else { result.resolve(with: false) return } - self.updateMessageSync(message, didProcessTrigger: true, consumed: true) - self.requestHandler?.inAppConsume(message.messageId, - onSuccess: nil, - onFailure: nil) result.resolve(with: true) } } @@ -746,7 +891,11 @@ class InAppManager: NSObject, IterableInternalInAppManagerProtocol { private let persister: InAppPersistenceProtocol private let jsonOnlyMessageStore: JsonOnlyMessageStore + private let identityCoordinator: IdentityCoordinator + private let identityProvider: () -> UserIdentitySnapshot? private var messagesMap = OrderedDictionary() + private var messagesIdentityContext: UserIdentityContext? + private var messagesRevision: UInt64 = 0 private let dateProvider: DateProviderProtocol private var lastDismissedTime: Date? private var lastDisplayTime: Date? @@ -755,6 +904,7 @@ class InAppManager: NSObject, IterableInternalInAppManagerProtocol { private let scheduleQueue = DispatchQueue(label: "ScheduleQueue") private let callbackQueue = DispatchQueue(label: "CallbackQueue") private let syncQueue = DispatchQueue(label: "SyncQueue") + private let processingQueue = DispatchQueue(label: "InAppProcessingQueue") private var syncResult: Pending? private var lastSyncTime: Date? @@ -817,13 +967,17 @@ extension InAppManager: InAppNotifiable { let result = Fulfill() syncQueue.async { [weak self] in - self?.messagesMap.reset() - if let messagesMap = self?.messagesMap { - self?.persister.persist(messagesMap.values) + guard let self = self else { return } + self.identityCoordinator.withCriticalSection { + let identityContext = self.identityCoordinator.capture(identityProvider: self.identityProvider) + self.messagesRevision &+= 1 + self.messagesMap.reset() + self.messagesIdentityContext = identityContext + self.persister.persist(self.messagesMap.values) } - self?.callbackQueue.async { - self?.notificationCenter.post(name: .iterableInboxChanged, object: self, userInfo: nil) + self.callbackQueue.async { + self.notificationCenter.post(name: .iterableInboxChanged, object: self, userInfo: nil) result.resolve(with: true) } } diff --git a/swift-sdk/Internal/in-app/InAppPersistence.swift b/swift-sdk/Internal/in-app/InAppPersistence.swift index 1f361c26e..b928e261e 100644 --- a/swift-sdk/Internal/in-app/InAppPersistence.swift +++ b/swift-sdk/Internal/in-app/InAppPersistence.swift @@ -396,93 +396,190 @@ final class JsonOnlyMessageStore { init(localStorage: LocalStorageProtocol, dateProvider: DateProviderProtocol, - identityProvider: @escaping () -> UserIdentitySnapshot?) { + identityProvider: @escaping () -> UserIdentitySnapshot?, + identityCoordinator: IdentityCoordinator = IdentityCoordinator()) { self.localStorage = localStorage self.dateProvider = dateProvider self.identityProvider = identityProvider + self.identityCoordinator = identityCoordinator } - var identityScope: UserIdentitySnapshot? { - stateQueue.sync { - identityProvider() - } + var identityContext: UserIdentityContext { + identityCoordinator.capture(identityProvider: identityProvider) } @discardableResult func enqueue(_ message: IterableInAppMessage) -> Bool { - guard let identityScope = identityScope else { return false } - return enqueue(message, identityScope: identityScope) + enqueue(message, identityContext: identityContext) } @discardableResult - func enqueue(_ message: IterableInAppMessage, identityScope: UserIdentitySnapshot) -> Bool { - stateQueue.sync { - guard var state = loadCurrentState(expectedIdentity: identityScope) else { return false } - let currentDate = dateProvider.currentDate - guard !Self.isExpired(message, at: currentDate) else { return false } - - if state.entries.contains(where: { $0.message.messageId == message.messageId }) { - return true + func enqueue(_ message: IterableInAppMessage, identityContext: UserIdentityContext) -> Bool { + guard let identity = identityContext.identity else { return false } + var result = false + guard identityCoordinator.performIfCurrent(identityContext, identityProvider: identityProvider, { + result = stateQueue.sync { + guard var state = loadCurrentState(currentIdentity: identity) else { return false } + let currentDate = dateProvider.currentDate + guard !Self.isExpired(message, at: currentDate) else { return false } + if state.entries.contains(where: { $0.message.messageId == message.messageId }) { + return true + } + guard !state.discardedUnacknowledgedMessageIds.contains(message.messageId) else { return false } + guard !isAcknowledgedUnchanged(message, in: state) else { return false } + state.acknowledgements.removeAll { $0.messageId == message.messageId } + state.entries.append(Entry(message: message, + storedAt: currentDate, + didBeginInitialDelivery: false)) + trimEntries(&state) + return persist(state) } - - state.entries.append(Entry(message: message, - storedAt: currentDate, - didBeginInitialDelivery: false)) - state.entries = Array(state.entries.suffix(Self.maximumRecordCount)) - return persist(state) - } + }) else { return false } + return result } - func prepareDelivery(for message: IterableInAppMessage) -> Delivery? { - guard let identityScope = identityScope else { return nil } - return prepareDelivery(for: message, identityScope: identityScope) + @discardableResult + func enqueue(_ messages: [IterableInAppMessage], identityContext: UserIdentityContext) -> Bool { + guard let identity = identityContext.identity else { return false } + var result = false + guard identityCoordinator.performIfCurrent(identityContext, identityProvider: identityProvider, { + result = stateQueue.sync { + guard var state = loadCurrentState(currentIdentity: identity) else { return false } + let currentDate = dateProvider.currentDate + var didChange = false + + messages.forEach { message in + guard !Self.isExpired(message, at: currentDate), + !state.entries.contains(where: { $0.message.messageId == message.messageId }), + !state.discardedUnacknowledgedMessageIds.contains(message.messageId), + !isAcknowledgedUnchanged(message, in: state) else { + return + } + state.acknowledgements.removeAll { $0.messageId == message.messageId } + state.entries.append(Entry(message: message, + storedAt: currentDate, + didBeginInitialDelivery: false)) + didChange = true + } + + guard didChange else { return true } + trimEntries(&state) + return persist(state) + } + }) else { return false } + return result } - func prepareDelivery(for message: IterableInAppMessage, identityScope: UserIdentitySnapshot) -> Delivery? { - stateQueue.sync { - guard var state = loadCurrentState(expectedIdentity: identityScope), - let index = state.entries.firstIndex(where: { $0.message.messageId == message.messageId }) else { - return nil - } - let currentDate = dateProvider.currentDate - guard !Self.isExpired(state.entries[index].message, at: currentDate) else { return nil } - let isInitial = !state.entries[index].didBeginInitialDelivery - if isInitial { - state.entries[index].didBeginInitialDelivery = true - guard persist(state) else { return nil } + func prepareDelivery(for message: IterableInAppMessage) -> Delivery? { + prepareDelivery(for: message, identityContext: identityContext) + } + + func prepareDelivery(for message: IterableInAppMessage, identityContext: UserIdentityContext) -> Delivery? { + guard let identity = identityContext.identity else { return nil } + var result: Delivery? + guard identityCoordinator.performIfCurrent(identityContext, identityProvider: identityProvider, { + result = stateQueue.sync { + guard var state = loadCurrentState(currentIdentity: identity), + let index = state.entries.firstIndex(where: { $0.message.messageId == message.messageId }) else { + return nil + } + let currentDate = dateProvider.currentDate + guard !Self.isExpired(state.entries[index].message, at: currentDate) else { return nil } + let isInitial = !state.entries[index].didBeginInitialDelivery + if isInitial { + state.entries[index].didBeginInitialDelivery = true + guard persist(state) else { return nil } + } + return Delivery(message: state.entries[index].message, isInitial: isInitial) } - return Delivery(message: state.entries[index].message, isInitial: isInitial) - } + }) else { return nil } + return result } func getMessages() -> [IterableInAppMessage] { - stateQueue.sync { - loadCurrentState()?.entries.map(\.message) ?? [] - } + getMessages(identityContext: identityContext) } - func getMessages(identityScope: UserIdentitySnapshot) -> [IterableInAppMessage] { - stateQueue.sync { - loadCurrentState(expectedIdentity: identityScope)?.entries.map(\.message) ?? [] - } + func getMessages(identityContext: UserIdentityContext) -> [IterableInAppMessage] { + guard let identity = identityContext.identity else { return [] } + var result = [IterableInAppMessage]() + guard identityCoordinator.performIfCurrent(identityContext, identityProvider: identityProvider, { + result = stateQueue.sync { + loadCurrentState(currentIdentity: identity)?.entries.map(\.message) ?? [] + } + }) else { return [] } + return result + } + + struct AcknowledgementStatus { + let unchangedMessageIds: Set + let changedMessageIds: Set + } + + func acknowledgementStatus(for messages: [IterableInAppMessage], + identityContext: UserIdentityContext) -> AcknowledgementStatus { + guard let identity = identityContext.identity else { + return AcknowledgementStatus(unchangedMessageIds: [], changedMessageIds: []) + } + var result = AcknowledgementStatus(unchangedMessageIds: [], changedMessageIds: []) + guard identityCoordinator.performIfCurrent(identityContext, identityProvider: identityProvider, { + result = stateQueue.sync { + guard let state = loadCurrentState(currentIdentity: identity) else { return result } + var unchangedMessageIds = Set() + var changedMessageIds = Set() + messages.forEach { message in + guard message.isJsonOnly else { return } + if state.discardedUnacknowledgedMessageIds.contains(message.messageId) { + unchangedMessageIds.insert(message.messageId) + return + } + guard let acknowledgement = state.acknowledgements.last(where: { $0.messageId == message.messageId }) else { + return + } + if let acknowledgedFingerprint = acknowledgement.payloadFingerprint, + let messageFingerprint = Self.payloadFingerprint(for: message), + acknowledgedFingerprint == messageFingerprint { + unchangedMessageIds.insert(message.messageId) + } else { + changedMessageIds.insert(message.messageId) + } + } + return AcknowledgementStatus(unchangedMessageIds: unchangedMessageIds, + changedMessageIds: changedMessageIds) + } + }) else { return AcknowledgementStatus(unchangedMessageIds: [], changedMessageIds: []) } + return result } @discardableResult func remove(messageId: String) -> Bool { - stateQueue.sync { - guard var state = loadCurrentState(), - let index = state.entries.firstIndex(where: { $0.message.messageId == messageId }) else { - return false + let context = identityContext + guard let identity = context.identity else { return false } + var result = false + guard identityCoordinator.performIfCurrent(context, identityProvider: identityProvider, { + result = stateQueue.sync { + guard var state = loadCurrentState(currentIdentity: identity), + let index = state.entries.firstIndex(where: { $0.message.messageId == messageId }) else { + return false + } + + let message = state.entries.remove(at: index).message + state.discardedUnacknowledgedMessageIds.removeAll { $0 == messageId } + state.acknowledgements.removeAll { $0.messageId == messageId } + state.acknowledgements.append(Acknowledgement(messageId: messageId, + payloadFingerprint: Self.payloadFingerprint(for: message))) + state.acknowledgements = Array(state.acknowledgements.suffix(Self.maximumAcknowledgementCount)) + return persist(state) } - - state.entries.remove(at: index) - return persist(state) - } + }) else { return false } + return result } func clear() { - stateQueue.sync { - localStorage.jsonOnlyMessageQueueData = nil + identityCoordinator.withCriticalSection { + stateQueue.sync { + localStorage.jsonOnlyMessageQueueData = nil + } } } @@ -513,9 +610,35 @@ final class JsonOnlyMessageStore { var didBeginInitialDelivery: Bool } + private struct Acknowledgement: Codable { + let messageId: String + let payloadFingerprint: Data? + } + private struct State: Codable { let identity: StoredIdentity var entries: [Entry] + var acknowledgements: [Acknowledgement] + var discardedUnacknowledgedMessageIds: [String] + + init(identity: StoredIdentity, + entries: [Entry], + acknowledgements: [Acknowledgement] = [], + discardedUnacknowledgedMessageIds: [String] = []) { + self.identity = identity + self.entries = entries + self.acknowledgements = acknowledgements + self.discardedUnacknowledgedMessageIds = discardedUnacknowledgedMessageIds + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + identity = try container.decode(StoredIdentity.self, forKey: .identity) + entries = try container.decode([Entry].self, forKey: .entries) + acknowledgements = try container.decodeIfPresent([Acknowledgement].self, forKey: .acknowledgements) ?? [] + discardedUnacknowledgedMessageIds = try container.decodeIfPresent([String].self, + forKey: .discardedUnacknowledgedMessageIds) ?? [] + } } private static func isExpired(_ message: IterableInAppMessage, at currentDate: Date) -> Bool { @@ -523,11 +646,8 @@ final class JsonOnlyMessageStore { return expiresAt <= currentDate } - private func loadCurrentState(expectedIdentity: UserIdentitySnapshot? = nil) -> State? { - guard let snapshot = identityProvider() else { return nil } - if let expectedIdentity = expectedIdentity, snapshot != expectedIdentity { return nil } - - let identity = StoredIdentity(snapshot) + private func loadCurrentState(currentIdentity: UserIdentitySnapshot) -> State? { + let identity = StoredIdentity(currentIdentity) var state: State var stateChanged = false if let data = localStorage.jsonOnlyMessageQueueData { @@ -548,15 +668,18 @@ final class JsonOnlyMessageStore { } let currentDate = dateProvider.currentDate - let retainedEntries = state.entries.filter { entry in + let expiredEntries = state.entries.filter { entry in if entry.message.expiresAt != nil { - return !Self.isExpired(entry.message, at: currentDate) + return Self.isExpired(entry.message, at: currentDate) } - return entry.storedAt.addingTimeInterval(Self.fallbackRetentionPeriod) > currentDate + return entry.storedAt.addingTimeInterval(Self.fallbackRetentionPeriod) <= currentDate } - if retainedEntries.count != state.entries.count { - state.entries = retainedEntries + if !expiredEntries.isEmpty { + state.discardedUnacknowledgedMessageIds.append(contentsOf: expiredEntries.map { $0.message.messageId }) + state.discardedUnacknowledgedMessageIds = Array(state.discardedUnacknowledgedMessageIds.suffix(Self.maximumDiscardedMessageCount)) + let expiredMessageIds = Set(expiredEntries.map { $0.message.messageId }) + state.entries.removeAll { expiredMessageIds.contains($0.message.messageId) } stateChanged = true } @@ -564,6 +687,57 @@ final class JsonOnlyMessageStore { return state } + private func isAcknowledgedUnchanged(_ message: IterableInAppMessage, in state: State) -> Bool { + guard let messageFingerprint = Self.payloadFingerprint(for: message) else { return false } + return state.acknowledgements.contains { + $0.messageId == message.messageId && $0.payloadFingerprint == messageFingerprint + } + } + + private func trimEntries(_ state: inout State) { + let overflow = state.entries.count - Self.maximumRecordCount + guard overflow > 0 else { return } + state.discardedUnacknowledgedMessageIds.append(contentsOf: state.entries.prefix(overflow).map { $0.message.messageId }) + state.discardedUnacknowledgedMessageIds = Array(state.discardedUnacknowledgedMessageIds.suffix(Self.maximumDiscardedMessageCount)) + state.entries = Array(state.entries.suffix(Self.maximumRecordCount)) + } + + private static func payloadFingerprint(for message: IterableInAppMessage) -> Data? { + guard let customPayload = message.customPayload, + let canonicalPayload = canonicalJson(customPayload) else { return nil } + return canonicalPayload.data(using: .utf8) + } + + private static func canonicalJson(_ value: Any) -> String? { + if let dictionary = value as? [AnyHashable: Any] { + var entries = [(String, Any)]() + for (key, value) in dictionary { + guard let key = key as? String else { return nil } + entries.append((key, value)) + } + entries.sort { $0.0 < $1.0 } + var encodedEntries = [String]() + for (key, value) in entries { + guard let encodedKey = canonicalJson(key), + let encodedValue = canonicalJson(value) else { return nil } + encodedEntries.append("\(encodedKey):\(encodedValue)") + } + return "{\(encodedEntries.joined(separator: ","))}" + } + if let array = value as? [Any] { + var encodedValues = [String]() + for value in array { + guard let encodedValue = canonicalJson(value) else { return nil } + encodedValues.append(encodedValue) + } + return "[\(encodedValues.joined(separator: ","))]" + } + guard JSONSerialization.isValidJSONObject([value]), + let data = try? JSONSerialization.data(withJSONObject: [value], options: []), + let encodedArray = String(data: data, encoding: .utf8) else { return nil } + return String(encodedArray.dropFirst().dropLast()) + } + private func persist(_ state: State) -> Bool { do { localStorage.jsonOnlyMessageQueueData = try JSONEncoder().encode(state) @@ -577,10 +751,13 @@ final class JsonOnlyMessageStore { // Product defaults pending confirmation. private static let fallbackRetentionPeriod: TimeInterval = 30 * 24 * 60 * 60 private static let maximumRecordCount = 100 + private static let maximumAcknowledgementCount = 100 + private static let maximumDiscardedMessageCount = 100 private var localStorage: LocalStorageProtocol private let dateProvider: DateProviderProtocol private let identityProvider: () -> UserIdentitySnapshot? + private let identityCoordinator: IdentityCoordinator private let stateQueue = DispatchQueue(label: "JsonOnlyMessageStore") } diff --git a/swift-sdk/SDK/IterableConfig.swift b/swift-sdk/SDK/IterableConfig.swift index e5d09c006..2dbe01c63 100644 --- a/swift-sdk/SDK/IterableConfig.swift +++ b/swift-sdk/SDK/IterableConfig.swift @@ -68,6 +68,7 @@ public struct IterableAPIMobileFrameworkInfo: Codable { @objc public protocol IterableInAppDelegate: AnyObject { /// This method is called when new in-app message is available. /// The default behavior is to `show` if you don't override this method. + /// For JSON-only messages, this method runs inside an SDK identity critical section and must not synchronously wait on another thread that makes any Iterable SDK call that reads or changes identity or message state, including request-sending APIs. /// /// - Parameters: /// - message: `IterableInAppMessage` object containing information regarding in-app to display @@ -78,6 +79,7 @@ public struct IterableAPIMobileFrameworkInfo: Codable { /// Called on the main thread when a JSON-only message is available locally. /// This may be called more than once until the message is marked as handled. + /// The callback runs inside an SDK identity critical section and may call SDK identity APIs on the same thread. It must not synchronously wait on another thread that makes any Iterable SDK call that reads or changes identity or message state, including request-sending APIs, because doing so can deadlock. @objc(onJsonOnlyMessageAvailable:) optional func onJsonOnlyMessageAvailable(message: IterableInAppMessage) } @@ -217,4 +219,3 @@ public class IterableConfig: NSObject { /// The type of mobile framework we are using. public var mobileFrameworkInfo: IterableAPIMobileFrameworkInfo? } - diff --git a/swift-sdk/SDK/IterableMessaging.swift b/swift-sdk/SDK/IterableMessaging.swift index aefda0fbd..beac15cb2 100644 --- a/swift-sdk/SDK/IterableMessaging.swift +++ b/swift-sdk/SDK/IterableMessaging.swift @@ -19,9 +19,15 @@ public extension Notification.Name { static let iterableInboxChanged = Notification.Name(rawValue: "itbl_inbox_changed") /// This is fired when a JSON-only in-app message is available locally. + /// Notification observers run inside an SDK identity critical section and may call SDK identity APIs on the same thread. They must not synchronously wait on another thread that makes any Iterable SDK call that reads or changes identity or message state, including request-sending APIs, because doing so can deadlock. static let iterableJsonOnlyInAppMessageAvailable = Notification.Name(rawValue: "itbl_json_only_in_app_message_available") } +public extension IterableAPI { + /// Objective-C name for the JSON-only availability notification. + @objc static let jsonOnlyInAppMessageAvailableNotification = Notification.Name.iterableJsonOnlyInAppMessageAvailable +} + @objcMembers open class DefaultInAppDelegate: IterableInAppDelegate { public init() {} diff --git a/tests/common/MockInAppPersister.swift b/tests/common/MockInAppPersister.swift index b55c5e82d..6f473fd12 100644 --- a/tests/common/MockInAppPersister.swift +++ b/tests/common/MockInAppPersister.swift @@ -7,6 +7,8 @@ import Foundation @testable import IterableSDK class MockInAppPersister: InAppPersistenceProtocol { + var onPersist: (() -> Void)? + private var messages = [IterableInAppMessage]() func getMessages() -> [IterableInAppMessage] { @@ -15,6 +17,8 @@ class MockInAppPersister: InAppPersistenceProtocol { func persist(_ messages: [IterableInAppMessage]) { self.messages = messages + let callback = onPersist + callback?() } func clear() { diff --git a/tests/common/MockLocalStorage.swift b/tests/common/MockLocalStorage.swift index cd430465a..28231f066 100644 --- a/tests/common/MockLocalStorage.swift +++ b/tests/common/MockLocalStorage.swift @@ -42,7 +42,18 @@ class MockLocalStorage: LocalStorageProtocol { var hasStoredNotificationSetting: Bool = false - var jsonOnlyMessageQueueData: Data? + var jsonOnlyMessageQueueData: Data? { + get { + onJsonOnlyMessageQueueDataRead?() + return storedJsonOnlyMessageQueueData + } + set { + storedJsonOnlyMessageQueueData = newValue + jsonOnlyMessageQueueDataWriteCount += 1 + } + } + var onJsonOnlyMessageQueueDataRead: (() -> Void)? + private(set) var jsonOnlyMessageQueueDataWriteCount = 0 func getAttributionInfo(currentDate: Date) -> IterableAttributionInfo? { guard !MockLocalStorage.isExpired(expiration: attributionInfoExpiration, currentDate: currentDate) else { @@ -58,6 +69,7 @@ class MockLocalStorage: LocalStorageProtocol { private var attributionInfo: IterableAttributionInfo? = nil private var attributionInfoExpiration: Date? = nil + private var storedJsonOnlyMessageQueueData: Data? private static func isExpired(expiration: Date?, currentDate: Date) -> Bool { guard let expiration = expiration else { diff --git a/tests/unit-tests/InAppMessageProcessorTests.swift b/tests/unit-tests/InAppMessageProcessorTests.swift index fbac26bfd..465987ac1 100644 --- a/tests/unit-tests/InAppMessageProcessorTests.swift +++ b/tests/unit-tests/InAppMessageProcessorTests.swift @@ -38,6 +38,23 @@ class InAppMessageProcessorTests: XCTestCase { messages: newMessages).handle() XCTAssertEqual(result.deliveredMessages.count, 1) } + + func testServerReadStateDoesNotClearLocalConsumeState() { + let messageId = "msg-1" + let localMessage = Self.makeEmptyInboxMessage(messageId) + localMessage.consumed = true + localMessage.didProcessTrigger = true + let serverMessage = Self.makeEmptyInboxMessage(messageId) + serverMessage.read = true + + let result = MessagesObtainedHandler(messagesMap: [messageId: localMessage], + messages: [serverMessage]).handle() + let mergedMessage = result.messagesMap[messageId] + + XCTAssertTrue(mergedMessage?.read == true) + XCTAssertTrue(mergedMessage?.consumed == true) + XCTAssertTrue(mergedMessage?.didProcessTrigger == true) + } private static let emptyInAppContent = IterableHtmlInAppContent(edgeInsets: .zero, html: "") diff --git a/tests/unit-tests/InAppTests.swift b/tests/unit-tests/InAppTests.swift index 99533a846..5df5d9fef 100644 --- a/tests/unit-tests/InAppTests.swift +++ b/tests/unit-tests/InAppTests.swift @@ -1967,6 +1967,14 @@ private final class LegacySwiftInAppDelegate: NSObject, IterableInAppDelegate { } } +private final class CallbackInAppDisplayDelegate: NSObject, IterableInAppDisplayDelegate { + var callback: ((IterableInAppMessage) -> Bool)? + + func isAutoDisplayPaused(for message: IterableInAppMessage) -> Bool { + callback?(message) ?? false + } +} + private final class BlockingInAppFetcher: InAppFetcherProtocol { func blockNextFetch(with messages: [IterableInAppMessage]) { blockedMessages = messages @@ -2077,6 +2085,298 @@ final class JsonOnlyMessageAvailabilityTests: XCTestCase { XCTAssertEqual(IterableAPI.getUnhandledJsonOnlyMessages().map(\.messageId), [second.messageId]) } + func testExpiredJsonOnlyMessageDoesNotBlockRemainingBatch() { + let availabilityExpectation = expectation(description: "valid JSON availability") + let htmlExpectation = expectation(description: "HTML processed") + let dateProvider = MockDateProvider() + let fetcher = MockInAppFetcher() + let delegate = MockInAppDelegate() + let expired = makeJsonOnlyMessage(id: "expired", expiresAt: dateProvider.currentDate) + let valid = makeJsonOnlyMessage(id: "valid") + let html = makeHtmlMessage(id: "html", triggerType: .immediate) + + delegate.onNewMessageCallback = { message in + if message.messageId == html.messageId { + htmlExpectation.fulfill() + } + } + delegate.onJsonOnlyMessageAvailableCallback = { message in + XCTAssertEqual(message.messageId, valid.messageId) + availabilityExpectation.fulfill() + } + + let internalAPI = initialize(fetcher: fetcher, + delegate: delegate, + dateProvider: dateProvider) + fetch([expired, valid, html], with: fetcher, internalAPI: internalAPI) + + wait(for: [availabilityExpectation, htmlExpectation], timeout: testExpectationTimeout) + XCTAssertEqual(IterableAPI.getUnhandledJsonOnlyMessages().map(\.messageId), [valid.messageId]) + XCTAssertTrue(html.didProcessTrigger) + } + + func testJsonOnlyAvailabilityIgnoresAutoDisplayPause() { + let availabilityExpectation = expectation(description: "JSON availability while paused") + let fetcher = MockInAppFetcher() + let delegate = MockInAppDelegate() + let json = makeJsonOnlyMessage(id: "json") + let html = makeHtmlMessage(id: "html", triggerType: .immediate) + delegate.onJsonOnlyMessageAvailableCallback = { _ in availabilityExpectation.fulfill() } + let internalAPI = initialize(fetcher: fetcher, delegate: delegate) + internalAPI.inAppManager.isAutoDisplayPaused = true + + fetch([json, html], with: fetcher, internalAPI: internalAPI) + + wait(for: [availabilityExpectation], timeout: testExpectationTimeout) + XCTAssertFalse(html.didProcessTrigger) + } + + func testJsonOnlyAvailabilityIgnoresPopupCooldown() { + let firstShowExpectation = expectation(description: "first HTML shown") + let availabilityExpectation = expectation(description: "JSON availability during cooldown") + let dismissalExpectation = expectation(description: "first HTML dismissed") + let dateProvider = MockDateProvider() + let displayer = MockInAppDisplayer() + let fetcher = MockInAppFetcher() + let delegate = MockInAppDelegate() + let firstHtml = makeHtmlMessage(id: "html-1", triggerType: .immediate) + let secondHtml = makeHtmlMessage(id: "html-2", triggerType: .immediate) + let json = makeJsonOnlyMessage(id: "json") + + displayer.onShow.onSuccess { _ in + displayer.click(url: URL(string: "iterable://dismiss")!) + firstShowExpectation.fulfill() + } + delegate.onJsonOnlyMessageAvailableCallback = { _ in availabilityExpectation.fulfill() } + let internalAPI = initialize(fetcher: fetcher, + displayer: displayer, + delegate: delegate, + dateProvider: dateProvider, + displayInterval: 60) + fetch([firstHtml], with: fetcher, internalAPI: internalAPI) + wait(for: [firstShowExpectation], timeout: testExpectationTimeout) + DispatchQueue.main.async { dismissalExpectation.fulfill() } + wait(for: [dismissalExpectation], timeout: testExpectationTimeout) + + fetch([json, secondHtml], with: fetcher, internalAPI: internalAPI) + + wait(for: [availabilityExpectation], timeout: testExpectationTimeout) + XCTAssertFalse(secondHtml.didProcessTrigger) + } + + func testSlowHtmlCallbackDoesNotBlockReset() { + let callbackStarted = DispatchSemaphore(value: 0) + let releaseCallback = DispatchSemaphore(value: 0) + let resetExpectation = expectation(description: "reset completed") + let fetchExpectation = expectation(description: "fetch completed") + let fetcher = MockInAppFetcher() + let delegate = MockInAppDelegate(showInApp: .skip) + let message = makeHtmlMessage(id: "html", triggerType: .immediate) + var internalAPI: InternalIterableAPI! + + delegate.onNewMessageCallback = { _ in + callbackStarted.signal() + internalAPI.inAppManager.reset().onSuccess { _ in resetExpectation.fulfill() } + releaseCallback.wait() + } + internalAPI = initialize(fetcher: fetcher, delegate: delegate) + fetcher.add(message: message) + internalAPI.inAppManager.scheduleSync().onSuccess { _ in fetchExpectation.fulfill() } + + XCTAssertEqual(callbackStarted.wait(timeout: .now() + testExpectationTimeout), .success) + wait(for: [resetExpectation], timeout: testExpectationTimeout) + releaseCallback.signal() + wait(for: [fetchExpectation], timeout: testExpectationTimeout) + XCTAssertFalse(internalAPI.inAppManager.getMessages().contains { $0.messageId == message.messageId }) + } + + func testQueuedHtmlProcessingSkipsStaleIdentity() { + let processingStarted = DispatchSemaphore(value: 0) + let releaseProcessing = DispatchSemaphore(value: 0) + let fetchCompleted = expectation(description: "fetch completed") + let noOnNew = expectation(description: "no stale HTML onNew") + noOnNew.isInverted = true + let noDisplay = expectation(description: "no stale HTML display") + noDisplay.isInverted = true + let fetcher = MockInAppFetcher() + let displayer = MockInAppDisplayer() + let delegate = MockInAppDelegate() + let html = makeHtmlMessage(id: "html-a", triggerType: .immediate) + let internalAPI = initialize(fetcher: fetcher, displayer: displayer, delegate: delegate) + let manager = internalAPI.inAppManager as! InAppManager + let processingQueue = Mirror(reflecting: manager).children.first { $0.label == "processingQueue" }?.value as! DispatchQueue + + delegate.onNewMessageCallback = { _ in noOnNew.fulfill() } + displayer.onShow.onSuccess { _ in noDisplay.fulfill() } + processingQueue.async { + processingStarted.signal() + releaseProcessing.wait() + } + XCTAssertEqual(processingStarted.wait(timeout: .now() + testExpectationTimeout), .success) + + fetcher.add(message: html) + internalAPI.inAppManager.scheduleSync().onSuccess { _ in fetchCompleted.fulfill() } + let deadline = Date().addingTimeInterval(testExpectationTimeout) + while !internalAPI.inAppManager.getMessages().contains(where: { $0.messageId == html.messageId }), Date() < deadline { + Thread.sleep(forTimeInterval: 0.01) + } + XCTAssertTrue(internalAPI.inAppManager.getMessages().contains { $0.messageId == html.messageId }) + + fetcher.mockMessagesAvailableFromServer(internalApi: nil, messages: []) + internalAPI.setUserId("user-b") + releaseProcessing.signal() + + wait(for: [fetchCompleted], timeout: testExpectationTimeout) + wait(for: [noOnNew, noDisplay], timeout: testExpectationTimeoutForInverted) + } + + func testIdentitySwitchInDisplayDelegateStopsOnNew() { + let displayCheckExpectation = expectation(description: "display delegate called") + let noOnNew = expectation(description: "no stale onNew") + noOnNew.isInverted = true + let fetcher = MockInAppFetcher() + let delegate = MockInAppDelegate() + let displayDelegate = CallbackInAppDisplayDelegate() + let message = makeHtmlMessage(id: "html-a", triggerType: .immediate) + var internalAPI: InternalIterableAPI! + + delegate.onNewMessageCallback = { _ in noOnNew.fulfill() } + displayDelegate.callback = { _ in + fetcher.mockMessagesAvailableFromServer(internalApi: nil, messages: []) + internalAPI.setUserId("user-b") + displayCheckExpectation.fulfill() + return false + } + internalAPI = initialize(fetcher: fetcher, + delegate: delegate, + displayDelegate: displayDelegate) + + fetch([message], with: fetcher, internalAPI: internalAPI) + + wait(for: [displayCheckExpectation], timeout: testExpectationTimeout) + wait(for: [noOnNew], timeout: testExpectationTimeoutForInverted) + } + + func testIdentitySwitchInOnNewStopsRecursiveDisplayCheck() { + let firstDisplayCheck = expectation(description: "first display check") + let firstOnNew = expectation(description: "first onNew") + let noSecondDisplayCheck = expectation(description: "no stale second display check") + noSecondDisplayCheck.isInverted = true + let noSecondOnNew = expectation(description: "no stale second onNew") + noSecondOnNew.isInverted = true + let fetcher = MockInAppFetcher() + let delegate = MockInAppDelegate(showInApp: .skip) + let displayDelegate = CallbackInAppDisplayDelegate() + let first = makeHtmlMessage(id: "html-a", triggerType: .immediate) + let second = makeHtmlMessage(id: "html-b", triggerType: .immediate) + var internalAPI: InternalIterableAPI! + + displayDelegate.callback = { message in + if message.messageId == first.messageId { + firstDisplayCheck.fulfill() + } else if message.messageId == second.messageId { + noSecondDisplayCheck.fulfill() + } + return false + } + delegate.onNewMessageCallback = { message in + if message.messageId == first.messageId { + fetcher.mockMessagesAvailableFromServer(internalApi: nil, messages: []) + internalAPI.setUserId("user-b") + firstOnNew.fulfill() + } else if message.messageId == second.messageId { + noSecondOnNew.fulfill() + } + } + internalAPI = initialize(fetcher: fetcher, + delegate: delegate, + displayDelegate: displayDelegate) + + fetch([first, second], with: fetcher, internalAPI: internalAPI) + + wait(for: [firstDisplayCheck, firstOnNew], timeout: testExpectationTimeout) + wait(for: [noSecondDisplayCheck, noSecondOnNew], timeout: testExpectationTimeoutForInverted) + } + + func testIdentitySwitchInOnNewStopsLaterDeliverySteps() { + assertIdentitySwitchDuringDelivery(at: .onNew) + } + + func testIdentitySwitchInAvailabilityDelegateStopsLaterDeliverySteps() { + assertIdentitySwitchDuringDelivery(at: .delegate) + } + + func testIdentitySwitchInNotificationStopsConsume() { + assertIdentitySwitchDuringDelivery(at: .notification) + } + + func testConcurrentIdentitySwitchDuringOnNewStopsLaterDeliverySteps() { + let onNewStarted = DispatchSemaphore(value: 0) + let releaseOnNew = DispatchSemaphore(value: 0) + let switchStarted = DispatchSemaphore(value: 0) + let switchCompleted = DispatchSemaphore(value: 0) + let coordinationCompleted = expectation(description: "identity switch coordinated") + let fetchCompleted = expectation(description: "fetch completed") + let noAvailability = expectation(description: "no availability") + noAvailability.isInverted = true + let noNotification = expectation(description: "no notification") + noNotification.isInverted = true + let noConsume = expectation(description: "no consume") + noConsume.isInverted = true + let fetcher = MockInAppFetcher() + let notificationCenter = MockNotificationCenter() + let networkSession = MockNetworkSession() + let delegate = MockInAppDelegate() + let message = makeJsonOnlyMessage(id: "message-a") + + delegate.onNewMessageCallback = { _ in + onNewStarted.signal() + releaseOnNew.wait() + } + delegate.onJsonOnlyMessageAvailableCallback = { _ in noAvailability.fulfill() } + let notificationReference = notificationCenter.addCallback(forNotification: .iterableJsonOnlyInAppMessageAvailable) { _ in + noNotification.fulfill() + } + networkSession.requestCallback = { request in + if request.url?.path.contains(Const.Path.inAppConsume) == true { + noConsume.fulfill() + } + } + let internalAPI = initialize(fetcher: fetcher, + delegate: delegate, + networkSession: networkSession, + notificationCenter: notificationCenter) + DispatchQueue.global().async { + guard onNewStarted.wait(timeout: .now() + testExpectationTimeout) == .success else { + XCTFail("onNew did not start") + releaseOnNew.signal() + coordinationCompleted.fulfill() + return + } + fetcher.mockMessagesAvailableFromServer(internalApi: nil, messages: []) + DispatchQueue.global().async { + switchStarted.signal() + internalAPI.setUserId("user-b") + switchCompleted.signal() + } + XCTAssertEqual(switchStarted.wait(timeout: .now() + testExpectationTimeout), .success) + XCTAssertEqual(switchCompleted.wait(timeout: .now() + 0.1), .timedOut) + releaseOnNew.signal() + XCTAssertEqual(switchCompleted.wait(timeout: .now() + testExpectationTimeout), .success) + coordinationCompleted.fulfill() + } + fetcher.add(message: message) + internalAPI.inAppManager.scheduleSync().onSuccess { _ in fetchCompleted.fulfill() } + + wait(for: [coordinationCompleted, fetchCompleted], timeout: testExpectationTimeout) + wait(for: [noAvailability, noNotification, noConsume], timeout: testExpectationTimeoutForInverted) + XCTAssertTrue(IterableAPI.getUnhandledJsonOnlyMessages().isEmpty) + XCTAssertFalse(internalAPI.inAppManager.getMessages().contains { $0.messageId == message.messageId }) + XCTAssertFalse(message.didProcessTrigger) + XCTAssertFalse(message.consumed) + notificationCenter.removeCallbacks(withIds: notificationReference.callbackId) + } + func testIdentitySwitchBeforeMainDeliveryDoesNotLeakMessage() { let noOnNewExpectation = expectation(description: "no onNew after identity switch") noOnNewExpectation.isInverted = true @@ -2158,6 +2458,7 @@ final class JsonOnlyMessageAvailabilityTests: XCTestCase { localStorage.email = Self.email let notificationCenter = MockNotificationCenter() let fetcher = BlockingInAppFetcher() + let persister = MockInAppPersister() let delegate = MockInAppDelegate() let message = makeJsonOnlyMessage(id: "message-a") @@ -2168,6 +2469,7 @@ final class JsonOnlyMessageAvailabilityTests: XCTestCase { } let internalAPI = initialize(localStorage: localStorage, fetcher: fetcher, + persister: persister, delegate: delegate, notificationCenter: notificationCenter) fetcher.blockNextFetch(with: [message]) @@ -2184,9 +2486,48 @@ final class JsonOnlyMessageAvailabilityTests: XCTestCase { wait(for: [noOnNewExpectation, noAvailabilityExpectation, noNotificationExpectation], timeout: testExpectationTimeoutForInverted) XCTAssertTrue(IterableAPI.getUnhandledJsonOnlyMessages().isEmpty) XCTAssertFalse(internalAPI.inAppManager.getMessages().contains(where: { $0.messageId == message.messageId })) + XCTAssertFalse(persister.getMessages().contains(where: { $0.messageId == message.messageId })) notificationCenter.removeCallbacks(withIds: notificationReference.callbackId) } + func testIdentitySwitchDuringFetchCommitDoesNotPersistStaleState() { + let commitStarted = DispatchSemaphore(value: 0) + let continueCommit = DispatchSemaphore(value: 0) + let switchCompleted = DispatchSemaphore(value: 0) + let fetchCompleted = expectation(description: "fetch completed") + let localStorage = MockLocalStorage() + localStorage.email = Self.email + let fetcher = MockInAppFetcher() + let persister = MockInAppPersister() + let message = makeJsonOnlyMessage(id: "message-a") + let internalAPI = initialize(localStorage: localStorage, + fetcher: fetcher, + persister: persister) + + localStorage.onJsonOnlyMessageQueueDataRead = { + localStorage.onJsonOnlyMessageQueueDataRead = nil + commitStarted.signal() + continueCommit.wait() + } + fetcher.add(message: message) + internalAPI.inAppManager.scheduleSync().onSuccess { _ in fetchCompleted.fulfill() } + XCTAssertEqual(commitStarted.wait(timeout: .now() + testExpectationTimeout), .success) + fetcher.mockMessagesAvailableFromServer(internalApi: nil, messages: []) + + DispatchQueue.global().async { + internalAPI.setUserId("user-b") + switchCompleted.signal() + } + XCTAssertEqual(switchCompleted.wait(timeout: .now() + 0.1), .timedOut) + continueCommit.signal() + XCTAssertEqual(switchCompleted.wait(timeout: .now() + testExpectationTimeout), .success) + + wait(for: [fetchCompleted], timeout: testExpectationTimeout) + XCTAssertTrue(IterableAPI.getUnhandledJsonOnlyMessages().isEmpty) + XCTAssertFalse(internalAPI.inAppManager.getMessages().contains { $0.messageId == message.messageId }) + XCTAssertFalse(persister.getMessages().contains { $0.messageId == message.messageId }) + } + func testBackgroundFetchSurvivesRecreationAndDeliversOnForeground() { let localStorage = MockLocalStorage() localStorage.email = Self.email @@ -2271,10 +2612,11 @@ final class JsonOnlyMessageAvailabilityTests: XCTestCase { delegate.onJsonOnlyMessageAvailableCallback = { _ in availabilityCount += 1 } let internalAPI = initialize(fetcher: fetcher, delegate: delegate) - fetch([makeJsonOnlyMessage(id: "message-1")], with: fetcher, internalAPI: internalAPI) - fetch([makeJsonOnlyMessage(id: "message-1")], with: fetcher, internalAPI: internalAPI) + fetch([makeJsonOnlyMessage(id: "message-1", payloadId: "first")], with: fetcher, internalAPI: internalAPI) + fetch([makeJsonOnlyMessage(id: "message-1", payloadId: "second")], with: fetcher, internalAPI: internalAPI) XCTAssertEqual(IterableAPI.getUnhandledJsonOnlyMessages().map(\.messageId), ["message-1"]) + XCTAssertEqual(IterableAPI.getUnhandledJsonOnlyMessages().first?.customPayload?["id"] as? String, "first") XCTAssertEqual(availabilityCount, 1) } @@ -2285,11 +2627,11 @@ final class JsonOnlyMessageAvailabilityTests: XCTestCase { let store = JsonOnlyMessageStore(localStorage: localStorage, dateProvider: dateProvider, identityProvider: { UserIdentitySnapshot(auth: auth) }) + let identityContext = store.identityContext let first = makeJsonOnlyMessage(id: "message-1", payloadId: "first") let second = makeJsonOnlyMessage(id: "message-1", payloadId: "second") - XCTAssertTrue(store.enqueue(first)) - XCTAssertTrue(store.enqueue(second)) + XCTAssertTrue(store.enqueue([first, second], identityContext: identityContext)) XCTAssertEqual(store.getMessages().first?.customPayload?["id"] as? String, "first") XCTAssertTrue(store.remove(messageId: first.messageId)) @@ -2297,6 +2639,200 @@ final class JsonOnlyMessageAvailabilityTests: XCTestCase { XCTAssertEqual(store.getMessages().first?.customPayload?["id"] as? String, "second") } + func testAcknowledgedMessageIdCanBeReadmittedWithNewPayload() { + let availabilityExpectation = expectation(description: "readmitted availability") + let deliveryTrackExpectation = expectation(description: "readmitted delivery tracked") + let fetcher = MockInAppFetcher() + let delegate = MockInAppDelegate() + let networkSession = MockNetworkSession() + let applicationState = MockApplicationStateProvider(applicationState: .background) + let notificationCenter = MockNotificationCenter() + let first = makeJsonOnlyMessage(id: "message-1", payloadId: "first") + let second = makeJsonOnlyMessage(id: "message-1", payloadId: "second") + var payloadIds = [String]() + delegate.onJsonOnlyMessageAvailableCallback = { message in + payloadIds.append(message.customPayload?["id"] as! String) + availabilityExpectation.fulfill() + } + let internalAPI = initialize(fetcher: fetcher, + delegate: delegate, + networkSession: networkSession, + applicationState: applicationState, + notificationCenter: notificationCenter) + + fetch([first], with: fetcher, internalAPI: internalAPI) + XCTAssertTrue(IterableAPI.markJsonOnlyMessageHandled(messageId: first.messageId)) + networkSession.requestCallback = { request in + guard request.url?.path.contains(Const.Path.trackInAppDelivery) == true else { return } + let body = request.httpBody?.json() as? [String: Any] + XCTAssertEqual(body?[JsonKey.messageId] as? String, second.messageId) + deliveryTrackExpectation.fulfill() + } + fetch([second], with: fetcher, internalAPI: internalAPI) + wait(for: [deliveryTrackExpectation], timeout: testExpectationTimeout) + networkSession.requestCallback = nil + + applicationState.applicationState = .active + notificationCenter.post(name: UIApplication.didBecomeActiveNotification, object: nil, userInfo: nil) + + wait(for: [availabilityExpectation], timeout: testExpectationTimeout) + XCTAssertEqual(payloadIds, ["second"]) + XCTAssertEqual(IterableAPI.getUnhandledJsonOnlyMessages().first?.customPayload?["id"] as? String, "second") + } + + func testAcknowledgedJsonIdDoesNotSuppressSameIdHtmlMessage() { + let onNewExpectation = expectation(description: "HTML onNew") + let displayExpectation = expectation(description: "HTML displayed") + let deliveryTrackExpectation = expectation(description: "HTML delivery tracked") + let inboxChangedExpectation = expectation(description: "inbox changed") + let fetcher = MockInAppFetcher() + let delegate = MockInAppDelegate() + let displayer = MockInAppDisplayer() + let networkSession = MockNetworkSession() + let notificationCenter = MockNotificationCenter() + let applicationState = MockApplicationStateProvider(applicationState: .background) + let json = makeJsonOnlyMessage(id: "shared", payloadId: "payload") + let html = makeHtmlMessage(id: "shared", + triggerType: .immediate, + saveToInbox: true, + customPayload: ["id": "payload"]) + delegate.onNewMessageCallback = { message in + guard !message.isJsonOnly else { return } + onNewExpectation.fulfill() + } + displayer.onShow.onSuccess { message in + XCTAssertEqual(message.messageId, html.messageId) + displayExpectation.fulfill() + } + let internalAPI = initialize(fetcher: fetcher, + displayer: displayer, + delegate: delegate, + networkSession: networkSession, + applicationState: applicationState, + notificationCenter: notificationCenter) + + fetch([json], with: fetcher, internalAPI: internalAPI) + XCTAssertTrue(IterableAPI.markJsonOnlyMessageHandled(messageId: json.messageId)) + var didObserveDeliveryTrack = false + networkSession.requestCallback = { request in + guard request.url?.path.contains(Const.Path.trackInAppDelivery) == true, + !didObserveDeliveryTrack else { return } + didObserveDeliveryTrack = true + let body = request.httpBody?.json() as? [String: Any] + XCTAssertEqual(body?[JsonKey.messageId] as? String, html.messageId) + deliveryTrackExpectation.fulfill() + } + var didObserveInboxChanged = false + let notificationReference = notificationCenter.addCallback(forNotification: .iterableInboxChanged) { _ in + guard !didObserveInboxChanged else { return } + didObserveInboxChanged = true + inboxChangedExpectation.fulfill() + } + applicationState.applicationState = .active + fetch([html], with: fetcher, internalAPI: internalAPI) + + wait(for: [onNewExpectation, displayExpectation, deliveryTrackExpectation, inboxChangedExpectation], + timeout: testExpectationTimeout) + XCTAssertTrue(internalAPI.inAppManager.getInboxMessages().contains { + $0.messageId == html.messageId && !$0.isJsonOnly + }) + notificationCenter.removeCallbacks(withIds: notificationReference.callbackId) + } + + func testAcknowledgedJsonTypeRoundTripDoesNotBlockLaterHtml() { + let laterHtmlExpectation = expectation(description: "later HTML processed") + let fetcher = MockInAppFetcher() + let delegate = MockInAppDelegate(showInApp: .skip) + let applicationState = MockApplicationStateProvider(applicationState: .background) + let json = makeJsonOnlyMessage(id: "shared", payloadId: "payload") + let historicalHtml = makeHtmlMessage(id: "shared", + triggerType: .never, + customPayload: ["id": "payload"]) + let laterHtml = makeHtmlMessage(id: "later", triggerType: .immediate) + delegate.onNewMessageCallback = { message in + if message.messageId == laterHtml.messageId { + laterHtmlExpectation.fulfill() + } + } + let internalAPI = initialize(fetcher: fetcher, + delegate: delegate, + applicationState: applicationState) + + fetch([json], with: fetcher, internalAPI: internalAPI) + XCTAssertTrue(IterableAPI.markJsonOnlyMessageHandled(messageId: json.messageId)) + fetch([historicalHtml], with: fetcher, internalAPI: internalAPI) + applicationState.applicationState = .active + + fetch([json, laterHtml], with: fetcher, internalAPI: internalAPI) + + wait(for: [laterHtmlExpectation], timeout: testExpectationTimeout) + XCTAssertTrue(internalAPI.inAppManager.getMessages().contains { $0.messageId == laterHtml.messageId }) + } + + func testTombstonedJsonTypeRoundTripDoesNotBlockLaterHtml() { + let laterHtmlExpectation = expectation(description: "later HTML processed") + let dateProvider = MockDateProvider() + let fetcher = MockInAppFetcher() + let delegate = MockInAppDelegate(showInApp: .skip) + let applicationState = MockApplicationStateProvider(applicationState: .background) + let json = makeJsonOnlyMessage(id: "shared", payloadId: "payload") + let historicalHtml = makeHtmlMessage(id: "shared", + triggerType: .never, + customPayload: ["id": "payload"]) + let laterHtml = makeHtmlMessage(id: "later", triggerType: .immediate) + delegate.onNewMessageCallback = { message in + if message.messageId == laterHtml.messageId { + laterHtmlExpectation.fulfill() + } + } + let internalAPI = initialize(fetcher: fetcher, + delegate: delegate, + applicationState: applicationState, + dateProvider: dateProvider) + + fetch([json], with: fetcher, internalAPI: internalAPI) + dateProvider.currentDate = dateProvider.currentDate.addingTimeInterval(30 * 24 * 60 * 60 + 1) + XCTAssertTrue(IterableAPI.getUnhandledJsonOnlyMessages().isEmpty) + fetch([historicalHtml], with: fetcher, internalAPI: internalAPI) + applicationState.applicationState = .active + + fetch([json, laterHtml], with: fetcher, internalAPI: internalAPI) + + wait(for: [laterHtmlExpectation], timeout: testExpectationTimeout) + XCTAssertTrue(internalAPI.inAppManager.getMessages().contains { $0.messageId == laterHtml.messageId }) + } + + func testRetentionDiscardedUnacknowledgedMessageIsNotReadmitted() { + let noAvailability = expectation(description: "no availability after retention discard") + noAvailability.isInverted = true + let localStorage = MockLocalStorage() + let dateProvider = MockDateProvider() + let applicationState = MockApplicationStateProvider(applicationState: .background) + let notificationCenter = MockNotificationCenter() + let fetcher = MockInAppFetcher() + let delegate = MockInAppDelegate() + let first = makeJsonOnlyMessage(id: "message-1", payloadId: "first") + let second = makeJsonOnlyMessage(id: "message-1", payloadId: "second") + delegate.onJsonOnlyMessageAvailableCallback = { _ in noAvailability.fulfill() } + let internalAPI = initialize(localStorage: localStorage, + fetcher: fetcher, + delegate: delegate, + applicationState: applicationState, + notificationCenter: notificationCenter, + dateProvider: dateProvider) + + fetch([first], with: fetcher, internalAPI: internalAPI) + dateProvider.currentDate = dateProvider.currentDate.addingTimeInterval(30 * 24 * 60 * 60 + 1) + XCTAssertTrue(IterableAPI.getUnhandledJsonOnlyMessages().isEmpty) + fetch([second], with: fetcher, internalAPI: internalAPI) + XCTAssertTrue(IterableAPI.getUnhandledJsonOnlyMessages().isEmpty) + + applicationState.applicationState = .active + notificationCenter.post(name: UIApplication.didBecomeActiveNotification, object: nil, userInfo: nil) + wait(for: [noAvailability], timeout: testExpectationTimeoutForInverted) + XCTAssertTrue(IterableAPI.getUnhandledJsonOnlyMessages().isEmpty) + } + func testIdentitySwitchClearsUnhandledMessages() { let fetcher = MockInAppFetcher() let internalAPI = initialize(fetcher: fetcher) @@ -2365,9 +2901,10 @@ final class JsonOnlyMessageAvailabilityTests: XCTestCase { dateProvider: dateProvider, identityProvider: { UserIdentitySnapshot(auth: auth) }) - for index in 0...100 { - store.enqueue(makeJsonOnlyMessage(id: "message-\(index)")) - } + let identityContext = store.identityContext + let messages = (0...100).map { makeJsonOnlyMessage(id: "message-\($0)") } + XCTAssertTrue(store.enqueue(messages, identityContext: identityContext)) + XCTAssertEqual(localStorage.jsonOnlyMessageQueueDataWriteCount, 1) let retainedIds = store.getMessages().map(\.messageId) XCTAssertEqual(retainedIds.count, 100) @@ -2388,6 +2925,90 @@ final class JsonOnlyMessageAvailabilityTests: XCTestCase { XCTAssertTrue(expiringStore.getMessages().isEmpty) } + func testCapacityDiscardedUnacknowledgedMessageIsNotReadmitted() { + let localStorage = MockLocalStorage() + let dateProvider = MockDateProvider() + let auth = Auth(userId: nil, email: Self.email, authToken: nil, userIdUnknownUser: nil) + let store = JsonOnlyMessageStore(localStorage: localStorage, + dateProvider: dateProvider, + identityProvider: { UserIdentitySnapshot(auth: auth) }) + let identityContext = store.identityContext + let messages = (0...100).map { makeJsonOnlyMessage(id: "message-\($0)", payloadId: "first") } + XCTAssertTrue(store.enqueue(messages, identityContext: identityContext)) + XCTAssertFalse(store.getMessages().contains { $0.messageId == "message-0" }) + + let changedMessage = makeJsonOnlyMessage(id: "message-0", payloadId: "second") + let status = store.acknowledgementStatus(for: [changedMessage], identityContext: identityContext) + XCTAssertEqual(status.unchangedMessageIds, [changedMessage.messageId]) + XCTAssertTrue(status.changedMessageIds.isEmpty) + XCTAssertFalse(store.enqueue(changedMessage, identityContext: identityContext)) + XCTAssertFalse(store.getMessages().contains { $0.messageId == changedMessage.messageId }) + } + + func testPayloadFingerprintIsStableForNestedKeyOrderAndBooleanType() { + let localStorage = MockLocalStorage() + let dateProvider = MockDateProvider() + let auth = Auth(userId: nil, email: Self.email, authToken: nil, userIdUnknownUser: nil) + let store = JsonOnlyMessageStore(localStorage: localStorage, + dateProvider: dateProvider, + identityProvider: { UserIdentitySnapshot(auth: auth) }) + let identityContext = store.identityContext + let first = makeJsonOnlyMessage(id: "message-1", customPayload: [ + "nested": ["b": NSNumber(value: 2), "a": NSNumber(value: true)] + ]) + let reordered = makeJsonOnlyMessage(id: "message-1", customPayload: [ + "nested": ["a": NSNumber(value: true), "b": NSNumber(value: 2)] + ]) + let booleanChangedToNumber = makeJsonOnlyMessage(id: "message-1", customPayload: [ + "nested": ["a": NSNumber(value: 1), "b": NSNumber(value: 2)] + ]) + + XCTAssertTrue(store.enqueue(first, identityContext: identityContext)) + XCTAssertTrue(store.remove(messageId: first.messageId)) + let reorderedStatus = store.acknowledgementStatus(for: [reordered], + identityContext: identityContext) + let changedStatus = store.acknowledgementStatus(for: [booleanChangedToNumber], + identityContext: identityContext) + + XCTAssertEqual(reorderedStatus.unchangedMessageIds, [reordered.messageId]) + XCTAssertTrue(reorderedStatus.changedMessageIds.isEmpty) + XCTAssertTrue(changedStatus.unchangedMessageIds.isEmpty) + XCTAssertEqual(changedStatus.changedMessageIds, [booleanChangedToNumber.messageId]) + } + + func testConcurrentMessageGettersWaitForResetStateCommit() { + let resetPersistStarted = DispatchSemaphore(value: 0) + let releaseResetPersist = DispatchSemaphore(value: 0) + let gettersCompleted = DispatchSemaphore(value: 0) + let resetCompleted = expectation(description: "reset completed") + let fetcher = MockInAppFetcher() + let persister = MockInAppPersister() + let applicationState = MockApplicationStateProvider(applicationState: .background) + let message = makeHtmlMessage(id: "inbox", triggerType: .never, saveToInbox: true) + let internalAPI = initialize(fetcher: fetcher, + persister: persister, + applicationState: applicationState) + fetch([message], with: fetcher, internalAPI: internalAPI) + persister.onPersist = { + persister.onPersist = nil + resetPersistStarted.signal() + releaseResetPersist.wait() + } + + internalAPI.inAppManager.reset().onSuccess { _ in resetCompleted.fulfill() } + XCTAssertEqual(resetPersistStarted.wait(timeout: .now() + testExpectationTimeout), .success) + DispatchQueue.global().async { + XCTAssertTrue(internalAPI.inAppManager.getMessages().isEmpty) + XCTAssertTrue(internalAPI.inAppManager.getInboxMessages().isEmpty) + XCTAssertNil(internalAPI.inAppManager.getMessage(withId: message.messageId)) + gettersCompleted.signal() + } + XCTAssertEqual(gettersCompleted.wait(timeout: .now() + 0.1), .timedOut) + releaseResetPersist.signal() + XCTAssertEqual(gettersCompleted.wait(timeout: .now() + testExpectationTimeout), .success) + wait(for: [resetCompleted], timeout: testExpectationTimeout) + } + func testStoreDropsAlreadyExpiredMessage() { let localStorage = MockLocalStorage() let dateProvider = MockDateProvider() @@ -2437,26 +3058,90 @@ final class JsonOnlyMessageAvailabilityTests: XCTestCase { notificationCenter.removeCallbacks(withIds: notificationReference.callbackId) } + private enum IdentitySwitchStage: Equatable { + case onNew + case delegate + case notification + } + + private func assertIdentitySwitchDuringDelivery(at stage: IdentitySwitchStage) { + let identitySwitchExpectation = expectation(description: "identity switched") + let noConsumeExpectation = expectation(description: "no consume after identity switch") + noConsumeExpectation.isInverted = true + let fetcher = MockInAppFetcher() + let delegate = MockInAppDelegate() + let notificationCenter = MockNotificationCenter() + let networkSession = MockNetworkSession() + let message = makeJsonOnlyMessage(id: "message-a") + var internalAPI: InternalIterableAPI! + var onNewCount = 0 + var delegateCount = 0 + var notificationCount = 0 + + let switchIdentity = { + fetcher.mockMessagesAvailableFromServer(internalApi: nil, messages: []) + internalAPI.setUserId("user-b") + identitySwitchExpectation.fulfill() + } + delegate.onNewMessageCallback = { _ in + onNewCount += 1 + if stage == .onNew { switchIdentity() } + } + delegate.onJsonOnlyMessageAvailableCallback = { _ in + delegateCount += 1 + if stage == .delegate { switchIdentity() } + } + let notificationReference = notificationCenter.addCallback(forNotification: .iterableJsonOnlyInAppMessageAvailable) { _ in + notificationCount += 1 + if stage == .notification { switchIdentity() } + } + networkSession.requestCallback = { request in + if request.url?.path.contains(Const.Path.inAppConsume) == true { + noConsumeExpectation.fulfill() + } + } + internalAPI = initialize(fetcher: fetcher, + delegate: delegate, + networkSession: networkSession, + notificationCenter: notificationCenter) + + fetch([message], with: fetcher, internalAPI: internalAPI) + + wait(for: [identitySwitchExpectation], timeout: testExpectationTimeout) + wait(for: [noConsumeExpectation], timeout: testExpectationTimeoutForInverted) + XCTAssertEqual(onNewCount, 1) + XCTAssertEqual(delegateCount, stage == .onNew ? 0 : 1) + XCTAssertEqual(notificationCount, stage == .notification ? 1 : 0) + XCTAssertFalse(message.didProcessTrigger) + XCTAssertFalse(message.consumed) + notificationCenter.removeCallbacks(withIds: notificationReference.callbackId) + } + private func initialize(localStorage: MockLocalStorage = MockLocalStorage(), fetcher: InAppFetcherProtocol, persister: InAppPersistenceProtocol = MockInAppPersister(), + displayer: InAppDisplayerProtocol = MockInAppDisplayer(), delegate: IterableInAppDelegate = MockInAppDelegate(), + displayDelegate: IterableInAppDisplayDelegate? = nil, networkSession: MockNetworkSession = MockNetworkSession(), applicationState: MockApplicationStateProvider = MockApplicationStateProvider(applicationState: .active), notificationCenter: MockNotificationCenter = MockNotificationCenter(), - dateProvider: DateProviderProtocol = SystemDateProvider()) -> InternalIterableAPI { + dateProvider: DateProviderProtocol = SystemDateProvider(), + displayInterval: Double = 0) -> InternalIterableAPI { if localStorage.email == nil && localStorage.userId == nil { localStorage.email = Self.email } let config = IterableConfig() config.autoPushRegistration = false - config.inAppDisplayInterval = 0 + config.inAppDisplayInterval = displayInterval config.inAppDelegate = delegate + config.inAppDisplayDelegate = displayDelegate IterableAPI.initializeForTesting(config: config, dateProvider: dateProvider, networkSession: networkSession, localStorage: localStorage, inAppFetcher: fetcher, + inAppDisplayer: displayer, inAppPersister: persister, applicationStateProvider: applicationState, notificationCenter: notificationCenter) @@ -2477,25 +3162,28 @@ final class JsonOnlyMessageAvailabilityTests: XCTestCase { triggerType: IterableInAppTriggerType = .immediate, priorityLevel: Double = 0, expiresAt: Date? = nil, - payloadId: String? = nil) -> IterableInAppMessage { + payloadId: String? = nil, + customPayload: [AnyHashable: Any]? = nil) -> IterableInAppMessage { IterableInAppMessage(messageId: id, campaignId: 1, trigger: .create(withTriggerType: triggerType), expiresAt: expiresAt, content: IterableHtmlInAppContent(edgeInsets: .zero, html: ""), - customPayload: ["id": payloadId ?? id], + customPayload: customPayload ?? ["id": payloadId ?? id], priorityLevel: priorityLevel, jsonOnly: true) } private func makeHtmlMessage(id: String, triggerType: IterableInAppTriggerType, - saveToInbox: Bool = false) -> IterableInAppMessage { + saveToInbox: Bool = false, + customPayload: [AnyHashable: Any]? = nil) -> IterableInAppMessage { IterableInAppMessage(messageId: id, campaignId: 1, trigger: .create(withTriggerType: triggerType), content: IterableHtmlInAppContent(edgeInsets: .zero, html: ""), - saveToInbox: saveToInbox) + saveToInbox: saveToInbox, + customPayload: customPayload) } private static let email = "json-only@example.com" @@ -2542,5 +3230,3 @@ extension IterableInAppMessage { pairSeparator: " = ", separator: "\n") } } - - diff --git a/tests/unit-tests/IterableAPITests.swift b/tests/unit-tests/IterableAPITests.swift index 9c267ffc5..9f4070570 100644 --- a/tests/unit-tests/IterableAPITests.swift +++ b/tests/unit-tests/IterableAPITests.swift @@ -1040,13 +1040,15 @@ class IterableAPITests: XCTestCase { initialInboxExpectation.fulfill() } let networkSession = MockNetworkSession(statusCode: 200) + let localStorage = MockLocalStorage() + localStorage.email = IterableAPITests.email let config = IterableConfig() let internalAPI = InternalIterableAPI.initializeForTesting(apiKey: IterableAPITests.apiKey, config: config, networkSession: networkSession, + localStorage: localStorage, inAppFetcher: mockInAppFetcher, notificationCenter: mockNotificationCenter) - internalAPI.email = "user@example.com" let oldImplementation = IterableAPI.implementation IterableAPI.implementation = internalAPI defer { IterableAPI.implementation = oldImplementation } @@ -1108,11 +1110,13 @@ class IterableAPITests: XCTestCase { initialInboxExpectation.fulfill() } let networkSession = MockNetworkSession(statusCode: 200) + let localStorage = MockLocalStorage() + localStorage.email = IterableAPITests.email let internalAPI = InternalIterableAPI.initializeForTesting(apiKey: IterableAPITests.apiKey, networkSession: networkSession, + localStorage: localStorage, inAppFetcher: mockInAppFetcher, notificationCenter: mockNotificationCenter) - internalAPI.email = IterableAPITests.email let oldImplementation = IterableAPI.implementation IterableAPI.implementation = internalAPI defer { IterableAPI.implementation = oldImplementation } @@ -1163,11 +1167,13 @@ class IterableAPITests: XCTestCase { let mockInAppFetcher = MockInAppFetcher(messages: [message]) let mockNotificationCenter = MockNotificationCenter() let networkSession = MockNetworkSession(statusCode: 200) + let localStorage = MockLocalStorage() + localStorage.email = IterableAPITests.email let internalAPI = InternalIterableAPI.initializeForTesting(apiKey: IterableAPITests.apiKey, networkSession: networkSession, + localStorage: localStorage, inAppFetcher: mockInAppFetcher, notificationCenter: mockNotificationCenter) - internalAPI.email = IterableAPITests.email let oldImplementation = IterableAPI.implementation IterableAPI.implementation = internalAPI defer { IterableAPI.implementation = oldImplementation } diff --git a/tests/unit-tests/IterableDataRegionObjCTests.m b/tests/unit-tests/IterableDataRegionObjCTests.m index 5fd184a80..28eca04ae 100644 --- a/tests/unit-tests/IterableDataRegionObjCTests.m +++ b/tests/unit-tests/IterableDataRegionObjCTests.m @@ -19,6 +19,20 @@ - (enum InAppShowResponse)onNewMessage:(IterableInAppMessage * _Nonnull)message @end +@interface JsonOnlyInAppDelegate : NSObject +@end + +@implementation JsonOnlyInAppDelegate + +- (enum InAppShowResponse)onNewMessage:(IterableInAppMessage * _Nonnull)message { + return InAppShowResponseShow; +} + +- (void)onJsonOnlyMessageAvailable:(IterableInAppMessage * _Nonnull)message { +} + +@end + @interface IterableDataRegionObjCTests : XCTestCase @end @@ -44,4 +58,15 @@ - (void)testLegacyInAppDelegateConformanceRemainsValid { XCTAssertNotNil(config.inAppDelegate); } +- (void)testJsonOnlyApiSurfaceIsAccessibleFromObjectiveC { + IterableConfig *config = [[IterableConfig alloc] init]; + config.inAppDelegate = [[JsonOnlyInAppDelegate alloc] init]; + XCTAssertNotNil(config.inAppDelegate); + + XCTAssertEqualObjects(IterableAPI.jsonOnlyInAppMessageAvailableNotification, + @"itbl_json_only_in_app_message_available"); + (void)[IterableAPI getUnhandledJsonOnlyMessages]; + XCTAssertFalse([IterableAPI markJsonOnlyMessageHandled:@"missing"]); +} + @end From f7540e303af1ce86f92a8a8f0cd330bb7f43778d Mon Sep 17 00:00:00 2001 From: Sumeru Chatterjee Date: Thu, 23 Jul 2026 22:48:55 +0100 Subject: [PATCH 09/15] SDK-496 Expand CHANGELOG for acknowledgement and identity scoping Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bfb14aa0d..54410a08a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,10 +4,11 @@ All notable changes to this project will be documented in this file. This project adheres to [Semantic Versioning](http://semver.org/). ## [Unreleased] ### Added -- Added at least once delivery for JSON-only in-app messages through `IterableInAppDelegate.onJsonOnlyMessageAvailable(message:)` and `iterableJsonOnlyInAppMessageAvailable`. Unhandled messages remain available through `IterableAPI.getUnhandledJsonOnlyMessages()` until acknowledged with `markJsonOnlyMessageHandled(messageId:)`. +- Added at least once delivery for JSON-only in-app messages through `IterableInAppDelegate.onJsonOnlyMessageAvailable(message:)` and `iterableJsonOnlyInAppMessageAvailable` (Objective-C: `IterableAPI.jsonOnlyInAppMessageAvailableNotification`). Messages are saved to local storage before signaling and replay on foreground until acknowledged. Unhandled messages remain available through `IterableAPI.getUnhandledJsonOnlyMessages()` until acknowledged with `markJsonOnlyMessageHandled(messageId:)`; acknowledgement records a payload fingerprint so the same message ID with a changed payload is delivered again. These callbacks run inside an SDK identity critical section; see the API documentation for the cross-thread wait restriction. ### Fixed - Public `inAppConsume` APIs now remove messages locally and post `iterableInboxChanged` after the local state is updated. The notification fires only when an inbox message changes, so removing popups or JSON-only messages no longer announces an inbox change. +- In-app fetch, delivery, and merge are now scoped to the user identity that started them, so a login or logout during processing can no longer deliver or persist the previous user's messages. ## [6.7.4] ### Added From e4cdd0c75a3452d71dae08e04860d34f90434bb9 Mon Sep 17 00:00:00 2001 From: Sumeru Chatterjee Date: Fri, 24 Jul 2026 13:48:36 +0100 Subject: [PATCH 10/15] SDK-496 Trim redundant guards and pin the readmission tracking test Removes scaffolding left by iterative hardening: the dead skipAndConsume processor case, the processAndShowMessage entry guard subsumed by the later per-boundary checks, and the store's test-only default coordinator and no-context wrappers. Restores unrelated EOF whitespace. The readmission test intermittently observed the first message's delivery track through the second message's callback because the mock network session invokes callbacks asynchronously; the test now waits for the initial track before acknowledging, so the two phases are serialized without disabling over-fulfillment checks. No production tracking behavior changed. Also bounds the CHANGELOG acknowledgement claim to the retained metadata. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 2 +- .../in-app/InAppManager+Functions.swift | 4 -- swift-sdk/Internal/in-app/InAppManager.swift | 7 +--- .../Internal/in-app/InAppPersistence.swift | 11 +----- tests/unit-tests/InAppTests.swift | 37 +++++++++++++------ 5 files changed, 29 insertions(+), 32 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 54410a08a..45a13e9f7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ All notable changes to this project will be documented in this file. This project adheres to [Semantic Versioning](http://semver.org/). ## [Unreleased] ### Added -- Added at least once delivery for JSON-only in-app messages through `IterableInAppDelegate.onJsonOnlyMessageAvailable(message:)` and `iterableJsonOnlyInAppMessageAvailable` (Objective-C: `IterableAPI.jsonOnlyInAppMessageAvailableNotification`). Messages are saved to local storage before signaling and replay on foreground until acknowledged. Unhandled messages remain available through `IterableAPI.getUnhandledJsonOnlyMessages()` until acknowledged with `markJsonOnlyMessageHandled(messageId:)`; acknowledgement records a payload fingerprint so the same message ID with a changed payload is delivered again. These callbacks run inside an SDK identity critical section; see the API documentation for the cross-thread wait restriction. +- Added at least once delivery for JSON-only in-app messages through `IterableInAppDelegate.onJsonOnlyMessageAvailable(message:)` and `iterableJsonOnlyInAppMessageAvailable` (Objective-C: `IterableAPI.jsonOnlyInAppMessageAvailableNotification`). Messages are saved to local storage before signaling and replay on foreground until acknowledged. Unhandled messages remain available through `IterableAPI.getUnhandledJsonOnlyMessages()` until acknowledged with `markJsonOnlyMessageHandled(messageId:)`; acknowledgement records a payload fingerprint (bounded to the latest 100 per user) so the same message ID with a changed payload is delivered again. These callbacks run inside an SDK identity critical section; see the API documentation for the cross-thread wait restriction. ### Fixed - Public `inAppConsume` APIs now remove messages locally and post `iterableInboxChanged` after the local state is updated. The notification fires only when an inbox message changes, so removing popups or JSON-only messages no longer announces an inbox change. diff --git a/swift-sdk/Internal/in-app/InAppManager+Functions.swift b/swift-sdk/Internal/in-app/InAppManager+Functions.swift index 9e2a27c00..619e422d3 100644 --- a/swift-sdk/Internal/in-app/InAppManager+Functions.swift +++ b/swift-sdk/Internal/in-app/InAppManager+Functions.swift @@ -35,9 +35,6 @@ struct MessagesProcessor { case let .skip(message): updateMessage(message, didProcessTrigger: true) return processMessages() - case let .skipAndConsume(message): - updateMessage(message, didProcessTrigger: true, consumed: true) - return .noShow(message: message, messagesMap: messagesMap) case let .jsonOnly(message): return .jsonOnly(message: message, messagesMap: messagesMap) case .none, .wait: @@ -48,7 +45,6 @@ struct MessagesProcessor { private enum ProcessNextMessageResult { case show(IterableInAppMessage) case skip(IterableInAppMessage) - case skipAndConsume(IterableInAppMessage) case jsonOnly(IterableInAppMessage) case none case wait diff --git a/swift-sdk/Internal/in-app/InAppManager.swift b/swift-sdk/Internal/in-app/InAppManager.swift index 451142c0d..40ec95039 100644 --- a/swift-sdk/Internal/in-app/InAppManager.swift +++ b/swift-sdk/Internal/in-app/InAppManager.swift @@ -419,11 +419,6 @@ class InAppManager: NSObject, IterableInternalInAppManagerProtocol { private func processAndShowMessage(messagesMap: OrderedDictionary, identityContext: UserIdentityContext, processingRevision: UInt64) -> Pending { - guard canProcessMessages(identityContext: identityContext, - processingRevision: processingRevision) else { - return Fulfill(value: true) - } - var processor = MessagesProcessor(inAppDelegate: inAppDelegate, inAppDisplayChecker: self, messagesMap: messagesMap, @@ -443,7 +438,7 @@ class InAppManager: NSObject, IterableInternalInAppManagerProtocol { }), didCommit else { return Fulfill(value: true) } - + if case let .jsonOnly(message, _) = messagesProcessorResult { return deliverJsonOnlyMessage(message, consumeOnReplay: true, identityContext: identityContext).flatMap { [weak self] processed in guard let self = self, diff --git a/swift-sdk/Internal/in-app/InAppPersistence.swift b/swift-sdk/Internal/in-app/InAppPersistence.swift index b928e261e..9c82e35ae 100644 --- a/swift-sdk/Internal/in-app/InAppPersistence.swift +++ b/swift-sdk/Internal/in-app/InAppPersistence.swift @@ -397,7 +397,7 @@ final class JsonOnlyMessageStore { init(localStorage: LocalStorageProtocol, dateProvider: DateProviderProtocol, identityProvider: @escaping () -> UserIdentitySnapshot?, - identityCoordinator: IdentityCoordinator = IdentityCoordinator()) { + identityCoordinator: IdentityCoordinator) { self.localStorage = localStorage self.dateProvider = dateProvider self.identityProvider = identityProvider @@ -408,11 +408,6 @@ final class JsonOnlyMessageStore { identityCoordinator.capture(identityProvider: identityProvider) } - @discardableResult - func enqueue(_ message: IterableInAppMessage) -> Bool { - enqueue(message, identityContext: identityContext) - } - @discardableResult func enqueue(_ message: IterableInAppMessage, identityContext: UserIdentityContext) -> Bool { guard let identity = identityContext.identity else { return false } @@ -470,10 +465,6 @@ final class JsonOnlyMessageStore { return result } - func prepareDelivery(for message: IterableInAppMessage) -> Delivery? { - prepareDelivery(for: message, identityContext: identityContext) - } - func prepareDelivery(for message: IterableInAppMessage, identityContext: UserIdentityContext) -> Delivery? { guard let identity = identityContext.identity else { return nil } var result: Delivery? diff --git a/tests/unit-tests/InAppTests.swift b/tests/unit-tests/InAppTests.swift index 5df5d9fef..4e79d7fd0 100644 --- a/tests/unit-tests/InAppTests.swift +++ b/tests/unit-tests/InAppTests.swift @@ -2626,7 +2626,8 @@ final class JsonOnlyMessageAvailabilityTests: XCTestCase { let auth = Auth(userId: nil, email: Self.email, authToken: nil, userIdUnknownUser: nil) let store = JsonOnlyMessageStore(localStorage: localStorage, dateProvider: dateProvider, - identityProvider: { UserIdentitySnapshot(auth: auth) }) + identityProvider: { UserIdentitySnapshot(auth: auth) }, + identityCoordinator: IdentityCoordinator()) let identityContext = store.identityContext let first = makeJsonOnlyMessage(id: "message-1", payloadId: "first") let second = makeJsonOnlyMessage(id: "message-1", payloadId: "second") @@ -2635,16 +2636,17 @@ final class JsonOnlyMessageAvailabilityTests: XCTestCase { XCTAssertEqual(store.getMessages().first?.customPayload?["id"] as? String, "first") XCTAssertTrue(store.remove(messageId: first.messageId)) - XCTAssertTrue(store.enqueue(second)) + XCTAssertTrue(store.enqueue(second, identityContext: identityContext)) XCTAssertEqual(store.getMessages().first?.customPayload?["id"] as? String, "second") } func testAcknowledgedMessageIdCanBeReadmittedWithNewPayload() { let availabilityExpectation = expectation(description: "readmitted availability") + let initialDeliveryTrackExpectation = expectation(description: "initial delivery tracked") let deliveryTrackExpectation = expectation(description: "readmitted delivery tracked") let fetcher = MockInAppFetcher() let delegate = MockInAppDelegate() - let networkSession = MockNetworkSession() + let networkSession = MockNetworkSession(delay: 0.05) let applicationState = MockApplicationStateProvider(applicationState: .background) let notificationCenter = MockNotificationCenter() let first = makeJsonOnlyMessage(id: "message-1", payloadId: "first") @@ -2660,7 +2662,12 @@ final class JsonOnlyMessageAvailabilityTests: XCTestCase { applicationState: applicationState, notificationCenter: notificationCenter) + networkSession.requestCallback = { request in + guard request.url?.path.contains(Const.Path.trackInAppDelivery) == true else { return } + initialDeliveryTrackExpectation.fulfill() + } fetch([first], with: fetcher, internalAPI: internalAPI) + wait(for: [initialDeliveryTrackExpectation], timeout: testExpectationTimeout) XCTAssertTrue(IterableAPI.markJsonOnlyMessageHandled(messageId: first.messageId)) networkSession.requestCallback = { request in guard request.url?.path.contains(Const.Path.trackInAppDelivery) == true else { return } @@ -2899,7 +2906,8 @@ final class JsonOnlyMessageAvailabilityTests: XCTestCase { let auth = Auth(userId: nil, email: Self.email, authToken: nil, userIdUnknownUser: nil) let store = JsonOnlyMessageStore(localStorage: localStorage, dateProvider: dateProvider, - identityProvider: { UserIdentitySnapshot(auth: auth) }) + identityProvider: { UserIdentitySnapshot(auth: auth) }, + identityCoordinator: IdentityCoordinator()) let identityContext = store.identityContext let messages = (0...100).map { makeJsonOnlyMessage(id: "message-\($0)") } @@ -2918,9 +2926,12 @@ final class JsonOnlyMessageAvailabilityTests: XCTestCase { let expiringDateProvider = MockDateProvider() let expiringStore = JsonOnlyMessageStore(localStorage: expiringLocalStorage, dateProvider: expiringDateProvider, - identityProvider: { UserIdentitySnapshot(auth: auth) }) + identityProvider: { UserIdentitySnapshot(auth: auth) }, + identityCoordinator: IdentityCoordinator()) + let expiringIdentityContext = expiringStore.identityContext expiringStore.enqueue(makeJsonOnlyMessage(id: "expiring", - expiresAt: expiringDateProvider.currentDate.addingTimeInterval(1))) + expiresAt: expiringDateProvider.currentDate.addingTimeInterval(1)), + identityContext: expiringIdentityContext) expiringDateProvider.currentDate = expiringDateProvider.currentDate.addingTimeInterval(2) XCTAssertTrue(expiringStore.getMessages().isEmpty) } @@ -2931,7 +2942,8 @@ final class JsonOnlyMessageAvailabilityTests: XCTestCase { let auth = Auth(userId: nil, email: Self.email, authToken: nil, userIdUnknownUser: nil) let store = JsonOnlyMessageStore(localStorage: localStorage, dateProvider: dateProvider, - identityProvider: { UserIdentitySnapshot(auth: auth) }) + identityProvider: { UserIdentitySnapshot(auth: auth) }, + identityCoordinator: IdentityCoordinator()) let identityContext = store.identityContext let messages = (0...100).map { makeJsonOnlyMessage(id: "message-\($0)", payloadId: "first") } XCTAssertTrue(store.enqueue(messages, identityContext: identityContext)) @@ -2951,7 +2963,8 @@ final class JsonOnlyMessageAvailabilityTests: XCTestCase { let auth = Auth(userId: nil, email: Self.email, authToken: nil, userIdUnknownUser: nil) let store = JsonOnlyMessageStore(localStorage: localStorage, dateProvider: dateProvider, - identityProvider: { UserIdentitySnapshot(auth: auth) }) + identityProvider: { UserIdentitySnapshot(auth: auth) }, + identityCoordinator: IdentityCoordinator()) let identityContext = store.identityContext let first = makeJsonOnlyMessage(id: "message-1", customPayload: [ "nested": ["b": NSNumber(value: 2), "a": NSNumber(value: true)] @@ -3015,12 +3028,14 @@ final class JsonOnlyMessageAvailabilityTests: XCTestCase { let auth = Auth(userId: nil, email: Self.email, authToken: nil, userIdUnknownUser: nil) let store = JsonOnlyMessageStore(localStorage: localStorage, dateProvider: dateProvider, - identityProvider: { UserIdentitySnapshot(auth: auth) }) + identityProvider: { UserIdentitySnapshot(auth: auth) }, + identityCoordinator: IdentityCoordinator()) + let identityContext = store.identityContext let message = makeJsonOnlyMessage(id: "expired", expiresAt: dateProvider.currentDate) - XCTAssertFalse(store.enqueue(message)) + XCTAssertFalse(store.enqueue(message, identityContext: identityContext)) XCTAssertTrue(store.getMessages().isEmpty) - XCTAssertNil(store.prepareDelivery(for: message)) + XCTAssertNil(store.prepareDelivery(for: message, identityContext: identityContext)) XCTAssertTrue(store.getMessages().isEmpty) } From 6200375beca1726d9a59e3b65f5a89365fb2a071 Mon Sep 17 00:00:00 2001 From: Sumeru Chatterjee Date: Fri, 24 Jul 2026 15:01:08 +0100 Subject: [PATCH 11/15] SDK-496 Invoke delivery callbacks outside the identity section Adopts the review request on the delivery design: onNew, the availability delegate, and the notification post are now invoked with no SDK lock held. Each surface is prechecked against the captured identity and generation, invoked unlocked, and revalidated on return; a stale result stops every later surface, mutation, delivery tracking, and consume. This removes the deadlock where a callback synchronously waiting on another thread that touches an identity-reading SDK API blocked forever, and with it the public cross-thread wait restriction. The replacement contract, documented on the delegate methods and the notification, is that a callback already selected for the previous user may complete after a concurrent switch, with no subsequent SDK step performed for it. Concurrency tests now assert the switch completes while a callback is paused and the suffix stays suppressed at every boundary. Co-Authored-By: Claude Fable 5 --- swift-sdk/Internal/Auth.swift | 11 +- swift-sdk/Internal/in-app/InAppManager.swift | 35 +++-- swift-sdk/SDK/IterableConfig.swift | 4 +- swift-sdk/SDK/IterableMessaging.swift | 3 +- tests/unit-tests/InAppTests.swift | 144 +++++++++++-------- 5 files changed, 117 insertions(+), 80 deletions(-) diff --git a/swift-sdk/Internal/Auth.swift b/swift-sdk/Internal/Auth.swift index dc276708c..c43ef4d15 100644 --- a/swift-sdk/Internal/Auth.swift +++ b/swift-sdk/Internal/Auth.swift @@ -20,6 +20,15 @@ final class IdentityCoordinator { } } + func isCurrent(_ context: UserIdentityContext, + identityProvider: () -> UserIdentitySnapshot?) -> Bool { + withCriticalSection { + context.generation == generation && + context.identity == identityProvider() && + !hasPendingPublication + } + } + @discardableResult func performIfCurrent(_ context: UserIdentityContext, identityProvider: () -> UserIdentitySnapshot?, @@ -71,7 +80,7 @@ final class IdentityCoordinator { } // Lock order is manager queue, identity section, then JSON store queue. Identity - // holders may call customers but must never synchronously wait on manager queues. + // holders must not call customer code or synchronously wait on manager queues. private let lock = NSRecursiveLock() private let pendingPublicationLock = NSLock() private var generation: UInt64 = 0 diff --git a/swift-sdk/Internal/in-app/InAppManager.swift b/swift-sdk/Internal/in-app/InAppManager.swift index 40ec95039..d1c2e34d4 100644 --- a/swift-sdk/Internal/in-app/InAppManager.swift +++ b/swift-sdk/Internal/in-app/InAppManager.swift @@ -736,27 +736,34 @@ class InAppManager: NSObject, IterableInternalInAppManagerProtocol { } if delivery.isInitial { - guard self.identityCoordinator.performIfCurrent(identityContext, - identityProvider: self.identityProvider, { - _ = self.inAppDelegate.onNew(message: delivery.message) - }) else { + guard self.identityCoordinator.isCurrent(identityContext, + identityProvider: self.identityProvider) else { + result.resolve(with: false) + return + } + _ = self.inAppDelegate.onNew(message: delivery.message) + guard self.identityCoordinator.isCurrent(identityContext, + identityProvider: self.identityProvider) else { result.resolve(with: false) return } } - guard self.identityCoordinator.performIfCurrent(identityContext, - identityProvider: self.identityProvider, { - self.inAppDelegate.onJsonOnlyMessageAvailable?(message: delivery.message) - }) else { + guard self.identityCoordinator.isCurrent(identityContext, + identityProvider: self.identityProvider) else { result.resolve(with: false) return } - guard self.identityCoordinator.performIfCurrent(identityContext, - identityProvider: self.identityProvider, { - self.notificationCenter.post(name: .iterableJsonOnlyInAppMessageAvailable, - object: delivery.message, - userInfo: nil) - }) else { + self.inAppDelegate.onJsonOnlyMessageAvailable?(message: delivery.message) + guard self.identityCoordinator.isCurrent(identityContext, + identityProvider: self.identityProvider) else { + result.resolve(with: false) + return + } + self.notificationCenter.post(name: .iterableJsonOnlyInAppMessageAvailable, + object: delivery.message, + userInfo: nil) + guard self.identityCoordinator.isCurrent(identityContext, + identityProvider: self.identityProvider) else { result.resolve(with: false) return } diff --git a/swift-sdk/SDK/IterableConfig.swift b/swift-sdk/SDK/IterableConfig.swift index 2dbe01c63..6194b64eb 100644 --- a/swift-sdk/SDK/IterableConfig.swift +++ b/swift-sdk/SDK/IterableConfig.swift @@ -68,7 +68,7 @@ public struct IterableAPIMobileFrameworkInfo: Codable { @objc public protocol IterableInAppDelegate: AnyObject { /// This method is called when new in-app message is available. /// The default behavior is to `show` if you don't override this method. - /// For JSON-only messages, this method runs inside an SDK identity critical section and must not synchronously wait on another thread that makes any Iterable SDK call that reads or changes identity or message state, including request-sending APIs. + /// For JSON-only messages, this callback is invoked without SDK locks held. A callback selected for a previous user may complete after a concurrent identity switch; the SDK revalidates afterward and stops later delivery steps, state changes, and consumption. /// /// - Parameters: /// - message: `IterableInAppMessage` object containing information regarding in-app to display @@ -79,7 +79,7 @@ public struct IterableAPIMobileFrameworkInfo: Codable { /// Called on the main thread when a JSON-only message is available locally. /// This may be called more than once until the message is marked as handled. - /// The callback runs inside an SDK identity critical section and may call SDK identity APIs on the same thread. It must not synchronously wait on another thread that makes any Iterable SDK call that reads or changes identity or message state, including request-sending APIs, because doing so can deadlock. + /// This callback is invoked without SDK locks held. A callback selected for a previous user may complete after a concurrent identity switch; the SDK revalidates afterward and stops later delivery steps, state changes, and consumption. @objc(onJsonOnlyMessageAvailable:) optional func onJsonOnlyMessageAvailable(message: IterableInAppMessage) } diff --git a/swift-sdk/SDK/IterableMessaging.swift b/swift-sdk/SDK/IterableMessaging.swift index beac15cb2..1e20b9c39 100644 --- a/swift-sdk/SDK/IterableMessaging.swift +++ b/swift-sdk/SDK/IterableMessaging.swift @@ -19,12 +19,13 @@ public extension Notification.Name { static let iterableInboxChanged = Notification.Name(rawValue: "itbl_inbox_changed") /// This is fired when a JSON-only in-app message is available locally. - /// Notification observers run inside an SDK identity critical section and may call SDK identity APIs on the same thread. They must not synchronously wait on another thread that makes any Iterable SDK call that reads or changes identity or message state, including request-sending APIs, because doing so can deadlock. + /// Observers run on the main thread without SDK locks held. An observer selected for a previous user may complete after a concurrent identity switch; the SDK revalidates afterward and stops later delivery steps, state changes, and consumption. static let iterableJsonOnlyInAppMessageAvailable = Notification.Name(rawValue: "itbl_json_only_in_app_message_available") } public extension IterableAPI { /// Objective-C name for the JSON-only availability notification. + /// Observers run on the main thread without SDK locks held. An observer selected for a previous user may complete after a concurrent identity switch; the SDK revalidates afterward and stops later delivery steps, state changes, and consumption. @objc static let jsonOnlyInAppMessageAvailableNotification = Notification.Name.iterableJsonOnlyInAppMessageAvailable } diff --git a/tests/unit-tests/InAppTests.swift b/tests/unit-tests/InAppTests.swift index 4e79d7fd0..ef5f7bdd4 100644 --- a/tests/unit-tests/InAppTests.swift +++ b/tests/unit-tests/InAppTests.swift @@ -2311,70 +2311,15 @@ final class JsonOnlyMessageAvailabilityTests: XCTestCase { } func testConcurrentIdentitySwitchDuringOnNewStopsLaterDeliverySteps() { - let onNewStarted = DispatchSemaphore(value: 0) - let releaseOnNew = DispatchSemaphore(value: 0) - let switchStarted = DispatchSemaphore(value: 0) - let switchCompleted = DispatchSemaphore(value: 0) - let coordinationCompleted = expectation(description: "identity switch coordinated") - let fetchCompleted = expectation(description: "fetch completed") - let noAvailability = expectation(description: "no availability") - noAvailability.isInverted = true - let noNotification = expectation(description: "no notification") - noNotification.isInverted = true - let noConsume = expectation(description: "no consume") - noConsume.isInverted = true - let fetcher = MockInAppFetcher() - let notificationCenter = MockNotificationCenter() - let networkSession = MockNetworkSession() - let delegate = MockInAppDelegate() - let message = makeJsonOnlyMessage(id: "message-a") + assertConcurrentIdentitySwitchDuringDelivery(at: .onNew) + } - delegate.onNewMessageCallback = { _ in - onNewStarted.signal() - releaseOnNew.wait() - } - delegate.onJsonOnlyMessageAvailableCallback = { _ in noAvailability.fulfill() } - let notificationReference = notificationCenter.addCallback(forNotification: .iterableJsonOnlyInAppMessageAvailable) { _ in - noNotification.fulfill() - } - networkSession.requestCallback = { request in - if request.url?.path.contains(Const.Path.inAppConsume) == true { - noConsume.fulfill() - } - } - let internalAPI = initialize(fetcher: fetcher, - delegate: delegate, - networkSession: networkSession, - notificationCenter: notificationCenter) - DispatchQueue.global().async { - guard onNewStarted.wait(timeout: .now() + testExpectationTimeout) == .success else { - XCTFail("onNew did not start") - releaseOnNew.signal() - coordinationCompleted.fulfill() - return - } - fetcher.mockMessagesAvailableFromServer(internalApi: nil, messages: []) - DispatchQueue.global().async { - switchStarted.signal() - internalAPI.setUserId("user-b") - switchCompleted.signal() - } - XCTAssertEqual(switchStarted.wait(timeout: .now() + testExpectationTimeout), .success) - XCTAssertEqual(switchCompleted.wait(timeout: .now() + 0.1), .timedOut) - releaseOnNew.signal() - XCTAssertEqual(switchCompleted.wait(timeout: .now() + testExpectationTimeout), .success) - coordinationCompleted.fulfill() - } - fetcher.add(message: message) - internalAPI.inAppManager.scheduleSync().onSuccess { _ in fetchCompleted.fulfill() } + func testConcurrentIdentitySwitchDuringAvailabilityDelegateStopsLaterDeliverySteps() { + assertConcurrentIdentitySwitchDuringDelivery(at: .delegate) + } - wait(for: [coordinationCompleted, fetchCompleted], timeout: testExpectationTimeout) - wait(for: [noAvailability, noNotification, noConsume], timeout: testExpectationTimeoutForInverted) - XCTAssertTrue(IterableAPI.getUnhandledJsonOnlyMessages().isEmpty) - XCTAssertFalse(internalAPI.inAppManager.getMessages().contains { $0.messageId == message.messageId }) - XCTAssertFalse(message.didProcessTrigger) - XCTAssertFalse(message.consumed) - notificationCenter.removeCallbacks(withIds: notificationReference.callbackId) + func testConcurrentIdentitySwitchDuringNotificationStopsLaterDeliverySteps() { + assertConcurrentIdentitySwitchDuringDelivery(at: .notification) } func testIdentitySwitchBeforeMainDeliveryDoesNotLeakMessage() { @@ -3079,6 +3024,81 @@ final class JsonOnlyMessageAvailabilityTests: XCTestCase { case notification } + private func assertConcurrentIdentitySwitchDuringDelivery(at stage: IdentitySwitchStage) { + let surfaceStarted = DispatchSemaphore(value: 0) + let releaseSurface = DispatchSemaphore(value: 0) + let switchCompleted = DispatchSemaphore(value: 0) + let coordinationCompleted = expectation(description: "identity switch coordinated") + let fetchCompleted = expectation(description: "fetch completed") + let noNetworkSideEffect = expectation(description: "no consume or delivery tracking") + noNetworkSideEffect.isInverted = true + let fetcher = MockInAppFetcher() + let notificationCenter = MockNotificationCenter() + let networkSession = MockNetworkSession() + let delegate = MockInAppDelegate() + let message = makeJsonOnlyMessage(id: "message-a") + var onNewCount = 0 + var delegateCount = 0 + var notificationCount = 0 + + let pauseIfNeeded = { (callbackStage: IdentitySwitchStage) in + guard stage == callbackStage else { return } + surfaceStarted.signal() + releaseSurface.wait() + } + delegate.onNewMessageCallback = { _ in + onNewCount += 1 + pauseIfNeeded(.onNew) + } + delegate.onJsonOnlyMessageAvailableCallback = { _ in + delegateCount += 1 + pauseIfNeeded(.delegate) + } + let notificationReference = notificationCenter.addCallback(forNotification: .iterableJsonOnlyInAppMessageAvailable) { _ in + notificationCount += 1 + pauseIfNeeded(.notification) + } + networkSession.requestCallback = { request in + if request.url?.path.contains(Const.Path.inAppConsume) == true || + request.url?.path.contains(Const.Path.trackInAppDelivery) == true { + noNetworkSideEffect.fulfill() + } + } + let internalAPI = initialize(fetcher: fetcher, + delegate: delegate, + networkSession: networkSession, + notificationCenter: notificationCenter) + DispatchQueue.global().async { + guard surfaceStarted.wait(timeout: .now() + testExpectationTimeout) == .success else { + XCTFail("Delivery surface did not start") + releaseSurface.signal() + coordinationCompleted.fulfill() + return + } + fetcher.mockMessagesAvailableFromServer(internalApi: nil, messages: []) + DispatchQueue.global().async { + internalAPI.setUserId("user-b") + switchCompleted.signal() + } + XCTAssertEqual(switchCompleted.wait(timeout: .now() + testExpectationTimeout), .success) + releaseSurface.signal() + coordinationCompleted.fulfill() + } + fetcher.add(message: message) + internalAPI.inAppManager.scheduleSync().onSuccess { _ in fetchCompleted.fulfill() } + + wait(for: [coordinationCompleted, fetchCompleted], timeout: testExpectationTimeout) + wait(for: [noNetworkSideEffect], timeout: testExpectationTimeoutForInverted) + XCTAssertEqual(onNewCount, 1) + XCTAssertEqual(delegateCount, stage == .onNew ? 0 : 1) + XCTAssertEqual(notificationCount, stage == .notification ? 1 : 0) + XCTAssertTrue(IterableAPI.getUnhandledJsonOnlyMessages().isEmpty) + XCTAssertFalse(internalAPI.inAppManager.getMessages().contains { $0.messageId == message.messageId }) + XCTAssertFalse(message.didProcessTrigger) + XCTAssertFalse(message.consumed) + notificationCenter.removeCallbacks(withIds: notificationReference.callbackId) + } + private func assertIdentitySwitchDuringDelivery(at stage: IdentitySwitchStage) { let identitySwitchExpectation = expectation(description: "identity switched") let noConsumeExpectation = expectation(description: "no consume after identity switch") From 14596663df73d1cb693e6f17528a262e2d5d51ff Mon Sep 17 00:00:00 2001 From: Sumeru Chatterjee Date: Wed, 29 Jul 2026 11:02:58 +0100 Subject: [PATCH 12/15] SDK-496 Read application state on the main thread during start JSON-only replay was added to start() reading applicationStateProvider.applicationState directly, so when start() ran off the main thread the SDK made an invalid off-main read of UIApplication.applicationState. Under Xcode 16.x, which runs async XCTest bodies on a concurrency worker, this terminated the test host and crashed the three async disableDevice tests in CI; the same access would be invalid for any app initializing the SDK off the main thread. Replay now resolves the active state through a main-thread helper, mirroring the existing getAppIsReady hop but without its display gate, so JSON-only data replay stays decoupled from HTML display state. The regression test asserts the read happens on the main thread, so it fails deterministically on any toolchain rather than depending on scheduling. Also hardens InboxTests.testInboxAndInAppCallbacksTogether, which had no happens-before edge between observing the first inbox notification and submitting the second payload, so the deferred observer could read the later count. It now waits for the first notification and its assertion before the second fetch. Co-Authored-By: Claude Fable 5 --- swift-sdk/Internal/in-app/InAppManager.swift | 38 ++++++++++++++------ tests/unit-tests/InAppTests.swift | 19 ++++++++++ tests/unit-tests/InboxTests.swift | 11 +++--- 3 files changed, 52 insertions(+), 16 deletions(-) diff --git a/swift-sdk/Internal/in-app/InAppManager.swift b/swift-sdk/Internal/in-app/InAppManager.swift index d1c2e34d4..ed4ffbeef 100644 --- a/swift-sdk/Internal/in-app/InAppManager.swift +++ b/swift-sdk/Internal/in-app/InAppManager.swift @@ -688,17 +688,19 @@ class InAppManager: NSObject, IterableInternalInAppManagerProtocol { } private func replayUnhandledJsonOnlyMessages() -> Pending { - let identityContext = jsonOnlyMessageStore.identityContext - guard applicationStateProvider.applicationState == .active, - identityContext.identity != nil else { - return Fulfill(value: true) - } + InAppManager.getApplicationIsActive(applicationStateProvider: applicationStateProvider).flatMap { [weak self] appIsActive in + guard let self = self else { return Fulfill(value: true) } + let identityContext = self.jsonOnlyMessageStore.identityContext + guard appIsActive, identityContext.identity != nil else { + return Fulfill(value: true) + } - return jsonOnlyMessageStore.getMessages(identityContext: identityContext).reduce(Fulfill(value: true) as Pending) { pending, message in - pending.flatMap { [weak self] _ in - self?.deliverJsonOnlyMessage(message, - consumeOnReplay: false, - identityContext: identityContext) ?? Fulfill(value: true) + return self.jsonOnlyMessageStore.getMessages(identityContext: identityContext).reduce(Fulfill(value: true) as Pending) { pending, message in + pending.flatMap { [weak self] _ in + self?.deliverJsonOnlyMessage(message, + consumeOnReplay: false, + identityContext: identityContext) ?? Fulfill(value: true) + } } } } @@ -877,7 +879,21 @@ class InAppManager: NSObject, IterableInternalInAppManagerProtocol { return result } } - + + private static func getApplicationIsActive(applicationStateProvider: ApplicationStateProviderProtocol) -> Fulfill { + if Thread.isMainThread { + return Fulfill(value: applicationStateProvider.applicationState == .active) + } else { + let result = Fulfill() + + DispatchQueue.main.async { + result.resolve(with: applicationStateProvider.applicationState == .active) + } + + return result + } + } + private weak var requestHandler: RequestHandlerProtocol? private let deviceMetadata: DeviceMetadata private let fetcher: InAppFetcherProtocol diff --git a/tests/unit-tests/InAppTests.swift b/tests/unit-tests/InAppTests.swift index ef5f7bdd4..2b730c0e2 100644 --- a/tests/unit-tests/InAppTests.swift +++ b/tests/unit-tests/InAppTests.swift @@ -2003,12 +2003,31 @@ private final class BlockingInAppFetcher: InAppFetcherProtocol { private var didCompleteBlockedFetch = false } +private final class MainThreadCheckingApplicationStateProvider: NSObject, ApplicationStateProviderProtocol { + var applicationState: UIApplication.State { + XCTAssertTrue(Thread.isMainThread) + return .active + } +} + final class JsonOnlyMessageAvailabilityTests: XCTestCase { override func tearDown() { IterableAPI.implementation = nil super.tearDown() } + func testStartReadsApplicationStateOnMainThread() { + let startExpectation = expectation(description: "SDK start") + let applicationStateProvider = MainThreadCheckingApplicationStateProvider() + + DispatchQueue.global().async { + IterableAPI.initializeForTesting(applicationStateProvider: applicationStateProvider) + startExpectation.fulfill() + } + + wait(for: [startExpectation], timeout: testExpectationTimeout) + } + func testAvailabilityPersistsBeforeOrderedMainThreadSignals() { let onNewExpectation = expectation(description: "legacy onNew") let availabilityExpectation = expectation(description: "availability delegate") diff --git a/tests/unit-tests/InboxTests.swift b/tests/unit-tests/InboxTests.swift index 7047acc0a..43ae68c3c 100644 --- a/tests/unit-tests/InboxTests.swift +++ b/tests/unit-tests/InboxTests.swift @@ -454,8 +454,8 @@ class InboxTests: XCTestCase { } func testInboxAndInAppCallbacksTogether() { - let expectation1 = expectation(description: "call inbox callback") - expectation1.expectedFulfillmentCount = 2 + let firstInboxObserved = expectation(description: "first inbox callback") + let secondInboxObserved = expectation(description: "second inbox callback") let expectation2 = expectation(description: "call inApp callback") expectation2.expectedFulfillmentCount = 2 let expectation3 = expectation(description: "payload 1 processed") @@ -484,11 +484,12 @@ class InboxTests: XCTestCase { if inboxCallbackCount == 0 { XCTAssertEqual(messages.count, 1, "inboxMessages: \(internalAPI.inAppManager.getInboxMessages())") XCTAssertEqual(messages[0].messageId, "message0") + firstInboxObserved.fulfill() } else { XCTAssertEqual(messages.count, 2) XCTAssertEqual(messages[1].messageId, "message1") + secondInboxObserved.fulfill() } - expectation1.fulfill() inboxCallbackCount += 1 } } @@ -530,7 +531,7 @@ class InboxTests: XCTestCase { mockInAppFetcher.mockInAppPayloadFromServer(internalApi: internalAPI, payload).onSuccess { _ in expectation3.fulfill() } - wait(for: [expectation3], timeout: testExpectationTimeout) + wait(for: [expectation3, firstInboxObserved], timeout: testExpectationTimeout) let payload2 = """ {"inAppMessages": @@ -575,7 +576,7 @@ class InboxTests: XCTestCase { expectation4.fulfill() } - wait(for: [expectation4, expectation1, expectation2], timeout: testExpectationTimeout) + wait(for: [expectation4, secondInboxObserved, expectation2], timeout: testExpectationTimeout) } func testShowNowAndInboxMessage() { From 10113ae7a6f81871b95926ed07ad4988ba221d95 Mon Sep 17 00:00:00 2001 From: Sumeru Chatterjee Date: Wed, 29 Jul 2026 11:20:36 +0100 Subject: [PATCH 13/15] SDK-496 Gate read-marking on inbox messages and settle retention limits set(read:) posted iterableInboxChanged for every message, so showing a popup announced an inbox change even though the message was never in the inbox. It is now gated on saveToInbox like the add and remove paths. reset() and sync overwrite still post unconditionally. Also drops the provisional marker on the JSON-only queue limits. The 30 day retention and 100 record caps are SDK limits on a local queue rather than a public contract, and are documented as such. Co-Authored-By: Claude Fable 5 --- swift-sdk/Internal/in-app/InAppManager.swift | 1 + .../Internal/in-app/InAppPersistence.swift | 1 - tests/unit-tests/InboxTests.swift | 71 +++++++++++++------ 3 files changed, 51 insertions(+), 22 deletions(-) diff --git a/swift-sdk/Internal/in-app/InAppManager.swift b/swift-sdk/Internal/in-app/InAppManager.swift index ed4ffbeef..16ad921ba 100644 --- a/swift-sdk/Internal/in-app/InAppManager.swift +++ b/swift-sdk/Internal/in-app/InAppManager.swift @@ -208,6 +208,7 @@ class InAppManager: NSObject, IterableInternalInAppManagerProtocol { func set(read: Bool, forMessage message: IterableInAppMessage, successHandler: OnSuccessHandler? = nil, failureHandler: OnFailureHandler? = nil) { updateMessage(message, read: read).onSuccess { [weak self] _ in successHandler?([:]) + guard message.saveToInbox else { return } self?.callbackQueue.async { [weak self] in self?.notificationCenter.post(name: .iterableInboxChanged, object: self, userInfo: nil) } diff --git a/swift-sdk/Internal/in-app/InAppPersistence.swift b/swift-sdk/Internal/in-app/InAppPersistence.swift index 9c82e35ae..03c78785f 100644 --- a/swift-sdk/Internal/in-app/InAppPersistence.swift +++ b/swift-sdk/Internal/in-app/InAppPersistence.swift @@ -739,7 +739,6 @@ final class JsonOnlyMessageStore { } } - // Product defaults pending confirmation. private static let fallbackRetentionPeriod: TimeInterval = 30 * 24 * 60 * 60 private static let maximumRecordCount = 100 private static let maximumAcknowledgementCount = 100 diff --git a/tests/unit-tests/InboxTests.swift b/tests/unit-tests/InboxTests.swift index 43ae68c3c..25eaec802 100644 --- a/tests/unit-tests/InboxTests.swift +++ b/tests/unit-tests/InboxTests.swift @@ -72,14 +72,15 @@ class InboxTests: XCTestCase { } func testSetRead() { - let expectation1 = expectation(description: "testSetRead") let mockInAppFetcher = MockInAppFetcher() + let mockNotificationCenter = MockNotificationCenter() let config = IterableConfig() config.logDelegate = AllLogDelegate() let internalAPI = InternalIterableAPI.initializeForTesting( config: config, - inAppFetcher: mockInAppFetcher + inAppFetcher: mockInAppFetcher, + notificationCenter: mockNotificationCenter ) let payload = """ @@ -104,26 +105,54 @@ class InboxTests: XCTestCase { ] } """.toJsonDict() - - mockInAppFetcher.mockInAppPayloadFromServer(internalApi: internalAPI, payload).onSuccess { _ in - let messages = internalAPI.inAppManager.getInboxMessages() - XCTAssertEqual(messages.count, 2) - - internalAPI.inAppManager.set(read: true, forMessage: messages[1]) - - DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { - XCTAssertEqual(messages[0].read, false) - XCTAssertEqual(messages[1].read, true) - - let unreadMessages = internalAPI.inAppManager.getInboxMessages().filter { $0.read == false } - XCTAssertEqual(internalAPI.inAppManager.getUnreadInboxMessagesCount(), 1) - XCTAssertEqual(unreadMessages.count, 1) - XCTAssertEqual(unreadMessages[0].read, false) - expectation1.fulfill() - } + + let initialInboxExpectation = expectation(description: "initial inbox load") + let initialReference = mockNotificationCenter.addCallback(forNotification: .iterableInboxChanged) { _ in + initialInboxExpectation.fulfill() } - - wait(for: [expectation1], timeout: testExpectationTimeout) + mockInAppFetcher.mockInAppPayloadFromServer(internalApi: internalAPI, payload) + wait(for: [initialInboxExpectation], timeout: testExpectationTimeout) + mockNotificationCenter.removeCallbacks(withIds: initialReference.callbackId) + + let messages = internalAPI.inAppManager.getInboxMessages() + XCTAssertEqual(messages.count, 2) + + let readExpectation = expectation(description: "inbox changed after read") + let readReference = mockNotificationCenter.addCallback(forNotification: .iterableInboxChanged) { _ in + XCTAssertTrue(messages[1].read) + readExpectation.fulfill() + } + internalAPI.inAppManager.set(read: true, forMessage: messages[1]) + wait(for: [readExpectation], timeout: testExpectationTimeout) + mockNotificationCenter.removeCallbacks(withIds: readReference.callbackId) + + XCTAssertFalse(messages[0].read) + XCTAssertEqual(internalAPI.inAppManager.getUnreadInboxMessagesCount(), 1) + let unreadMessages = internalAPI.inAppManager.getInboxMessages().filter { !$0.read } + XCTAssertEqual(unreadMessages.count, 1) + XCTAssertFalse(unreadMessages[0].read) + } + + func testSetReadNonInboxMessageDoesNotPostInboxChanged() { + let message = IterableInAppMessage(messageId: "message1", + campaignId: 1, + trigger: IterableInAppTrigger(dict: [JsonKey.InApp.type: "never"]), + content: IterableHtmlInAppContent(edgeInsets: .zero, html: ""), + saveToInbox: false) + let mockNotificationCenter = MockNotificationCenter() + let internalAPI = InternalIterableAPI.initializeForTesting(inAppFetcher: MockInAppFetcher(messages: [message]), + notificationCenter: mockNotificationCenter) + let notificationExpectation = expectation(description: "no inbox change after non-inbox read") + notificationExpectation.isInverted = true + let notificationReference = mockNotificationCenter.addCallback(forNotification: .iterableInboxChanged) { _ in + notificationExpectation.fulfill() + } + + internalAPI.inAppManager.set(read: true, forMessage: message) + + wait(for: [notificationExpectation], timeout: testExpectationTimeoutForInverted) + XCTAssertTrue(message.read) + mockNotificationCenter.removeCallbacks(withIds: notificationReference.callbackId) } func testReceiveReadMessage() { From d0a1ad5a2f2adb58f826cc321277a6c7423ccfb8 Mon Sep 17 00:00:00 2001 From: Sumeru Chatterjee Date: Wed, 29 Jul 2026 11:53:42 +0100 Subject: [PATCH 14/15] SDK-496 Clarify delivery flag name and record identity invariants Renames consumeOnReplay to consumePreviouslyDelivered. The flag means consume even though initial delivery already began, so the old name read backwards at the branch that decides it. Adds rationale comments where the constraint is not visible from the code: why identity publication is announced before taking the lock and why the announcement spans logout and republication, what the three processing phases are and what messagesRevision guards, why the per-callback identity checks cannot be collapsed, why JSON-only delivery bypasses the HTML display gate, why one merge branch must precede another, and why a missing unhandled record is never recreated. Co-Authored-By: Claude Fable 5 --- swift-sdk/Internal/Auth.swift | 1 + swift-sdk/Internal/InternalIterableAPI.swift | 3 +++ .../Internal/in-app/InAppManager+Functions.swift | 3 +++ swift-sdk/Internal/in-app/InAppManager.swift | 14 +++++++++----- swift-sdk/Internal/in-app/InAppPersistence.swift | 2 ++ 5 files changed, 18 insertions(+), 5 deletions(-) diff --git a/swift-sdk/Internal/Auth.swift b/swift-sdk/Internal/Auth.swift index c43ef4d15..1a6dc8b5d 100644 --- a/swift-sdk/Internal/Auth.swift +++ b/swift-sdk/Internal/Auth.swift @@ -47,6 +47,7 @@ final class IdentityCoordinator { } func publish(_ block: () -> Void) { + // Announce before waiting for the identity lock so stale in-flight checks fail while publication is queued. beginPublication() withCriticalSection { block() diff --git a/swift-sdk/Internal/InternalIterableAPI.swift b/swift-sdk/Internal/InternalIterableAPI.swift index a4a2fcd35..a25be7953 100644 --- a/swift-sdk/Internal/InternalIterableAPI.swift +++ b/swift-sdk/Internal/InternalIterableAPI.swift @@ -151,6 +151,7 @@ final class InternalIterableAPI: NSObject, PushTrackerProtocol, AuthProvider { } func setEmail(_ email: String?, authToken: String? = nil, successHandler: OnSuccessHandler? = nil, failureHandler: OnFailureHandler? = nil, identityResolution: IterableIdentityResolution? = nil) { + // Span previous-user logout/reset and replacement identity publication so stale work cannot commit between them. identityCoordinator.beginPublication() ITBInfo() @@ -204,6 +205,7 @@ final class InternalIterableAPI: NSObject, PushTrackerProtocol, AuthProvider { } func setUserId(_ userId: String?, authToken: String? = nil, successHandler: OnSuccessHandler? = nil, failureHandler: OnFailureHandler? = nil, isUnknownUser: Bool = false, identityResolution: IterableIdentityResolution? = nil) { + // Span previous-user logout/reset and replacement identity publication so stale work cannot commit between them. identityCoordinator.beginPublication() ITBInfo() @@ -268,6 +270,7 @@ final class InternalIterableAPI: NSObject, PushTrackerProtocol, AuthProvider { func logoutUser(withOnSuccess onSuccess: OnSuccessHandler?, onFailure: OnFailureHandler?) { + // Announce logout before waiting for the identity lock so stale work stops while publication is queued. identityCoordinator.beginPublication() ITBInfo() diff --git a/swift-sdk/Internal/in-app/InAppManager+Functions.swift b/swift-sdk/Internal/in-app/InAppManager+Functions.swift index 619e422d3..9ed5bb4d7 100644 --- a/swift-sdk/Internal/in-app/InAppManager+Functions.swift +++ b/swift-sdk/Internal/in-app/InAppManager+Functions.swift @@ -60,6 +60,7 @@ struct MessagesProcessor { ITBDebug("processing message with id: \(message.messageId)") + // JSON-only availability intentionally bypasses the HTML display pause and cooldown. if message.isJsonOnly { return .jsonOnly(message) } @@ -87,6 +88,7 @@ struct MessagesProcessor { private func getFirstProcessableTriggeredMessage() -> IterableInAppMessage? { let processableMessages = messagesMap.values.filter(isProcessableTriggeredMessage) + // Select JSON-only records before applying HTML priority ordering. return processableMessages.first(where: { $0.isJsonOnly }) ?? processableMessages.sorted { $0.priorityLevel < $1.priorityLevel }.first } @@ -154,6 +156,7 @@ struct MessagesObtainedHandler { messages.forEach { serverMessage in let messageId = serverMessage.messageId if let existingMessage = messagesMap[messageId] { + // Handle acknowledged HTML-to-JSON transitions before generic type replacement to avoid readmission. if !existingMessage.isJsonOnly, serverMessage.isJsonOnly, acknowledgedJsonOnlyMessageIds.contains(messageId) { diff --git a/swift-sdk/Internal/in-app/InAppManager.swift b/swift-sdk/Internal/in-app/InAppManager.swift index 16ad921ba..914234db5 100644 --- a/swift-sdk/Internal/in-app/InAppManager.swift +++ b/swift-sdk/Internal/in-app/InAppManager.swift @@ -309,6 +309,8 @@ class InAppManager: NSObject, IterableInternalInAppManagerProtocol { } } + // Merge on syncQueue, process customer/display work off it, then return for guarded tracking and persistence. + // messagesRevision prevents stale processing results from overwriting a reset. private func processFetchedMessages(_ messages: [IterableInAppMessage], appIsReady: Bool, identityContext: UserIdentityContext) -> Pending { @@ -441,7 +443,7 @@ class InAppManager: NSObject, IterableInternalInAppManagerProtocol { } if case let .jsonOnly(message, _) = messagesProcessorResult { - return deliverJsonOnlyMessage(message, consumeOnReplay: true, identityContext: identityContext).flatMap { [weak self] processed in + return deliverJsonOnlyMessage(message, consumePreviouslyDelivered: true, identityContext: identityContext).flatMap { [weak self] processed in guard let self = self, processed || InAppManager.isExpired(message: message, currentDate: self.dateProvider.currentDate) else { return Fulfill(value: true) @@ -699,7 +701,7 @@ class InAppManager: NSObject, IterableInternalInAppManagerProtocol { return self.jsonOnlyMessageStore.getMessages(identityContext: identityContext).reduce(Fulfill(value: true) as Pending) { pending, message in pending.flatMap { [weak self] _ in self?.deliverJsonOnlyMessage(message, - consumeOnReplay: false, + consumePreviouslyDelivered: false, identityContext: identityContext) ?? Fulfill(value: true) } } @@ -707,12 +709,12 @@ class InAppManager: NSObject, IterableInternalInAppManagerProtocol { } private func deliverJsonOnlyMessage(_ message: IterableInAppMessage, - consumeOnReplay: Bool, + consumePreviouslyDelivered: Bool, identityContext: UserIdentityContext) -> Pending { let result = Fulfill() guard identityContext.identity != nil else { - if consumeOnReplay { + if consumePreviouslyDelivered { deliverJsonOnlyMessageWithoutAvailability(message, result: result) } else { result.resolve(with: false) @@ -738,6 +740,8 @@ class InAppManager: NSObject, IterableInternalInAppManagerProtocol { return } + // Customer callbacks run outside the identity section. Revalidate each boundary so an identity switch + // stops later signals and mutation; these checks must not be deduplicated. if delivery.isInitial { guard self.identityCoordinator.isCurrent(identityContext, identityProvider: self.identityProvider) else { @@ -771,7 +775,7 @@ class InAppManager: NSObject, IterableInternalInAppManagerProtocol { return } - guard delivery.isInitial || consumeOnReplay else { + guard delivery.isInitial || consumePreviouslyDelivered else { result.resolve(with: true) return } diff --git a/swift-sdk/Internal/in-app/InAppPersistence.swift b/swift-sdk/Internal/in-app/InAppPersistence.swift index 03c78785f..ea0781731 100644 --- a/swift-sdk/Internal/in-app/InAppPersistence.swift +++ b/swift-sdk/Internal/in-app/InAppPersistence.swift @@ -470,6 +470,7 @@ final class JsonOnlyMessageStore { var result: Delivery? guard identityCoordinator.performIfCurrent(identityContext, identityProvider: identityProvider, { result = stateQueue.sync { + // Absence can mean retention/capacity discard or identity clearing, so never recreate a missing entry. guard var state = loadCurrentState(currentIdentity: identity), let index = state.entries.firstIndex(where: { $0.message.messageId == message.messageId }) else { return nil @@ -699,6 +700,7 @@ final class JsonOnlyMessageStore { return canonicalPayload.data(using: .utf8) } + // This encoding is persisted acknowledgement state; stability across SDK upgrades is part of its contract. private static func canonicalJson(_ value: Any) -> String? { if let dictionary = value as? [AnyHashable: Any] { var entries = [(String, Any)]() From ed110aff05d4b03ee8efbc91c690302bf78bb73c Mon Sep 17 00:00:00 2001 From: Sumeru Chatterjee Date: Wed, 29 Jul 2026 17:19:01 +0100 Subject: [PATCH 15/15] SDK-496 Harden mixed auth queue test ordering The test gave all queued requests the same JWT failure response and relied on Core Data preserving insertion order for equal scheduled timestamps. Under contention an unauthenticated task could run first, receive the JWT failure, and stop the runner before the test installed its success observer. Assign explicit task ordering, configure only the first authenticated request to fail, and register the success observer before starting the runner so the test deterministically exercises unauthenticated draining during auth pause. Co-Authored-By: Claude Fable 5 --- .../TaskRunnerTests.swift | 46 +++++++++++-------- 1 file changed, 28 insertions(+), 18 deletions(-) diff --git a/tests/offline-events-tests/TaskRunnerTests.swift b/tests/offline-events-tests/TaskRunnerTests.swift index efae734e6..41b447752 100644 --- a/tests/offline-events-tests/TaskRunnerTests.swift +++ b/tests/offline-events-tests/TaskRunnerTests.swift @@ -1115,7 +1115,17 @@ class TaskRunnerTests: XCTestCase { func testMixedQueueOnlyUnauthenticatedExecuteDuringAuthPause() throws { let jwtErrorData = ["code": "InvalidJwtPayload"].toJsonData() - let networkSession = MockNetworkSession(statusCode: 401, data: jwtErrorData) + let responseLock = NSLock() + var authFailureSent = false + let networkSession = MockNetworkSession(responseCallback: { url in + responseLock.lock() + defer { responseLock.unlock() } + guard url.path.contains(Const.Path.trackEvent), !authFailureSent else { + return nil + } + authFailureSent = true + return MockNetworkSession.MockResponse(statusCode: 401, data: jwtErrorData) + }) let notificationCenter = MockNotificationCenter() @@ -1128,9 +1138,10 @@ class TaskRunnerTests: XCTestCase { let scheduler = IterableTaskScheduler(persistenceContextProvider: persistenceContextProvider, notificationCenter: notificationCenter, healthMonitor: healthMonitor) - let _ = try scheduleSampleTask(scheduler: scheduler) - let _ = try scheduleUnauthenticatedTask(scheduler: scheduler) - let _ = try scheduleUnauthenticatedTask(scheduler: scheduler) + let scheduledAt = Date(timeIntervalSince1970: 0) + let _ = try scheduleSampleTask(scheduler: scheduler, scheduledAt: scheduledAt) + let _ = try scheduleUnauthenticatedTask(scheduler: scheduler, scheduledAt: scheduledAt.addingTimeInterval(1)) + let _ = try scheduleUnauthenticatedTask(scheduler: scheduler, scheduledAt: scheduledAt.addingTimeInterval(2)) // Wait for all 3 tasks to persist let scheduledPredicate = NSPredicate { _, _ in @@ -1145,6 +1156,13 @@ class TaskRunnerTests: XCTestCase { } XCTAssertNotNil(retryRef) + let unauthSuccessExpectation = expectation(description: "unauthenticated tasks processed") + unauthSuccessExpectation.expectedFulfillmentCount = 2 + let successRef = notificationCenter.addCallback(forNotification: .iterableTaskFinishedWithSuccess) { _ in + unauthSuccessExpectation.fulfill() + } + XCTAssertNotNil(successRef) + let taskRunner = IterableTaskRunner(networkSession: networkSession, persistenceContextProvider: persistenceContextProvider, healthMonitor: healthMonitor, @@ -1157,17 +1175,7 @@ class TaskRunnerTests: XCTestCase { wait(for: [retryExpectation], timeout: 5.0) notificationCenter.removeCallbacks(withIds: retryRef.callbackId) - // Fix network so unauthenticated tasks succeed - networkSession.responseCallback = nil - // Wait for the 2 unauthenticated tasks to be processed - let unauthSuccessExpectation = expectation(description: "unauthenticated tasks processed") - unauthSuccessExpectation.expectedFulfillmentCount = 2 - let successRef = notificationCenter.addCallback(forNotification: .iterableTaskFinishedWithSuccess) { _ in - unauthSuccessExpectation.fulfill() - } - XCTAssertNotNil(successRef) - wait(for: [unauthSuccessExpectation], timeout: 10.0) notificationCenter.removeCallbacks(withIds: successRef.callbackId) @@ -1538,7 +1546,8 @@ class TaskRunnerTests: XCTestCase { } private func scheduleSampleTask(scheduler: IterableTaskScheduler, - authToken: String? = nil) throws -> Pending { + authToken: String? = nil, + scheduledAt: Date? = nil) throws -> Pending { let apiKey = "zee-api-key" let eventName = "CustomEvent1" let dataFields = ["var1": "val1", "var2": "val2"] @@ -1553,11 +1562,12 @@ class TaskRunnerTests: XCTestCase { authToken: authToken, deviceMetadata: deviceMetadata, iterableRequest: trackEventRequest) - return scheduler.schedule(apiCallRequest: apiCallRequest) + return scheduler.schedule(apiCallRequest: apiCallRequest, scheduledAt: scheduledAt) } /// Schedules a task with an unauthenticated API path (disableDevice) for bypass testing. - private func scheduleUnauthenticatedTask(scheduler: IterableTaskScheduler) throws -> Pending { + private func scheduleUnauthenticatedTask(scheduler: IterableTaskScheduler, + scheduledAt: Date? = nil) throws -> Pending { let apiKey = "zee-api-key" let iterableRequest = IterableRequest.post(PostRequest(path: Const.Path.disableDevice, args: nil, @@ -1567,7 +1577,7 @@ class TaskRunnerTests: XCTestCase { authToken: nil, deviceMetadata: deviceMetadata, iterableRequest: iterableRequest) - return scheduler.schedule(apiCallRequest: apiCallRequest) + return scheduler.schedule(apiCallRequest: apiCallRequest, scheduledAt: scheduledAt) } private func verifyNoTaskIsExecuted(_ notificationCenter: MockNotificationCenter, forInterval interval: TimeInterval) {