Skip to content

Commit 3f54136

Browse files
authored
[MOB-10616] Improve Criteria Update Frequency (#886)
1 parent c12c4a8 commit 3f54136

File tree

7 files changed

+251
-40
lines changed

7 files changed

+251
-40
lines changed

swift-sdk.xcodeproj/project.pbxproj

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
00B6FAD1210E8D90007535CF /* dev-1.mobileprovision in Resources */ = {isa = PBXBuildFile; fileRef = 00B6FAD0210E8D90007535CF /* dev-1.mobileprovision */; };
1313
00CB31B621096129004ACDEC /* TestUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = 00CB31B4210960C4004ACDEC /* TestUtils.swift */; };
1414
092D01942D3038F600E3066A /* NotificationObserverTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 092D01932D3038F600E3066A /* NotificationObserverTests.swift */; };
15+
09CAA47B2D4B9AD80057FB72 /* IterableApiCriteriaFetchTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 09CAA47A2D4B9AD80057FB72 /* IterableApiCriteriaFetchTests.swift */; };
1516
1802C00F2CA2C99E009DEA2B /* CombinationComplexCriteria.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1802C00E2CA2C99E009DEA2B /* CombinationComplexCriteria.swift */; };
1617
181063DB2C9841460078E0ED /* CustomEventUserUpdateTestCaseTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 181063DA2C9841460078E0ED /* CustomEventUserUpdateTestCaseTests.swift */; };
1718
181063DD2C994FA40078E0ED /* ValidateCustomEventUserUpdateAPITest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 181063DC2C994FA40078E0ED /* ValidateCustomEventUserUpdateAPITest.swift */; };
@@ -575,6 +576,7 @@
575576
00B6FAD0210E8D90007535CF /* dev-1.mobileprovision */ = {isa = PBXFileReference; lastKnownFileType = file; path = "dev-1.mobileprovision"; sourceTree = "<group>"; };
576577
00CB31B4210960C4004ACDEC /* TestUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestUtils.swift; sourceTree = "<group>"; };
577578
092D01932D3038F600E3066A /* NotificationObserverTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationObserverTests.swift; sourceTree = "<group>"; };
579+
09CAA47A2D4B9AD80057FB72 /* IterableApiCriteriaFetchTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IterableApiCriteriaFetchTests.swift; sourceTree = "<group>"; };
578580
1802C00E2CA2C99E009DEA2B /* CombinationComplexCriteria.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CombinationComplexCriteria.swift; sourceTree = "<group>"; };
579581
181063DA2C9841460078E0ED /* CustomEventUserUpdateTestCaseTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomEventUserUpdateTestCaseTests.swift; sourceTree = "<group>"; };
580582
181063DC2C994FA40078E0ED /* ValidateCustomEventUserUpdateAPITest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ValidateCustomEventUserUpdateAPITest.swift; sourceTree = "<group>"; };
@@ -1705,6 +1707,7 @@
17051707
E9EA7CA52C1EE39A00A9D6FB /* anonymous-tracking-tests */ = {
17061708
isa = PBXGroup;
17071709
children = (
1710+
09CAA47A2D4B9AD80057FB72 /* IterableApiCriteriaFetchTests.swift */,
17081711
E9EA7CA62C1EE3BA00A9D6FB /* AnonymousUserCriteriaMatchTests.swift */,
17091712
DF97D12A2C2D4A060034D38C /* AnonymousUserCriteriaIsSetTests.swift */,
17101713
DF7302142C2C176E0002633A /* AnonymousUserComplexCriteriaMatchTests.swift */,
@@ -2371,6 +2374,7 @@
23712374
DF7302152C2C176E0002633A /* AnonymousUserComplexCriteriaMatchTests.swift in Sources */,
23722375
18BB8B7A2C64DC8D007EBF23 /* ComparatorTypeDoesNotEqualMatchTest.swift in Sources */,
23732376
00B6FACC210E8484007535CF /* APNSTypeCheckerTests.swift in Sources */,
2377+
09CAA47B2D4B9AD80057FB72 /* IterableApiCriteriaFetchTests.swift in Sources */,
23742378
AC8F35A2239806B500302994 /* InboxViewControllerViewModelTests.swift in Sources */,
23752379
092D01942D3038F600E3066A /* NotificationObserverTests.swift in Sources */,
23762380
AC995F9A2166EEB50099A184 /* CommonMocks.swift in Sources */,

swift-sdk/Core/Constants.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ enum Const {
2626
static let deepLinkRegex = "/a/[a-zA-Z0-9]+"
2727
static let href = "href"
2828
static let exponentialFactor = 2.0
29+
static let criteriaFetchingCooldown = 120.0 // 120 seconds = 120,000 milliseconds
2930

3031
enum Http {
3132
static let GET = "GET"

swift-sdk/Internal/AnonymousUserManager.swift

Lines changed: 52 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ public class AnonymousUserManager: AnonymousUserManagerProtocol {
2727
private let dateProvider: DateProviderProtocol
2828
private let notificationStateProvider: NotificationStateProviderProtocol
2929
private var config: IterableConfig
30+
private(set) var lastCriteriaFetch: Double = 0
3031

3132
// Tracks an anonymous event and store it locally
3233
public func trackAnonEvent(name: String, dataFields: [AnyHashable: Any]?) {
@@ -85,38 +86,6 @@ public class AnonymousUserManager: AnonymousUserManagerProtocol {
8586
}
8687
}
8788

88-
// Creates a user after criterias met and login the user and then sync the data through track APIs
89-
private func createAnonymousUser(_ criteriaId: String) {
90-
var anonSessions = convertToDictionary(data: localStorage.anonymousSessions?.itbl_anon_sessions)
91-
let userId = IterableUtil.generateUUID()
92-
anonSessions[JsonKey.matchedCriteriaId] = Int(criteriaId)
93-
let appName = Bundle.main.appPackageName ?? ""
94-
notificationStateProvider.isNotificationsEnabled { isEnabled in
95-
if !appName.isEmpty && isEnabled {
96-
anonSessions[JsonKey.mobilePushOptIn] = appName
97-
}
98-
99-
//track anon session for new user
100-
IterableAPI.implementation?.apiClient.trackAnonSession(
101-
createdAt: IterableUtil.secondsFromEpoch(for: self.dateProvider.currentDate),
102-
withUserId: userId,
103-
dataFields: self.localStorage.anonymousUserUpdate,
104-
requestJson: anonSessions
105-
).onError { error in
106-
if error.httpStatusCode == 409 {
107-
self.getAnonCriteria() // refetch the criteria
108-
}
109-
}.onSuccess { success in
110-
self.localStorage.userIdAnnon = userId
111-
self.config.anonUserDelegate?.onAnonUserCreated(userId: userId)
112-
113-
IterableAPI.implementation?.setUserId(userId, isAnon: true)
114-
115-
self.syncNonSyncedEvents()
116-
}
117-
}
118-
}
119-
12089
// Syncs unsynced data which might have failed to sync when calling syncEvents for the first time after criterias met
12190
public func syncNonSyncedEvents() {
12291
DispatchQueue.main.asyncAfter(deadline: .now() + 2) { // little delay necessary in case it takes time to store userIdAnon in localstorage
@@ -174,6 +143,57 @@ public class AnonymousUserManager: AnonymousUserManagerProtocol {
174143
localStorage.anonymousSessions = nil
175144
localStorage.anonymousUserUpdate = nil
176145
}
146+
147+
// Gets the anonymous criteria
148+
public func getAnonCriteria() {
149+
lastCriteriaFetch = Date().timeIntervalSince1970 * 1000
150+
151+
IterableAPI.implementation?.getCriteriaData { returnedData in
152+
self.localStorage.criteriaData = returnedData
153+
};
154+
}
155+
156+
// Gets the last criteria fetch time in milliseconds
157+
public func getLastCriteriaFetch() -> Double {
158+
return lastCriteriaFetch
159+
}
160+
161+
// Sets the last criteria fetch time in milliseconds
162+
public func updateLastCriteriaFetch(currentTime: Double) {
163+
lastCriteriaFetch = currentTime
164+
}
165+
166+
// Creates a user after criterias met and login the user and then sync the data through track APIs
167+
private func createAnonymousUser(_ criteriaId: String) {
168+
var anonSessions = convertToDictionary(data: localStorage.anonymousSessions?.itbl_anon_sessions)
169+
let userId = IterableUtil.generateUUID()
170+
anonSessions[JsonKey.matchedCriteriaId] = Int(criteriaId)
171+
let appName = Bundle.main.appPackageName ?? ""
172+
notificationStateProvider.isNotificationsEnabled { isEnabled in
173+
if !appName.isEmpty && isEnabled {
174+
anonSessions[JsonKey.mobilePushOptIn] = appName
175+
}
176+
177+
//track anon session for new user
178+
IterableAPI.implementation?.apiClient.trackAnonSession(
179+
createdAt: IterableUtil.secondsFromEpoch(for: self.dateProvider.currentDate),
180+
withUserId: userId,
181+
dataFields: self.localStorage.anonymousUserUpdate,
182+
requestJson: anonSessions
183+
).onError { error in
184+
if error.httpStatusCode == 409 {
185+
self.getAnonCriteria() // refetch the criteria
186+
}
187+
}.onSuccess { success in
188+
self.localStorage.userIdAnnon = userId
189+
self.config.anonUserDelegate?.onAnonUserCreated(userId: userId)
190+
191+
IterableAPI.implementation?.setUserId(userId, isAnon: true)
192+
193+
self.syncNonSyncedEvents()
194+
}
195+
}
196+
}
177197

178198
// Checks if criterias are being met and returns criteriaId if it matches the criteria.
179199
private func evaluateCriteriaAndReturnID() -> String? {
@@ -194,13 +214,6 @@ public class AnonymousUserManager: AnonymousUserManagerProtocol {
194214
return CriteriaCompletionChecker(anonymousCriteria: criteriaData, anonymousEvents: events).getMatchedCriteria()
195215
}
196216

197-
// Gets the anonymous criteria
198-
public func getAnonCriteria() {
199-
IterableAPI.implementation?.getCriteriaData { returnedData in
200-
self.localStorage.criteriaData = returnedData
201-
};
202-
}
203-
204217
// Stores event data locally
205218
private func storeEventData(type: String, data: [AnyHashable: Any], shouldOverWrite: Bool = false) {
206219
// Early return if no AUT consent was given

swift-sdk/Internal/AnonymousUserManagerProtocol.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ import Foundation
1212
func trackAnonTokenRegistration(token: String)
1313
func trackAnonUpdateUser(_ dataFields: [AnyHashable: Any])
1414
func updateAnonSession()
15+
func getLastCriteriaFetch() -> Double
16+
func updateLastCriteriaFetch(currentTime: Double)
1517
func getAnonCriteria()
1618
func syncEvents()
1719
func clearVisitorEventsAndUserData()

swift-sdk/Internal/InternalIterableAPI.swift

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -720,7 +720,7 @@ final class InternalIterableAPI: NSObject, PushTrackerProtocol, AuthProvider {
720720
}
721721

722722

723-
func isSDKInitialized() -> Bool {
723+
public func isSDKInitialized() -> Bool {
724724
let isInitialized = !apiKey.isEmpty && isEitherUserIdOrEmailSet()
725725

726726
if !isInitialized {
@@ -734,6 +734,10 @@ final class InternalIterableAPI: NSObject, PushTrackerProtocol, AuthProvider {
734734
IterableUtil.isNotNullOrEmpty(string: _email) || IterableUtil.isNotNullOrEmpty(string: _userId)
735735
}
736736

737+
public func noUserLoggedIn() -> Bool {
738+
IterableUtil.isNullOrEmpty(string: _email) && IterableUtil.isNullOrEmpty(string: _userId)
739+
}
740+
737741
public func isAnonUserSet() -> Bool {
738742
IterableUtil.isNotNullOrEmpty(string: localStorage.userIdAnnon)
739743
}
@@ -904,6 +908,11 @@ final class InternalIterableAPI: NSObject, PushTrackerProtocol, AuthProvider {
904908
}
905909

906910
@objc private func onAppDidBecomeActiveNotification(notification: Notification) {
911+
handlePushNotificationState()
912+
handleMatchingCriteriaState()
913+
}
914+
915+
private func handlePushNotificationState() {
907916
guard config.autoPushRegistration else { return }
908917

909918
notificationStateProvider.isNotificationsEnabled { [weak self] systemEnabled in
@@ -928,6 +937,24 @@ final class InternalIterableAPI: NSObject, PushTrackerProtocol, AuthProvider {
928937
}
929938
}
930939

940+
private func handleMatchingCriteriaState() {
941+
guard config.enableForegroundCriteriaFetch else { return }
942+
943+
let currentTime = Date().timeIntervalSince1970 * 1000 // Convert to milliseconds
944+
945+
// fetching anonymous user criteria on foregrounding
946+
if noUserLoggedIn()
947+
&& !isAnonUserSet()
948+
&& config.enableAnonActivation
949+
&& getVisitorUsageTracked()
950+
&& (currentTime - anonymousUserManager.getLastCriteriaFetch() >= Const.criteriaFetchingCooldown) {
951+
952+
anonymousUserManager.updateLastCriteriaFetch(currentTime: currentTime)
953+
anonymousUserManager.getAnonCriteria()
954+
ITBInfo("Fetching anonymous user criteria - Foreground")
955+
}
956+
}
957+
931958
private func handle(launchOptions: [UIApplication.LaunchOptionsKey: Any]?) {
932959
guard let launchOptions = launchOptions else {
933960
return

swift-sdk/SDK/IterableConfig.swift

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,11 @@ public class IterableConfig: NSObject {
153153

154154
/// When set to `true`, IterableSDK will track all events when users are not logged into the application.
155155
public var enableAnonActivation = true
156+
157+
/// Enables fetching of anonymous user criteria on foreground when set to `true`
158+
/// By default, the SDK will fetch anonymous user criteria on foreground.
159+
public var enableForegroundCriteriaFetch = true
160+
156161
/// Allows for fetching embedded messages.
157162
public var enableEmbeddedMessaging = false
158163

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
//
2+
// IterableApiCriteriaFetchTests.swift
3+
// swift-sdk
4+
//
5+
// Created by Joao Dordio on 30/01/2025.
6+
// Copyright © 2025 Iterable. All rights reserved.
7+
//
8+
9+
import XCTest
10+
11+
@testable import IterableSDK
12+
13+
class IterableApiCriteriaFetchTests: XCTestCase {
14+
private var mockNetworkSession: MockNetworkSession!
15+
private var mockDateProvider: MockDateProvider!
16+
private var mockNotificationCenter: MockNotificationCenter!
17+
private var internalApi: InternalIterableAPI!
18+
private var mockApplicationStateProvider: MockApplicationStateProvider!
19+
private static let apiKey = "zeeApiKey"
20+
let localStorage = MockLocalStorage()
21+
22+
override func setUp() {
23+
super.setUp()
24+
mockNetworkSession = MockNetworkSession()
25+
mockDateProvider = MockDateProvider()
26+
mockNotificationCenter = MockNotificationCenter()
27+
mockApplicationStateProvider = MockApplicationStateProvider(applicationState: .active)
28+
}
29+
30+
override func tearDown() {
31+
mockNetworkSession = nil
32+
mockDateProvider = nil
33+
mockNotificationCenter = nil
34+
internalApi = nil
35+
mockApplicationStateProvider = nil
36+
super.tearDown()
37+
}
38+
39+
func testForegroundCriteriaFetchWhenConditionsMet() {
40+
let expectation1 = expectation(description: "First criteria fetch")
41+
expectation1.expectedFulfillmentCount = 2
42+
43+
mockNetworkSession.responseCallback = { urlRequest in
44+
if urlRequest.absoluteString.contains(Const.Path.getCriteria) == true {
45+
expectation1.fulfill()
46+
}
47+
return nil
48+
}
49+
50+
let config = IterableConfig()
51+
config.enableAnonActivation = true
52+
config.enableOnForegroundCriteriaFetching = true
53+
54+
IterableAPI.initializeForTesting(apiKey: IterableApiCriteriaFetchTests.apiKey,
55+
config: config,
56+
networkSession: mockNetworkSession,
57+
localStorage: localStorage)
58+
59+
internalApi = InternalIterableAPI.initializeForTesting(
60+
config: config,
61+
dateProvider: mockDateProvider,
62+
networkSession: mockNetworkSession,
63+
applicationStateProvider: mockApplicationStateProvider,
64+
notificationCenter: mockNotificationCenter
65+
)
66+
67+
internalApi.setVisitorUsageTracked(isVisitorUsageTracked: true)
68+
sleep(5)
69+
// Simulate app coming to foreground
70+
mockNotificationCenter.post(name: UIApplication.didBecomeActiveNotification, object: nil, userInfo: nil)
71+
72+
wait(for: [expectation1], timeout: testExpectationTimeout)
73+
}
74+
75+
func testCriteriaFetchNotCalledWhenDisabled() {
76+
let expectation1 = expectation(description: "No criteria fetch")
77+
expectation1.isInverted = true
78+
79+
mockNetworkSession.responseCallback = { urlRequest in
80+
if urlRequest.absoluteString.contains(Const.Path.getCriteria) == true {
81+
expectation1.fulfill()
82+
}
83+
return nil
84+
}
85+
86+
let config = IterableConfig()
87+
config.enableAnonActivation = true
88+
config.enableOnForegroundCriteriaFetching = false
89+
90+
internalApi = InternalIterableAPI.initializeForTesting(
91+
config: config,
92+
dateProvider: mockDateProvider,
93+
networkSession: mockNetworkSession,
94+
applicationStateProvider: mockApplicationStateProvider,
95+
notificationCenter: mockNotificationCenter
96+
)
97+
internalApi.setVisitorUsageTracked(isVisitorUsageTracked: true)
98+
99+
// Simulate app coming to foreground
100+
mockNotificationCenter.post(name: UIApplication.didBecomeActiveNotification, object: nil, userInfo: nil)
101+
102+
wait(for: [expectation1], timeout: testExpectationTimeout)
103+
}
104+
105+
func testForegroundCriteriaFetchWithCooldown() {
106+
let expectation1 = expectation(description: "First criteria fetch")
107+
let expectation2 = expectation(description: "Second criteria fetch")
108+
let expectation3 = expectation(description: "No third fetch during cooldown")
109+
expectation3.isInverted = true
110+
111+
var fetchCount = 0
112+
mockNetworkSession.responseCallback = { urlRequest in
113+
if urlRequest.absoluteString.contains(Const.Path.getCriteria) == true {
114+
fetchCount += 1
115+
switch fetchCount {
116+
case 1: expectation1.fulfill()
117+
case 2: expectation2.fulfill()
118+
case 3: expectation3.fulfill()
119+
default: break
120+
}
121+
}
122+
return nil
123+
}
124+
125+
let config = IterableConfig()
126+
config.enableAnonActivation = true
127+
config.enableOnForegroundCriteriaFetching = true
128+
129+
IterableAPI.initializeForTesting(apiKey: IterableApiCriteriaFetchTests.apiKey,
130+
config: config,
131+
networkSession: mockNetworkSession,
132+
localStorage: localStorage)
133+
134+
internalApi = InternalIterableAPI.initializeForTesting(
135+
config: config,
136+
dateProvider: mockDateProvider,
137+
networkSession: mockNetworkSession,
138+
applicationStateProvider: mockApplicationStateProvider,
139+
notificationCenter: mockNotificationCenter
140+
)
141+
142+
internalApi.setVisitorUsageTracked(isVisitorUsageTracked: true)
143+
144+
sleep(5)
145+
146+
// First foreground
147+
mockNotificationCenter.post(name: UIApplication.didBecomeActiveNotification, object: nil, userInfo: nil)
148+
149+
// Second foreground after some time
150+
mockDateProvider.currentDate = mockDateProvider.currentDate.addingTimeInterval(130) // After cooldown
151+
mockNotificationCenter.post(name: UIApplication.didBecomeActiveNotification, object: nil, userInfo: nil)
152+
153+
// Third foreground during cooldown
154+
mockDateProvider.currentDate = mockDateProvider.currentDate.addingTimeInterval(10) // Within cooldown
155+
mockNotificationCenter.post(name: UIApplication.didBecomeActiveNotification, object: nil, userInfo: nil)
156+
157+
wait(for: [expectation1, expectation2, expectation3], timeout: testExpectationTimeout)
158+
}
159+
}

0 commit comments

Comments
 (0)