Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */; };
Expand Down Expand Up @@ -1382,6 +1383,7 @@
3C2DB2EF2DE6CB5E0006B905 /* OneSignalBadgeHelpers.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = OneSignalBadgeHelpers.h; sourceTree = "<group>"; };
3C2DB2F02DE6CB5E0006B905 /* OneSignalBadgeHelpers.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = OneSignalBadgeHelpers.m; sourceTree = "<group>"; };
3C30FE352F21FBE1001B9C25 /* EarlyTriggerTrackingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EarlyTriggerTrackingTests.swift; sourceTree = "<group>"; };
3C8B41D72F7E9A6C001B9C25 /* TriggerThreadingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TriggerThreadingTests.swift; sourceTree = "<group>"; };
3C3D34E82E95EAA5006A2924 /* LiveActivityConstants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveActivityConstants.swift; sourceTree = "<group>"; };
3C3D8D772E92DB7500C3E977 /* OSLiveActivityViewExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSLiveActivityViewExtensions.swift; sourceTree = "<group>"; };
3C427AC8301BB28A0059B8B7 /* OSOperationRepoFlushTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSOperationRepoFlushTests.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -2281,6 +2283,7 @@
3C7021E82ECF0CF4001768C6 /* IAMIntegrationTests.swift */,
3CAA4BB62F0BAFBA00A16682 /* TriggerTests.swift */,
3C30FE352F21FBE1001B9C25 /* EarlyTriggerTrackingTests.swift */,
3C8B41D72F7E9A6C001B9C25 /* TriggerThreadingTests.swift */,
3CB35FCA2F0FA20B000E6E0F /* OSMessagingControllerUserStateTests.swift */,
3C7021E72ECF0CF3001768C6 /* OneSignalInAppMessagesTests-Bridging-Header.h */,
);
Expand Down Expand Up @@ -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 */,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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];
});
}
}];
}
}

Expand Down Expand Up @@ -828,25 +847,34 @@ - (void)evaluateRedisplayedInAppMessages:(NSArray<NSString *> *)newTriggersKeys

#pragma mark Trigger Methods
- (void)addTriggers:(NSDictionary<NSString *, id> *)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<NSString *, id> *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<NSString *> *)keys {
[self evaluateRedisplayedInAppMessages:keys];
[self.triggerController removeTriggersForKeys:keys];
NSArray<NSString *> *removedKeys = [keys copy];
[self runOnMainQueue:^{
[self evaluateRedisplayedInAppMessages:removedKeys];
[self.triggerController removeTriggersForKeys:removedKeys];
}];
}

- (void)clearTriggers {
NSDictionary<NSString *, id> *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<NSString *, id> *allTriggers = [self getTriggers];
[self removeTriggersForKeys:allTriggers.allKeys];
}];
}

- (NSDictionary<NSString *, id> *)getTriggers {
Expand Down Expand Up @@ -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];
});
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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])
}
}
Loading