diff --git a/Sources/Userpilot/Analytics/AnalyticsPublisher.swift b/Sources/Userpilot/Analytics/AnalyticsPublisher.swift index f2e03221..e7c35583 100644 --- a/Sources/Userpilot/Analytics/AnalyticsPublisher.swift +++ b/Sources/Userpilot/Analytics/AnalyticsPublisher.swift @@ -348,7 +348,7 @@ extension AnalyticsPublisher: AnalyticsPublishing { * * Routing order: * 1. Drop while the app is not active. - * 2. Ignore unchanged identifies and cache pending user updates. + * 2. Cache identify events as the pending user. * 3. Hold in `initialQueue` until network readiness is known (NWPathMonitor is async). * 4. Persist to local storage when the network is known unavailable. * 5. Drop during socket shutdown. @@ -373,8 +373,8 @@ extension AnalyticsPublisher: AnalyticsPublishing { // Handle app state - drop events when app is not in active state guard sessionMonitorer?.isAppActive ?? false else { return } - // Drop identify events that carry nothing new. Non-identify events skip this. - if event.isIdentifyEvent, didHandleIdentifyEvent(event) { return } + // Cache the pending user until the backend acknowledges the identify + if event.isIdentifyEvent { storage.temporaryUser = event.toUser().toJson() } // Hold events while network monitor is still resolving initial state if !networkMonitor.isReady { @@ -482,26 +482,6 @@ extension AnalyticsPublisher: AnalyticsPublishing { ) } - /** - * Drops identify events that carry nothing new, and caches the pending user for the rest. - * - * - Parameter event: The identify event to handle - * - Returns: true if the event should be ignored, false if processing should continue - */ - private func didHandleIdentifyEvent(_ event: Event) -> Bool { - // A pending identify has not reached the backend yet, so a closed socket means - // `onSocketClosed` is replaying it, not a fresh host-app call. A replay must go through or - // the reconnect never re-identifies the user. - let isPendingReplay = storage.temporaryUser != nil && !socketManager.isSocketOpened - let carriesNoNewData = storage.user.isNotEmpty - && User.fromJson(storage.user).isSameIdentifyEvent(event: event) - - let shouldIgnore = carriesNoNewData && !isPendingReplay - // Update temporary cached user in storage - if !shouldIgnore { storage.temporaryUser = event.toUser().toJson() } - return shouldIgnore - } - /** * Handles events when socket is closed by attempting to open connection. * diff --git a/Sources/Userpilot/Storage/Models/User.swift b/Sources/Userpilot/Storage/Models/User.swift index 5da88568..28d061ac 100644 --- a/Sources/Userpilot/Storage/Models/User.swift +++ b/Sources/Userpilot/Storage/Models/User.swift @@ -8,7 +8,7 @@ // [Brief Description] // The `User` struct is a holder for user details in the Userpilot SDK. // It tracks information about user properties and company attributes and supports -// custom serialization, updating with events, and comparison between user objects. +// custom serialization and updating with events. // import Foundation @@ -115,218 +115,6 @@ extension User { userId = eventUserId return self } - - /// Compares this User with an Event to determine if they represent the same identify event - /// - Parameter event: The Event to compare against - /// - Returns: true if the user and event represent the same identify event - func isSameIdentifyEvent(event: Event) -> Bool { - // Fix: Compare userId with event.userId, not event.type.userId - guard userId == event.userId else { - return false - } - - // For partial updates, check if the event is a subset of existing user data - let eventProperties = event.properties ?? [:] - let eventCompany = event.company ?? [:] - - // If event has no properties/company data, consider it the same user (just userId match) - if eventProperties.isEmpty && eventCompany.isEmpty { - return true - } - - // Check if event properties are a subset of user properties with same values - let propertiesMatch = properties.containsAll(from: eventProperties) - let companyDataMatch = company.containsAll(from: eventCompany) - - return propertiesMatch && companyDataMatch - } - - /// Alternative method with different comparison strategies - func isSameIdentifyEvent(event: Event, strategy: ComparisonStrategy = .partialUpdate) -> Bool { - guard userId == event.userId else { - return false - } - - let eventProperties = event.properties ?? [:] - let eventCompany = event.company ?? [:] - - switch strategy { - case .exactMatch: - // Original behavior - exact match required - return properties.isEqual(to: eventProperties) && - company.isEqual(to: eventCompany) - - case .partialUpdate: - // New behavior - event can be subset of stored user - return properties.containsAll(from: eventProperties) && - company.containsAll(from: eventCompany) - - case .ignoreEmpty: - // Ignore empty properties in comparison - let filteredProperties = properties.filteringOutEmptyValues() - let filteredEventProperties = eventProperties.filteringOutEmptyValues() - let filteredCompany = company.filteringOutEmptyValues() - let filteredEventCompany = eventCompany.filteringOutEmptyValues() - - return filteredProperties.containsAll(from: filteredEventProperties) && - filteredCompany.containsAll(from: filteredEventCompany) - } - } -} - -internal enum ComparisonStrategy { - case exactMatch // Both objects must be identical - case partialUpdate // Event can be a subset of stored user - case ignoreEmpty // Ignore empty values in comparison -} - -/** - * Extension for `Dictionary` where the key is a `String` and the value is `Any`. - * Adds the ability to deeply compare dictionaries to determine if they are equal. - */ -internal extension Dictionary where Key == String, Value == Any { - - /// Compares the current dictionary to another dictionary with deep comparison - /// - Parameter other: The other dictionary to compare against - /// - Returns: true if both dictionaries are identical in terms of keys and values - func isEqual(to other: [String: Any]) -> Bool { - // Check if both dictionaries have the same size - guard self.count == other.count else { - return false - } - - // Check if all keys and values match - for (key, value) in self { - guard let otherValue = other[key] else { - return false - } - - if !compareValues(value, otherValue) { - return false - } - } - - return true - } - - /// Filters out empty values (empty strings, empty arrays, empty dictionaries) - /// - Returns: A new dictionary with empty values removed - func filteringOutEmptyValues() -> [String: Any] { - return self.compactMapValues { value in - switch value { - case let string as String: - return string.isEmpty ? nil : value - case let array as [Any]: - return array.isEmpty ? nil : value - case let dict as [String: Any]: - return dict.isEmpty ? nil : value - default: - return value - } - } - } - - /// Checks if this dictionary contains all key-value pairs from another dictionary - /// This enables partial update detection - the other dictionary can be a subset - /// - Parameter other: The dictionary to check against (can be partial) - /// - Returns: true if all key-value pairs in 'other' exist in this dictionary with same values - func containsAll(from other: [String: Any]) -> Bool { - // Empty dictionary is considered contained in any dictionary - guard !other.isEmpty else { - return true - } - - // Check if all key-value pairs in 'other' exist in self with same values - for (key, otherValue) in other { - guard let selfValue = self[key] else { - return false // Key doesn't exist in self - } - - if !compareValues(selfValue, otherValue) { - return false // Values don't match - } - } - - return true - } - - // MARK: - Private Helper Methods - - // Deeply compares Any type values with improved type handling - // swiftlint:disable:next cyclomatic_complexity - private func compareValues(_ lhs: Any, _ rhs: Any) -> Bool { - // Handle nil values - if isNil(lhs) && isNil(rhs) { - return true - } - if isNil(lhs) || isNil(rhs) { - return false - } - - switch (lhs, rhs) { - case (let left as Int, let right as Int): - return left == right - case (let left as String, let right as String): - return left == right - case (let left as Double, let right as Double): - return left.isEqual(to: right, tolerance: 0.0001) // Handle floating point precision - case (let left as Float, let right as Float): - return abs(left - right) < 0.0001 - case (let left as Bool, let right as Bool): - return left == right - case (let left as [String: Any], let right as [String: Any]): - return left.isEqual(to: right) - case (let left as [Any], let right as [Any]): - return compareArrays(left, right) - // Handle number type conversions - case (let left as Int, let right as Double): - return Double(left) == right - case (let left as Double, let right as Int): - return left == Double(right) - case (let left as NSNumber, let right as NSNumber): - return left.isEqual(to: right) - default: - // Fallback to NSObject comparison for other types - if let leftObj = lhs as? NSObject, let rightObj = rhs as? NSObject { - return leftObj.isEqual(rightObj) - } - return false - } - } - - /// Compares arrays of Any with deep comparison - private func compareArrays(_ lhs: [Any], _ rhs: [Any]) -> Bool { - guard lhs.count == rhs.count else { - return false - } - - for (leftValue, rightValue) in zip(lhs, rhs) { - // swiftlint:disable:next for_where - if !compareValues(leftValue, rightValue) { - return false - } - } - - return true - } - - /// Helper to check if a value is nil (including NSNull) - private func isNil(_ value: Any) -> Bool { - if value is NSNull { - return true - } - // Use reflection to check for nil optionals - let mirror = Mirror(reflecting: value) - return mirror.displayStyle == .optional && mirror.children.count == 0 - } -} - -// MARK: - Double Extension for Floating Point Comparison - -private extension Double { - func isEqual(to other: Double, tolerance: Double) -> Bool { - return abs(self - other) <= tolerance - } } /** diff --git a/Sources/Userpilot/Version.swift b/Sources/Userpilot/Version.swift index 1cb2a6d6..ad25c84a 100644 --- a/Sources/Userpilot/Version.swift +++ b/Sources/Userpilot/Version.swift @@ -14,5 +14,5 @@ import Foundation /// The current version of the Userpilot SDK. private let versionMajor = "1" private let versionMinor = "4" -private let versionPatch = "0" +private let versionPatch = "1" let userpilotVersion = "\(versionMajor).\(versionMinor).\(versionPatch)" diff --git a/Tests/UserpilotTests/Analytics/AnalyticsPublisherTests.swift b/Tests/UserpilotTests/Analytics/AnalyticsPublisherTests.swift index 59a43ae2..89900095 100644 --- a/Tests/UserpilotTests/Analytics/AnalyticsPublisherTests.swift +++ b/Tests/UserpilotTests/Analytics/AnalyticsPublisherTests.swift @@ -1261,27 +1261,6 @@ class AnalyticsPublisherTests: XCTestCase { XCTAssertEqual(publishCount, 1) } - func testPublish_withSameIdentifyEvent_shouldNotReprocess() { - // Arrange - let userId = "test-user" - let properties = ["name": "John"] - let identifyEvent = Event(type: .identify(userId), properties: properties) - - // Set up existing user - let user = User(userId: userId, properties: properties, company: [:]) - userpilot.storage.user = user.toJson() ?? "" - - var publishIdentifyEventCalled = false - userpilot.socketManager.onPublish = { _, _ in publishIdentifyEventCalled = true } - - // Act - analyticsPublisher.publish(identifyEvent) - - // Assert - // Should not reprocess same identify event - XCTAssertFalse(publishIdentifyEventCalled) - } - // MARK: - User Switch With A Screen Tracked Right After Identify /// A user switch blanks the user id via `clean()` while the new user's identify waits in the @@ -1514,23 +1493,15 @@ class AnalyticsPublisherTests: XCTestCase { XCTAssertEqual(published(), []) } - func testDuplicateIdentifyIsDropped() { + /// Identify de-duplication is the backend's job, so an identify matching the cached user is + /// still sent. + func testIdentifyCarryingNothingNewIsForwarded() { arrangeReloadableScreen() let published = recordPublishedEvents() analyticsPublisher.publish( Event(type: .identify("reload-user"), properties: ["plan": "pro"])) - XCTAssertEqual(published(), []) - } - - func testIdentifyCarryingNewDataIsForwarded() { - arrangeReloadableScreen() - let published = recordPublishedEvents() - - analyticsPublisher.publish( - Event(type: .identify("reload-user"), properties: ["plan": "enterprise"])) - XCTAssertEqual(published(), [Constants.Event.identifyEvent]) } diff --git a/Tests/UserpilotTests/Monitor/NetworkMonitorTests.swift b/Tests/UserpilotTests/Monitor/NetworkMonitorTests.swift index a60e368e..7dfb0e4f 100644 --- a/Tests/UserpilotTests/Monitor/NetworkMonitorTests.swift +++ b/Tests/UserpilotTests/Monitor/NetworkMonitorTests.swift @@ -94,9 +94,13 @@ final class NetworkMonitorTests: XCTestCase { private func arrangeOfflineOnALiveInterface(counter: ProbeCounter) { monitor.updateInterfaceState(hasInterface: true, connectionType: .wifi) // The interface debounce plus the probe are both async. - let settled = expectation(description: "first probe settled") - DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { settled.fulfill() } - wait(for: [settled], timeout: 2.0) + let settled = XCTNSPredicateExpectation( + predicate: NSPredicate { [weak monitor] _, _ in + monitor?.isReady == true + }, + object: nil + ) + wait(for: [settled], timeout: 5.0) XCTAssertGreaterThanOrEqual(counter.value, 1, "precondition: the first probe ran") XCTAssertFalse(monitor.isNetworkAvailable, "precondition: the monitor is offline") @@ -174,15 +178,21 @@ final class NetworkMonitorTests: XCTestCase { /// Without an interface there is nothing to reach through; the interface transition itself /// re-probes when one comes back. func testRecheckIfOffline_doesNothing_whileTheInterfaceIsDown() { + // Exercise a debounce longer than the former fixed setup wait. + monitor.debounceDelay = 0.5 let counter = ProbeCounter() installProbe(failuresBeforeSuccess: .max, counter: counter) monitor.reachabilityRecheckInterval = 0 arrangeOfflineOnALiveInterface(counter: counter) monitor.updateInterfaceState(hasInterface: false, connectionType: .unknown) - let dropped = expectation(description: "interface drop settled") - DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { dropped.fulfill() } - wait(for: [dropped], timeout: 2.0) + let dropped = XCTNSPredicateExpectation( + predicate: NSPredicate { [weak monitor] _, _ in + monitor?.connectionType == .unknown + }, + object: nil + ) + wait(for: [dropped], timeout: 5.0) let afterDrop = counter.value for _ in 0..<5 { diff --git a/Tests/UserpilotTests/Storage/UserTests.swift b/Tests/UserpilotTests/Storage/UserTests.swift index 26d4754c..4ba83707 100644 --- a/Tests/UserpilotTests/Storage/UserTests.swift +++ b/Tests/UserpilotTests/Storage/UserTests.swift @@ -75,68 +75,4 @@ final class UserTests: XCTestCase { XCTAssertNil(updated.company["oldCompany"]) XCTAssertEqual(updated.company["company"] as? String, "new") } - - func testIsSameIdentifyEventSupportsPartialUpdates() { - let user = User( - userId: "user-1", - properties: [ - "name": "Jane", - "profile": ["role": "admin", "tier": 2], - "scores": [1, "two", true] - ], - company: ["id": "company-1", "size": 10] - ) - let event = Event( - type: .identify("user-1"), - properties: [ - "profile": ["role": "admin", "tier": 2], - "scores": [1, "two", true] - ], - company: ["id": "company-1"] - ) - - XCTAssertTrue(user.isSameIdentifyEvent(event: event)) - XCTAssertTrue(user.isSameIdentifyEvent(event: event, strategy: .partialUpdate)) - } - - func testIsSameIdentifyEventExactMatchRequiresSameKeys() { - let user = User(userId: "user-1", properties: ["name": "Jane", "age": 30], company: [:]) - let partialEvent = Event(type: .identify("user-1"), properties: ["name": "Jane"], company: [:]) - let exactEvent = Event(type: .identify("user-1"), properties: ["name": "Jane", "age": 30], company: [:]) - - XCTAssertFalse(user.isSameIdentifyEvent(event: partialEvent, strategy: .exactMatch)) - XCTAssertTrue(user.isSameIdentifyEvent(event: exactEvent, strategy: .exactMatch)) - } - - func testIsSameIdentifyEventIgnoreEmptyValues() { - let user = User( - userId: "user-1", - properties: ["name": "Jane", "empty": "", "emptyArray": [Any]()], - company: ["id": "company-1", "emptyCompany": [String: Any]()] - ) - let event = Event( - type: .identify("user-1"), - properties: ["name": "Jane", "empty": ""], - company: ["id": "company-1", "emptyCompany": [String: Any]()] - ) - - XCTAssertTrue(user.isSameIdentifyEvent(event: event, strategy: .ignoreEmpty)) - } - - func testDictionaryComparisonSupportsNumbersToleranceAndNilValues() { - let dict: [String: Any] = [ - "intAsDouble": 1, - "double": 1.00001, - "float": Float(2.00001), - "null": NSNull() - ] - - XCTAssertTrue(dict.containsAll(from: [ - "intAsDouble": 1.0, - "double": 1.00002, - "float": Float(2.00002), - "null": NSNull() - ])) - XCTAssertFalse(dict.containsAll(from: ["double": 1.2])) - } } diff --git a/Userpilot.podspec b/Userpilot.podspec index 0ae5e1d7..09233300 100644 --- a/Userpilot.podspec +++ b/Userpilot.podspec @@ -2,7 +2,7 @@ Pod::Spec.new do |spec| spec.name = "Userpilot" spec.module_name = "Userpilot" - spec.version = "1.4.0" + spec.version = "1.4.1" spec.summary = "Userpilot iOS SDK allows you to integrate Userpilot experiences into your native iOS apps" spec.description = <<-DESC