Skip to content

Commit 7531828

Browse files
authored
fix: Retry IAM fetch when OneSignal ID becomes available (#1626)
* fix: Retry IAM fetch when OneSignal ID becomes available When getInAppMessagesFromServer is called early in the app lifecycle while users are changing (e.g., login is called after initialize), the OneSignal ID may not be available yet, causing the fetch to fail silently. This fix ensures in-app messages are eventually fetched by: - Adding OSUserStateObserver to OSMessagingController - Storing the subscription ID when fetch fails due to missing OneSignal ID - Retrying the fetch when user state changes and OneSignal ID becomes available This prevents in-app messages from never being fetched when users are identified early in the app startup lifecycle. * chore: ignore .build/ files * add tests for messaging controller and user state * Add test class OSMessagingControllerUserStateTests * Update some existing test helpers
1 parent 8b7218d commit 7531828

10 files changed

Lines changed: 235 additions & 8 deletions

File tree

.gitignore

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,4 +17,5 @@ profile
1717
DerivedData
1818
.idea/
1919
iOS_SDK/Carthage/Build
20-
/temp/
20+
/temp/
21+
.build/

iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,7 @@
150150
3CA8B8822BEC2FCB0010ADA1 /* XCTest.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3C7A39D42B7C18EE0082665E /* XCTest.framework */; };
151151
3CA8B8832BEC2FCB0010ADA1 /* XCTest.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 3C7A39D42B7C18EE0082665E /* XCTest.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; };
152152
3CAA4BB72F0BAFBA00A16682 /* TriggerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CAA4BB62F0BAFBA00A16682 /* TriggerTests.swift */; };
153+
3CB35FCB2F0FA20B000E6E0F /* OSMessagingControllerUserStateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CB35FCA2F0FA20B000E6E0F /* OSMessagingControllerUserStateTests.swift */; };
153154
3CBB6C262ED59CCC000FEB02 /* ConsistencyManagerTestHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CBB6C252ED59CCC000FEB02 /* ConsistencyManagerTestHelpers.swift */; };
154155
3CC063942B6D6B6B002BB07F /* OneSignalCore.m in Sources */ = {isa = PBXBuildFile; fileRef = 3CC063932B6D6B6B002BB07F /* OneSignalCore.m */; };
155156
3CC063A22B6D7A8E002BB07F /* OneSignalCoreMocks.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3CC0639A2B6D7A8C002BB07F /* OneSignalCoreMocks.framework */; };
@@ -1354,6 +1355,7 @@
13541355
3C9AD6D22B228BB000BC1540 /* OSRequestUpdateProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSRequestUpdateProperties.swift; sourceTree = "<group>"; };
13551356
3CA6CE0928E4F19B00CA0585 /* OSUserRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSUserRequest.swift; sourceTree = "<group>"; };
13561357
3CAA4BB62F0BAFBA00A16682 /* TriggerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TriggerTests.swift; sourceTree = "<group>"; };
1358+
3CB35FCA2F0FA20B000E6E0F /* OSMessagingControllerUserStateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSMessagingControllerUserStateTests.swift; sourceTree = "<group>"; };
13571359
3CBB6C252ED59CCC000FEB02 /* ConsistencyManagerTestHelpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConsistencyManagerTestHelpers.swift; sourceTree = "<group>"; };
13581360
3CC063932B6D6B6B002BB07F /* OneSignalCore.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = OneSignalCore.m; sourceTree = "<group>"; };
13591361
3CC0639A2B6D7A8C002BB07F /* OneSignalCoreMocks.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = OneSignalCoreMocks.framework; sourceTree = BUILT_PRODUCTS_DIR; };
@@ -2158,6 +2160,7 @@
21582160
3C01519B2C2E29F90079E076 /* IAMRequestTests.m */,
21592161
3C7021E82ECF0CF4001768C6 /* IAMIntegrationTests.swift */,
21602162
3CAA4BB62F0BAFBA00A16682 /* TriggerTests.swift */,
2163+
3CB35FCA2F0FA20B000E6E0F /* OSMessagingControllerUserStateTests.swift */,
21612164
3C7021E72ECF0CF3001768C6 /* OneSignalInAppMessagesTests-Bridging-Header.h */,
21622165
);
21632166
path = OneSignalInAppMessagesTests;
@@ -4301,6 +4304,7 @@
43014304
3CAA4BB72F0BAFBA00A16682 /* TriggerTests.swift in Sources */,
43024305
3C7021E92ECF0CF4001768C6 /* IAMIntegrationTests.swift in Sources */,
43034306
3C01519C2C2E29F90079E076 /* IAMRequestTests.m in Sources */,
4307+
3CB35FCB2F0FA20B000E6E0F /* OSMessagingControllerUserStateTests.swift in Sources */,
43044308
);
43054309
runOnlyForDeploymentPostprocessing = 0;
43064310
};

iOS_SDK/OneSignalSDK/OneSignalCoreMocks/MockOneSignalClient.swift

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -202,9 +202,15 @@ extension MockOneSignalClient {
202202
return found
203203
}
204204

205-
public func hasExecutedRequestOfType(_ type: AnyClass) -> Bool {
206-
executedRequests.contains { request in
205+
public func hasExecutedRequestOfType(_ type: AnyClass, expectedCount: Int? = nil) -> Bool {
206+
let matchingCount = executedRequests.filter { request in
207207
request.isKind(of: type)
208+
}.count
209+
210+
if let expectedCount = expectedCount {
211+
return matchingCount == expectedCount
212+
} else {
213+
return matchingCount > 0
208214
}
209215
}
210216
}

iOS_SDK/OneSignalSDK/OneSignalInAppMessages/Controller/OSMessagingController.h

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ NS_ASSUME_NONNULL_BEGIN
3939

4040
@end
4141

42-
@interface OSMessagingController : NSObject <OSInAppMessageViewControllerDelegate, OSTriggerControllerDelegate, OSMessagingControllerDelegate, OSPushSubscriptionObserver>
42+
@interface OSMessagingController : NSObject <OSInAppMessageViewControllerDelegate, OSTriggerControllerDelegate, OSMessagingControllerDelegate, OSPushSubscriptionObserver, OSUserStateObserver>
4343

4444
@property (class, readonly) BOOL isInAppMessagingPaused;
4545

iOS_SDK/OneSignalSDK/OneSignalInAppMessages/Controller/OSMessagingController.m

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,9 @@ @interface OSMessagingController ()
146146

147147
@property (nonatomic) BOOL calledLoadTags;
148148

149+
/// set when we attempt getInAppMessagesFromServer and no onesignal ID is available yet
150+
@property (strong, nonatomic, nullable) NSString *shouldFetchOnUserChangeWithSubscriptionID;
151+
149152
@end
150153

151154
@implementation OSMessagingController
@@ -175,6 +178,7 @@ + (void)removeInstance {
175178
+ (void)start {
176179
OSMessagingController *shared = OSMessagingController.sharedInstance;
177180
[OneSignalUserManagerImpl.sharedInstance.pushSubscriptionImpl addObserver:shared];
181+
[OneSignalUserManagerImpl.sharedInstance addObserver:shared];
178182
}
179183

180184
static BOOL _isInAppMessagingPaused = false;
@@ -254,8 +258,10 @@ - (void)getInAppMessagesFromServer:(NSString *)subscriptionId {
254258
OSConsistencyManager *consistencyManager = [OSConsistencyManager shared];
255259
NSString *onesignalId = OneSignalUserManagerImpl.sharedInstance.onesignalId;
256260

261+
// NOTE: Check for subscription ID above first, before checking for OneSignal ID next
257262
if (!onesignalId) {
258-
[OneSignalLog onesignalLog:ONE_S_LL_VERBOSE message:@"Failed to get in app messages due to no OneSignal ID"];
263+
[OneSignalLog onesignalLog:ONE_S_LL_VERBOSE message:@"Failed to get in app messages due to no OneSignal ID, will reattempt"];
264+
self.shouldFetchOnUserChangeWithSubscriptionID = subscriptionId;
259265
return;
260266
}
261267

@@ -1198,6 +1204,15 @@ - (void)onPushSubscriptionDidChangeWithState:(OSPushSubscriptionChangedState * _
11981204
[self getInAppMessagesFromServer:state.current.id];
11991205
}
12001206

1207+
- (void)onUserStateDidChangeWithState:(OSUserChangedState * _Nonnull)state {
1208+
if (state.current.onesignalId != nil && self.shouldFetchOnUserChangeWithSubscriptionID) {
1209+
[OneSignalLog onesignalLog:ONE_S_LL_VERBOSE message:@"OSMessagingController onUserStateDidChangeWithState: changed to new valid onesignal id"];
1210+
NSString *subscriptionID = self.shouldFetchOnUserChangeWithSubscriptionID;
1211+
self.shouldFetchOnUserChangeWithSubscriptionID = nil;
1212+
[self getInAppMessagesFromServer:subscriptionID];
1213+
}
1214+
}
1215+
12011216
- (void)dealloc {
12021217
[[NSNotificationCenter defaultCenter] removeObserver:self];
12031218
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# in tests, we may want to force cast and throw any errors
2+
disabled_rules:
3+
- force_cast
Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
/*
2+
Modified MIT License
3+
4+
Copyright 2025 OneSignal
5+
6+
Permission is hereby granted, free of charge, to any person obtaining a copy
7+
of this software and associated documentation files (the "Software"), to deal
8+
in the Software without restriction, including without limitation the rights
9+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10+
copies of the Software, and to permit persons to whom the Software is
11+
furnished to do so, subject to the following conditions:
12+
13+
1. The above copyright notice and this permission notice shall be included in
14+
all copies or substantial portions of the Software.
15+
16+
2. All copies of substantial portions of the Software may only be used in connection
17+
with services provided by OneSignal.
18+
19+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
20+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
21+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
22+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
23+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
24+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
25+
THE SOFTWARE.
26+
*/
27+
28+
import XCTest
29+
import OneSignalOSCore
30+
import OneSignalCoreMocks
31+
import OneSignalOSCoreMocks
32+
import OneSignalUserMocks
33+
@testable import OneSignalUser
34+
35+
/**
36+
Tests for OSMessagingController's user state observer functionality.
37+
38+
These tests verify that IAM fetching is retried when the OneSignal ID becomes available
39+
after an initial fetch attempt fails due to missing OneSignal ID during early app startup.
40+
41+
Related to PR: https://github.com/OneSignal/OneSignal-iOS-SDK/pull/1626
42+
*/
43+
final class OSMessagingControllerUserStateTests: XCTestCase {
44+
45+
private let testSubscriptionId = "test-subscription-id-12345"
46+
private let testOneSignalId = "test-onesignal-id-12345"
47+
private let testExternalId = "test-external-id-12345"
48+
private let testAppId = "test-app-id"
49+
50+
override func setUpWithError() throws {
51+
OneSignalCoreMocks.clearUserDefaults()
52+
OneSignalUserMocks.reset()
53+
OSConsistencyManager.shared.reset()
54+
OSMessagingController.removeInstance()
55+
56+
// Set up basic configuration
57+
OneSignalConfigManager.setAppId(testAppId)
58+
OneSignalLog.setLogLevel(.LL_VERBOSE)
59+
}
60+
61+
override func tearDownWithError() throws {
62+
OSMessagingController.removeInstance()
63+
}
64+
65+
/**
66+
Test that when getInAppMessagesFromServer is called without a OneSignal ID, it stores the subscription ID for later retry.
67+
68+
Scenario:
69+
- User calls initialize() and login() early in app startup
70+
- Push subscription ID is available but OneSignal ID is not yet set
71+
- IAM fetch should be deferred
72+
73+
Expected:
74+
- shouldFetchOnUserChangeWithSubscriptionID property is set with the subscription ID
75+
- No IAM fetch actually occurs
76+
*/
77+
func testStoresSubscriptionIDWhenOneSignalIDUnavailable() throws {
78+
/* Setup */
79+
let client = MockOneSignalClient()
80+
OneSignalCoreImpl.setSharedClient(client)
81+
82+
// Note: nothing has set OneSignal ID
83+
84+
/* Execute */
85+
OneSignalInAppMessages.getFromServer(testSubscriptionId)
86+
OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5)
87+
88+
/* Verify */
89+
// The controller should have stored the subscription ID for retry
90+
let shouldFetchOnUserChangeWithSubscriptionID = OSMessagingController.sharedInstance().value(forKey: "shouldFetchOnUserChangeWithSubscriptionID")
91+
XCTAssertEqual(shouldFetchOnUserChangeWithSubscriptionID as! String, testSubscriptionId)
92+
93+
// Verify no IAM request was actually made (since we don't have OneSignal ID)
94+
XCTAssertTrue(client.hasExecutedRequestOfType(OSRequestGetInAppMessages.self, expectedCount: 0))
95+
}
96+
97+
/**
98+
Test that when user state changes with a valid OneSignal ID and shouldFetchOnUserChangeWithSubscriptionID is set, it retries the fetch.
99+
100+
Scenario:
101+
- IAM fetch was previously deferred due to missing OneSignal ID
102+
- User response is returned and OneSignal ID becomes available
103+
- User state change observer is triggered
104+
105+
Expected:
106+
- IAM fetch is retried with the stored subscription ID
107+
- shouldFetchOnUserChangeWithSubscriptionID is cleared
108+
*/
109+
func testRetriesFetchWhenUserStateChangesWithValidOneSignalID() throws {
110+
/* Setup */
111+
let client = MockOneSignalClient()
112+
OneSignalCoreImpl.setSharedClient(client)
113+
OSMessagingController.start()
114+
let controller = OSMessagingController.sharedInstance()
115+
116+
// Unblock consistency manager
117+
ConsistencyManagerTestHelpers.setDefaultRywToken(id: testOneSignalId)
118+
119+
/* Execute */
120+
// Setup the anonymous user
121+
MockUserRequests.setDefaultCreateAnonUserResponses(with: client, onesignalId: testOneSignalId, subscriptionId: testSubscriptionId)
122+
OneSignalUserManagerImpl.sharedInstance.start()
123+
124+
// Login to a new user, without setting the client response, so onesignal ID is not hydrated
125+
OneSignalUserManagerImpl.sharedInstance.login(externalId: testExternalId, token: nil)
126+
127+
// First attempt: Try to fetch IAMs without OneSignal ID (should be deferred)
128+
OneSignalInAppMessages.getFromServer(testSubscriptionId)
129+
OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5)
130+
131+
// Verify the subscription ID was stored and no IAM fetch occurred
132+
XCTAssertEqual(controller.value(forKey: "shouldFetchOnUserChangeWithSubscriptionID") as! String, testSubscriptionId)
133+
XCTAssertTrue(client.hasExecutedRequestOfType(OSRequestGetInAppMessages.self, expectedCount: 0))
134+
135+
// Now let the login succeed, receive onesignal ID which fires user state observer
136+
MockUserRequests.setDefaultIdentifyUserResponses(with: client, externalId: testExternalId)
137+
OneSignalUserManagerImpl.sharedInstance.userExecutor?.userRequestQueue.first?.sentToClient = false
138+
OneSignalUserManagerImpl.sharedInstance.userExecutor?.executePendingRequests()
139+
OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5)
140+
141+
/* Verify */
142+
// The fetch should have been retried now that OneSignal ID is available
143+
XCTAssertTrue(client.hasExecutedRequestOfType(OSRequestGetInAppMessages.self, expectedCount: 1))
144+
145+
// The stored subscription ID should be cleared after successful retry
146+
XCTAssertNil(controller.value(forKey: "shouldFetchOnUserChangeWithSubscriptionID"))
147+
}
148+
149+
/**
150+
Test that when user state changes but shouldFetchOnUserChangeWithSubscriptionID is not set, it does nothing.
151+
152+
Scenario:
153+
- Normal user state change occurs
154+
- No deferred fetch was pending
155+
156+
Expected:
157+
- No retry logic is triggered
158+
- Normal operation continues
159+
*/
160+
func testDoesNothingWhenNoRetryPending() throws {
161+
/* Setup */
162+
let client = MockOneSignalClient()
163+
OneSignalCoreImpl.setSharedClient(client)
164+
OneSignalInAppMessages.start()
165+
let controller = OSMessagingController.sharedInstance()
166+
167+
// Set up user with valid OneSignal ID from the start
168+
MockUserRequests.setDefaultCreateAnonUserResponses(
169+
with: client,
170+
onesignalId: testOneSignalId,
171+
subscriptionId: testSubscriptionId
172+
)
173+
ConsistencyManagerTestHelpers.setDefaultRywToken(id: testOneSignalId)
174+
OneSignalUserManagerImpl.sharedInstance.start()
175+
OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5)
176+
177+
/* Verify */
178+
// IAM is fetched and no retry is pending
179+
XCTAssertTrue(client.hasExecutedRequestOfType(OSRequestGetInAppMessages.self, expectedCount: 1))
180+
XCTAssertNil(controller.value(forKey: "shouldFetchOnUserChangeWithSubscriptionID"))
181+
182+
/* Execute */
183+
// Trigger a normal user state change by login
184+
MockUserRequests.setDefaultIdentifyUserResponses(with: client, externalId: testExternalId)
185+
OneSignalUserManagerImpl.sharedInstance.login(externalId: testExternalId, token: nil)
186+
OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5)
187+
188+
/* Verify */
189+
// Does not fetch IAMs again
190+
XCTAssertTrue(client.hasExecutedRequestOfType(OSRequestGetInAppMessages.self, expectedCount: 1))
191+
}
192+
}

iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/OneSignalInAppMessagesTests-Bridging-Header.h

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,12 @@
44

55
#import "OSInAppMessageInternal.h"
66
#import "OSMessagingController.h"
7+
#import "OSInAppMessagingRequests.h"
78

89
// Expose private properties and methods for testing
910
@interface OSMessagingController (Testing)
1011
@property (strong, nonatomic, nonnull) NSMutableArray <OSInAppMessageInternal *> *messageDisplayQueue;
12+
+ (void)start;
13+
+ (void)removeInstance;
1114
- (void)presentInAppPreviewMessage:(OSInAppMessageInternal *)message;
1215
@end

iOS_SDK/OneSignalSDK/OneSignalUserMocks/MockUserRequests.swift

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -76,8 +76,11 @@ extension MockUserRequests {
7676
}
7777

7878
@objc
79-
public static func setDefaultCreateAnonUserResponses(with client: MockOneSignalClient) {
80-
let anonCreateResponse = testDefaultFullCreateUserResponse(onesignalId: anonUserOSID, externalId: nil, subscriptionId: testPushSubId)
79+
public static func setDefaultCreateAnonUserResponses(with client: MockOneSignalClient, onesignalId: String? = nil, subscriptionId: String? = nil) {
80+
let anonCreateResponse = testDefaultFullCreateUserResponse(
81+
onesignalId: onesignalId ?? anonUserOSID,
82+
externalId: nil,
83+
subscriptionId: subscriptionId ?? testPushSubId)
8184

8285
client.setMockResponseForRequest(
8386
request: "<OSRequestCreateUser with external_id: nil>",

iOS_SDK/OneSignalSDK/OneSignalUserTests/OneSignalUserObjcTests.m

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ - (void)testSendPurchases {
3737
[OneSignalUserManagerImpl.sharedInstance start];
3838

3939
// 1. Set up mock responses for the anonymous user
40-
[MockUserRequests setDefaultCreateAnonUserResponsesWith:client];
40+
[MockUserRequests setDefaultCreateAnonUserResponsesWith:client onesignalId:nil subscriptionId:nil];
4141
[OneSignalCoreImpl setSharedClient:client];
4242

4343
/* When */

0 commit comments

Comments
 (0)