diff --git a/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj b/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj index 0a578d582..e4d55d76e 100644 --- a/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj +++ b/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj @@ -108,6 +108,7 @@ 3C2DB2F12DE6CB5E0006B905 /* OneSignalBadgeHelpers.h in Headers */ = {isa = PBXBuildFile; fileRef = 3C2DB2EF2DE6CB5E0006B905 /* OneSignalBadgeHelpers.h */; settings = {ATTRIBUTES = (Public, ); }; }; 3C2DB2F22DE6CB5E0006B905 /* OneSignalBadgeHelpers.m in Sources */ = {isa = PBXBuildFile; fileRef = 3C2DB2F02DE6CB5E0006B905 /* OneSignalBadgeHelpers.m */; }; 3C30FE362F21FBE1001B9C25 /* EarlyTriggerTrackingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C30FE352F21FBE1001B9C25 /* EarlyTriggerTrackingTests.swift */; }; + 3C8B41D82F7E9A6C001B9C25 /* TriggerThreadingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C8B41D72F7E9A6C001B9C25 /* TriggerThreadingTests.swift */; }; 3C3D34E92E95EAA5006A2924 /* LiveActivityConstants.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C3D34E82E95EAA5006A2924 /* LiveActivityConstants.swift */; }; 3C3D8D782E92DB7500C3E977 /* OSLiveActivityViewExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C3D8D772E92DB7500C3E977 /* OSLiveActivityViewExtensions.swift */; }; 3C427AC9301BB28A0059B8B7 /* OSOperationRepoFlushTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C427AC8301BB28A0059B8B7 /* OSOperationRepoFlushTests.swift */; }; @@ -1382,6 +1383,7 @@ 3C2DB2EF2DE6CB5E0006B905 /* OneSignalBadgeHelpers.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = OneSignalBadgeHelpers.h; sourceTree = ""; }; 3C2DB2F02DE6CB5E0006B905 /* OneSignalBadgeHelpers.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = OneSignalBadgeHelpers.m; sourceTree = ""; }; 3C30FE352F21FBE1001B9C25 /* EarlyTriggerTrackingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EarlyTriggerTrackingTests.swift; sourceTree = ""; }; + 3C8B41D72F7E9A6C001B9C25 /* TriggerThreadingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TriggerThreadingTests.swift; sourceTree = ""; }; 3C3D34E82E95EAA5006A2924 /* LiveActivityConstants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveActivityConstants.swift; sourceTree = ""; }; 3C3D8D772E92DB7500C3E977 /* OSLiveActivityViewExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSLiveActivityViewExtensions.swift; sourceTree = ""; }; 3C427AC8301BB28A0059B8B7 /* OSOperationRepoFlushTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSOperationRepoFlushTests.swift; sourceTree = ""; }; @@ -2281,6 +2283,7 @@ 3C7021E82ECF0CF4001768C6 /* IAMIntegrationTests.swift */, 3CAA4BB62F0BAFBA00A16682 /* TriggerTests.swift */, 3C30FE352F21FBE1001B9C25 /* EarlyTriggerTrackingTests.swift */, + 3C8B41D72F7E9A6C001B9C25 /* TriggerThreadingTests.swift */, 3CB35FCA2F0FA20B000E6E0F /* OSMessagingControllerUserStateTests.swift */, 3C7021E72ECF0CF3001768C6 /* OneSignalInAppMessagesTests-Bridging-Header.h */, ); @@ -4486,6 +4489,7 @@ buildActionMask = 2147483647; files = ( 3C30FE362F21FBE1001B9C25 /* EarlyTriggerTrackingTests.swift in Sources */, + 3C8B41D82F7E9A6C001B9C25 /* TriggerThreadingTests.swift in Sources */, 3CAA4BB72F0BAFBA00A16682 /* TriggerTests.swift in Sources */, 3C7021E92ECF0CF4001768C6 /* IAMIntegrationTests.swift in Sources */, 3C01519C2C2E29F90079E076 /* IAMRequestTests.m in Sources */, diff --git a/iOS_SDK/OneSignalSDK/OneSignalInAppMessages/Controller/OSMessagingController.m b/iOS_SDK/OneSignalSDK/OneSignalInAppMessages/Controller/OSMessagingController.m index 5e1256482..0eb2f7664 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalInAppMessages/Controller/OSMessagingController.m +++ b/iOS_SDK/OneSignalSDK/OneSignalInAppMessages/Controller/OSMessagingController.m @@ -190,16 +190,35 @@ + (void)start { - (BOOL)isInAppMessagingPaused { return _isInAppMessagingPaused; } + +/// The message collections and trigger evaluation belong to the main queue. Entry points that can +/// arrive on any thread go through here, inline on the main thread and dispatched asynchronously from +/// anywhere else. +- (void)runOnMainQueue:(void (^)(void))block { + if ([NSThread isMainThread]) { + block(); + } else { + dispatch_async(dispatch_get_main_queue(), block); + } +} + - (void)setInAppMessagingPaused:(BOOL)pause { + // Written on the calling thread so a read right after this call sees the new value. _isInAppMessagingPaused = pause; // If IAM are not paused, try to evaluate and show IAMs if (!pause) { - [self evaluateMessages]; - } else if (self.isInAppMessageShowing) { - dispatch_async(dispatch_get_main_queue(), ^{ - [self.viewController dismissCurrentInAppMessage]; - }); + [self runOnMainQueue:^{ + [self evaluateMessages]; + }]; + } else { + [self runOnMainQueue:^{ + if (self.isInAppMessageShowing) { + dispatch_async(dispatch_get_main_queue(), ^{ + [self.viewController dismissCurrentInAppMessage]; + }); + } + }]; } } @@ -828,25 +847,34 @@ - (void)evaluateRedisplayedInAppMessages:(NSArray *)newTriggersKeys #pragma mark Trigger Methods - (void)addTriggers:(NSDictionary *)triggers { - [self evaluateRedisplayedInAppMessages:triggers.allKeys]; - - // Track triggers added early on cold start (before first fetch completes) for redisplay logic - if (!self.hasCompletedFirstFetch) { - [OneSignalLog onesignalLog:ONE_S_LL_VERBOSE message:[NSString stringWithFormat:@"Tracking triggers added early on cold start: %@", triggers]]; - [self.earlySessionTriggers addObjectsFromArray:triggers.allKeys]; - } + NSDictionary *newTriggers = [triggers copy]; + [self runOnMainQueue:^{ + [self evaluateRedisplayedInAppMessages:newTriggers.allKeys]; + + // Track triggers added early on cold start (before first fetch completes) for redisplay logic + if (!self.hasCompletedFirstFetch) { + [OneSignalLog onesignalLog:ONE_S_LL_VERBOSE message:[NSString stringWithFormat:@"Tracking triggers added early on cold start: %@", newTriggers]]; + [self.earlySessionTriggers addObjectsFromArray:newTriggers.allKeys]; + } - [self.triggerController addTriggers:triggers]; + [self.triggerController addTriggers:newTriggers]; + }]; } - (void)removeTriggersForKeys:(NSArray *)keys { - [self evaluateRedisplayedInAppMessages:keys]; - [self.triggerController removeTriggersForKeys:keys]; + NSArray *removedKeys = [keys copy]; + [self runOnMainQueue:^{ + [self evaluateRedisplayedInAppMessages:removedKeys]; + [self.triggerController removeTriggersForKeys:removedKeys]; + }]; } - (void)clearTriggers { - NSDictionary *allTriggers = [self getTriggers]; - [self removeTriggersForKeys:allTriggers.allKeys]; + [self runOnMainQueue:^{ + // Read the keys on the main queue so a trigger queued before this call is cleared too. + NSDictionary *allTriggers = [self getTriggers]; + [self removeTriggersForKeys:allTriggers.allKeys]; + }]; } - (NSDictionary *)getTriggers { @@ -1044,8 +1072,8 @@ - (void)messageViewDidDisplayPage:(OSInAppMessageInternal *)message withPageId:( } - (void)messageIsNotActive:(OSInAppMessageInternal *)message { - [self deleteInactiveMessage:message]; dispatch_async(dispatch_get_main_queue(), ^{ + [self deleteInactiveMessage:message]; [self cleanUpInAppWindow]; }); } diff --git a/iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/IAMIntegrationTests.swift b/iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/IAMIntegrationTests.swift index ff466300f..6532a647f 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/IAMIntegrationTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/IAMIntegrationTests.swift @@ -101,7 +101,12 @@ final class IAMIntegrationTests: XCTestCase { // 3. Present the preview message OSMessagingController.sharedInstance().present(inAppPreviewMessage: message) - // 4. Verify that the preview IAM is showing even when paused + // 4. Let anything the pause queued on the main queue run first + let drained = expectation(description: "main queue drained") + DispatchQueue.main.async { drained.fulfill() } + wait(for: [drained], timeout: 5) + + // 5. Verify that the preview IAM is showing even when paused XCTAssertTrue(OSMessagingController.sharedInstance().isInAppMessageShowing) } diff --git a/iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/TriggerThreadingTests.swift b/iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/TriggerThreadingTests.swift new file mode 100644 index 000000000..56ec81058 --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/TriggerThreadingTests.swift @@ -0,0 +1,171 @@ +/* + Modified MIT License + + Copyright 2026 OneSignal + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + 1. The above copyright notice and this permission notice shall be included in + all copies or substantial portions of the Software. + + 2. All copies of substantial portions of the Software may only be used in connection +with services provided by OneSignal. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + THE SOFTWARE. + */ + +import XCTest +import OneSignalOSCore +import OneSignalCoreMocks +import OneSignalOSCoreMocks +import OneSignalUserMocks +import OneSignalInAppMessagesMocks + +/** + The trigger and pause entry points accept calls from any thread, while the controller's message + state lives on the main queue. Each test drives an entry point from a background queue with the + main thread held, then lets the main queue drain and checks where the work ran. + */ +final class TriggerThreadingTests: XCTestCase { + + /// Records the thread each call arrives on. Matching and sharing always fail so evaluation stops there. + private final class RecordingTriggerController: OSTriggerController { + var addCallsOnMain: [Bool] = [] + var removeCallsOnMain: [Bool] = [] + var matchCallsOnMain: [Bool] = [] + var sharedTriggerCallsOnMain: [Bool] = [] + + override func addTriggers(_ triggers: [String: Any]) { + addCallsOnMain.append(Thread.isMainThread) + super.addTriggers(triggers) + } + + override func removeTriggers(forKeys keys: [String]) { + removeCallsOnMain.append(Thread.isMainThread) + super.removeTriggers(forKeys: keys) + } + + override func messageMatchesTriggers(_ message: OSInAppMessageInternal) -> Bool { + matchCallsOnMain.append(Thread.isMainThread) + return false + } + + override func hasSharedTriggers(_ message: OSInAppMessageInternal, newTriggersKeys: [String]) -> Bool { + sharedTriggerCallsOnMain.append(Thread.isMainThread) + return false + } + } + + private var controller: OSMessagingController! + private var triggerController: RecordingTriggerController! + + override func setUpWithError() throws { + OneSignalCoreMocks.clearUserDefaults() + OneSignalUserMocks.reset() + OSMessagingController.removeInstance() + OneSignalIdentifiers.currentAppId = "test-app-id" + + controller = OSMessagingController.sharedInstance() + triggerController = RecordingTriggerController() + triggerController.delegate = controller + controller.triggerController = triggerController + + // One message with redisplay state so trigger changes reach the redisplay check + let message = OSInAppMessageInternal.instance(withJson: IAMTestHelpers.testDefaultMessageJson())! + controller.messages = NSMutableArray(array: [message]) + controller.redisplayedInAppMessages[message.messageId] = message + } + + override func tearDownWithError() throws { + OSMessagingController.removeInstance() + } + + /// Runs `work` on a background queue while the main thread waits, so nothing handed to the + /// main queue can run until `drainMainQueue` is called. + private func runOffMain(_ work: @escaping () -> Void) { + let finished = DispatchSemaphore(value: 0) + DispatchQueue.global().async { + work() + finished.signal() + } + XCTAssertEqual(finished.wait(timeout: .now() + 5), .success, "background call did not return") + } + + private func drainMainQueue() { + let drained = expectation(description: "main queue drained") + DispatchQueue.main.async { drained.fulfill() } + wait(for: [drained], timeout: 5) + } + + func testAddTriggersOffMainRunsOnMainQueue() { + runOffMain { self.controller.addTriggers(["key": "value"]) } + + XCTAssertEqual(triggerController.sharedTriggerCallsOnMain, [], "redisplay state was read on the calling thread") + XCTAssertEqual(triggerController.addCallsOnMain, [], "trigger state was touched on the calling thread") + + drainMainQueue() + + XCTAssertEqual(triggerController.sharedTriggerCallsOnMain, [true]) + XCTAssertEqual(triggerController.addCallsOnMain, [true]) + XCTAssertEqual(triggerController.getTriggers()["key"] as? String, "value") + XCTAssertTrue(controller.earlySessionTriggers.contains("key")) + } + + func testRemoveTriggersOffMainRunsOnMainQueue() { + controller.addTriggers(["key": "value"]) + triggerController.sharedTriggerCallsOnMain.removeAll() + + runOffMain { self.controller.removeTriggers(forKeys: ["key"]) } + + XCTAssertEqual(triggerController.sharedTriggerCallsOnMain, [], "redisplay state was read on the calling thread") + XCTAssertEqual(triggerController.removeCallsOnMain, [], "trigger state was touched on the calling thread") + + drainMainQueue() + + XCTAssertEqual(triggerController.sharedTriggerCallsOnMain, [true]) + XCTAssertEqual(triggerController.removeCallsOnMain, [true]) + XCTAssertNil(triggerController.getTriggers()["key"]) + } + + func testClearTriggersOffMainRunsOnMainQueueAndClearsATriggerQueuedBefore() { + runOffMain { + self.controller.addTriggers(["key": "value"]) + self.controller.clearTriggers() + } + + XCTAssertEqual(triggerController.removeCallsOnMain, [], "trigger state was touched on the calling thread") + + drainMainQueue() + + XCTAssertEqual(triggerController.removeCallsOnMain, [true]) + XCTAssertTrue(triggerController.getTriggers().isEmpty) + } + + func testUnpausingOffMainEvaluatesOnMainQueueAndFlagIsImmediate() { + var pausedSeenByCaller = false + + runOffMain { + self.controller.setInAppMessagingPaused(true) + pausedSeenByCaller = self.controller.isInAppMessagingPaused() + self.controller.setInAppMessagingPaused(false) + } + + XCTAssertTrue(pausedSeenByCaller) + XCTAssertEqual(triggerController.matchCallsOnMain, [], "messages were evaluated on the calling thread") + + drainMainQueue() + + XCTAssertEqual(triggerController.matchCallsOnMain, [true]) + } +}